Files
Michilis 498d7d8a7d Add photo content-hash dedup and local↔S3 library sync.
Uploads skip per-gallery duplicates, checksums can be backfilled, and STORAGE_BACKEND plus sync tooling make switching storage backends safe.
2026-08-05 05:41:12 +00:00

126 lines
3.7 KiB
Go

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, &notFound) || 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
}