package storage import ( "bytes" "context" "errors" "fmt" "io" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/aws/smithy-go" ) // S3Driver legt Objekte in einem S3-kompatiblen Objektspeicher ab — der // Produktions-Treiber (Akzeptanzkriterium 1). Funktioniert gegen echtes // AWS S3 UND gegen jeden S3-kompatiblen Anbieter (MinIO etc.) ueber // endpointURL — bewusst offenes Objektformat statt Herstellerbindung // (Produkt-DNA: "jederzeit ohne Herstellerwerkzeug lesbar"). type S3Driver struct { client *s3.Client bucket string } // NewS3Driver verbindet zu einem S3-kompatiblen Endpunkt. endpointURL leer // laesst den AWS-SDK-Standardendpunkt (echtes AWS S3) gelten, // usePathStyle=true ist fuer die meisten Nicht-AWS-S3-kompatiblen Anbieter // (MinIO, etc.) noetig. func NewS3Driver(ctx context.Context, bucket, region, endpointURL, accessKeyID, secretAccessKey string, usePathStyle bool) (*S3Driver, error) { cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region), config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, "")), ) if err != nil { return nil, fmt.Errorf("storage: s3-konfiguration laden: %w", err) } client := s3.NewFromConfig(cfg, func(o *s3.Options) { if endpointURL != "" { o.BaseEndpoint = aws.String(endpointURL) } o.UsePathStyle = usePathStyle }) return &S3Driver{client: client, bucket: bucket}, nil } func (d *S3Driver) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (int64, error) { buf, err := io.ReadAll(r) if err != nil { return 0, fmt.Errorf("storage: objekt vor upload lesen: %w", err) } _, err = d.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(d.bucket), Key: aws.String(key), Body: bytesReader(buf), ContentLength: aws.Int64(int64(len(buf))), ContentType: aws.String(contentType), }) if err != nil { return 0, fmt.Errorf("storage: s3-upload: %w", err) } return int64(len(buf)), nil } func (d *S3Driver) Get(ctx context.Context, key string) (io.ReadCloser, error) { out, err := d.client.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(d.bucket), Key: aws.String(key), }) if err != nil { if isS3NotFound(err) { return nil, ErrNotFound } return nil, fmt.Errorf("storage: s3-download: %w", err) } return out.Body, nil } func (d *S3Driver) Delete(ctx context.Context, key string) error { // S3 liefert bei DeleteObject fuer ein nicht existierendes Objekt KEINEN // Fehler (idempotente Semantik der S3-API) — um denselben Vertrag wie // LocalDriver (ErrNotFound bei fehlendem Objekt) zu erfuellen, wird die // Existenz vorher explizit geprueft (Pruefung 3). _, err := d.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(d.bucket), Key: aws.String(key)}) if err != nil { if isS3NotFound(err) { return ErrNotFound } return fmt.Errorf("storage: s3-existenzpruefung vor loeschen: %w", err) } if _, err := d.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(d.bucket), Key: aws.String(key), }); err != nil { return fmt.Errorf("storage: s3-loeschen: %w", err) } return nil } func (d *S3Driver) SignedURL(ctx context.Context, key string, ttl time.Duration) (string, error) { presignClient := s3.NewPresignClient(d.client) req, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(d.bucket), Key: aws.String(key), }, s3.WithPresignExpires(ttl)) if err != nil { return "", fmt.Errorf("storage: presigned url erzeugen: %w", err) } return req.URL, nil } func bytesReader(b []byte) *bytes.Reader { return bytes.NewReader(b) } // isS3NotFound erkennt sowohl den typisierten NoSuchKey-Fehler // (GetObject) als auch den generischen "NotFound"-API-Fehlercode // (HeadObject liefert keinen typisierten NoSuchKey, sondern einen // generischen smithy-API-Fehler mit Code "NotFound"). func isS3NotFound(err error) bool { var nsk *types.NoSuchKey if errors.As(err, &nsk) { return true } var apiErr smithy.APIError if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NotFound" { return true } return false }