package storage import ( "bytes" "context" "errors" "fmt" "io" "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.) über // endpointURL. Gleiches, bewährtes Muster wie DMS FDN-03s S3Driver // (bewusste Kopie, Mail kann DMS nicht importieren). type S3Driver struct { client *s3.Client bucket string } 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, _ 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: bytes.NewReader(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 S3-API-Semantik) — um denselben // Vertrag wie LocalDriver (ErrNotFound bei fehlendem Objekt) zu // erfüllen, wird die Existenz vorher explizit geprüft. _, 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-existenzprüfung vor löschen: %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-löschen: %w", err) } return nil } 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 }