Files
ds-galo/backend/internal/storage/storage.go
T
iu 96268ee16c feat(photos): separate object storage from metadata layer
Move physical file facts out of the photos table into an objects ledger:

- objects / photo_objects / variant_jobs tables (migration 000008)
- drop storage_key/thumb_key/preview_key/size/width/height/mime_type from
  photos (migration 000009); they are derived via joins to the original object
- album-free storage keys: objects/{id}/{role}.{ext}
- explicit GC via pending/ready/deleting states; objects deleted only when no
  photo references them
- background worker drives variant generation (durable variant_jobs) and GC
- copy/move are metadata-only; storage objects are shared between photos
- remove unused storage.DeletePrefix

Non-destructive to the external JSON API: size/dimensions/mime and URLs are
still returned, just sourced from the object layer.

OpenSpec change: media-object-layer
2026-10-10 23:31:41 +03:00

141 lines
3.6 KiB
Go

package storage
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
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"
"shared-album/internal/config"
)
type Client struct {
s3 *s3.Client
presign *s3.PresignClient
bucket string
presignTTL time.Duration
}
func New(ctx context.Context, cfg config.S3) (*Client, error) {
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
awsconfig.WithRegion(cfg.Region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
cfg.AccessKey, cfg.SecretKey, "",
)),
)
if err != nil {
return nil, fmt.Errorf("load aws config: %w", err)
}
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(cfg.Endpoint)
o.UsePathStyle = cfg.UsePathStyle
})
return &Client{
s3: client,
presign: s3.NewPresignClient(client),
bucket: cfg.Bucket,
presignTTL: cfg.PresignTTL,
}, nil
}
func (c *Client) Bucket() string { return c.bucket }
func (c *Client) EnsureBucket(ctx context.Context) error {
_, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)})
if err == nil {
return nil
}
var nf *types.NotFound
if !errors.As(err, &nf) && !isNoSuchBucket(err) {
return fmt.Errorf("head bucket: %w", err)
}
if _, err := c.s3.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(c.bucket)}); err != nil {
return fmt.Errorf("create bucket: %w", err)
}
return nil
}
func (c *Client) Ping(ctx context.Context) error {
_, err := c.s3.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(c.bucket)})
return err
}
func (c *Client) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error {
in := &s3.PutObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
Body: body,
ContentType: aws.String(contentType),
}
if size >= 0 {
in.ContentLength = aws.Int64(size)
}
if _, err := c.s3.PutObject(ctx, in); err != nil {
return fmt.Errorf("put object %s: %w", key, err)
}
return nil
}
func (c *Client) Delete(ctx context.Context, keys ...string) error {
if len(keys) == 0 {
return nil
}
objs := make([]types.ObjectIdentifier, 0, len(keys))
for _, k := range keys {
objs = append(objs, types.ObjectIdentifier{Key: aws.String(k)})
}
if _, err := c.s3.DeleteObjects(ctx, &s3.DeleteObjectsInput{
Bucket: aws.String(c.bucket),
Delete: &types.Delete{Objects: objs, Quiet: aws.Bool(true)},
}); err != nil {
return fmt.Errorf("delete objects: %w", err)
}
return nil
}
func (c *Client) Get(ctx context.Context, key string) ([]byte, error) {
out, err := c.s3.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, fmt.Errorf("get object %s: %w", key, err)
}
defer out.Body.Close()
data, err := io.ReadAll(out.Body)
if err != nil {
return nil, fmt.Errorf("read object %s: %w", key, err)
}
return data, nil
}
func (c *Client) PresignGet(ctx context.Context, key string) (string, error) { out, err := c.presign.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
}, s3.WithPresignExpires(c.presignTTL))
if err != nil {
return "", fmt.Errorf("presign %s: %w", key, err)
}
return out.URL, nil
}
func isNoSuchBucket(err error) bool {
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
code := apiErr.ErrorCode()
return strings.EqualFold(code, "NoSuchBucket") || strings.EqualFold(code, "NotFound")
}
return false
}