package storage import ( "context" "errors" "fmt" "io" "net/http" "time" "github.com/aws/aws-sdk-go-v2/aws" awshttp "github.com/aws/aws-sdk-go-v2/aws/transport/http" awsconfig "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" "git.azzamo.net/Michilis/Spanglish/photo-api/internal/config" ) // s3Store targets AWS S3 or path-style compatibles (Garage/MinIO), same as // the backend's S3 backend. The bucket is never public: downloads use // short-lived presigned GETs so visibility rules keep holding on S3. type s3Store struct { client *s3.Client presign *s3.PresignClient bucket string } // NewS3 builds the S3 backend explicitly, whichever backend is active — // `photo-api sync` needs both sides at once. func NewS3(cfg config.Config) (Storage, error) { return newS3(cfg) } func newS3(cfg config.Config) (*s3Store, error) { awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), awsconfig.WithRegion(cfg.S3Region), awsconfig.WithCredentialsProvider( credentials.NewStaticCredentialsProvider(cfg.S3AccessKeyID, cfg.S3SecretKey, "")), ) if err != nil { return nil, fmt.Errorf("s3 config: %w", err) } client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { o.BaseEndpoint = aws.String(cfg.S3Endpoint) o.UsePathStyle = cfg.S3ForcePathStyle }) return &s3Store{ client: client, presign: s3.NewPresignClient(client), bucket: cfg.S3Bucket, }, nil } func (s *s3Store) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error { _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), Body: r, ContentLength: aws.Int64(size), ContentType: aws.String(contentType), }) return err } func (s *s3Store) Open(ctx context.Context, key string) (io.ReadCloser, int64, error) { out, err := s.client.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), }) if err != nil { return nil, 0, err } return out.Body, aws.ToInt64(out.ContentLength), nil } func (s *s3Store) Stat(ctx context.Context, key string) (int64, error) { out, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), }) if err != nil { // Compatibles differ: some answer NotFound, some NoSuchKey, and a // HEAD carries no body to parse, so fall back to the status code. var notFound *types.NotFound var noKey *types.NoSuchKey var apiErr smithy.APIError var respErr *awshttp.ResponseError if errors.As(err, ¬Found) || errors.As(err, &noKey) || (errors.As(err, &apiErr) && (apiErr.ErrorCode() == "NotFound" || apiErr.ErrorCode() == "NoSuchKey")) || (errors.As(err, &respErr) && respErr.HTTPStatusCode() == http.StatusNotFound) { return 0, ErrNotExist } return 0, err } return aws.ToInt64(out.ContentLength), nil } func (s *s3Store) Delete(ctx context.Context, key string) error { _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), }) return err } func (s *s3Store) PresignGet(ctx context.Context, key, downloadFilename, contentType string, expiry time.Duration) (string, error) { in := &s3.GetObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), } if contentType != "" { in.ResponseContentType = aws.String(contentType) } if downloadFilename != "" { in.ResponseContentDisposition = aws.String(fmt.Sprintf("attachment; filename=%q", downloadFilename)) } req, err := s.presign.PresignGetObject(ctx, in, s3.WithPresignExpires(expiry)) if err != nil { return "", err } return req.URL, nil }