// Package fetch handles S3 storage of original and transformed avatar blobs. package fetch import ( "bytes" "context" "errors" "fmt" "io" "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/aws/smithy-go" ) // Store wraps an S3 client with bucket configuration. type Store struct { client *s3.Client bucket string publicHost string } // New returns a Store for the given S3 client, bucket, and optional public // host override. When publicHost is non-empty it is used as the hostname for // public object URLs; otherwise the computed Tigris virtual-hosted URL is used. func New(client *s3.Client, bucket, publicHost string) *Store { return &Store{client: client, bucket: bucket, publicHost: publicHost} } // originalKey returns the S3 key for a raw blob. func originalKey(did, cid string) string { return "avatars/" + did + "/original/" + cid } // TransformKey returns the S3 key for a transformed output given DID, CID, // and the canonical transform parameter string (e.g. "w200-h200-q85"). func TransformKey(did, cid, paramStr, ext string) string { if paramStr == "" { return "avatars/" + did + "/" + cid + "/default." + ext } return "avatars/" + did + "/" + cid + "/" + paramStr + "." + ext } // PublicURL returns the public HTTPS URL for an object key. When a custom // public host is configured it is used as the hostname; otherwise the URL is // derived from the Tigris virtual-hosted bucket domain. func (s *Store) PublicURL(key string) string { if s.publicHost != "" { return "https://" + s.publicHost + "/" + key } return "https://" + s.bucket + ".fly.storage.tigris.dev/" + key } // HasOriginal reports whether the raw blob is already cached in S3. func (s *Store) HasOriginal(ctx context.Context, did, cid string) (bool, error) { return s.exists(ctx, originalKey(did, cid)) } // HasTransform reports whether a transformed output already exists in S3. func (s *Store) HasTransform(ctx context.Context, did, cid, paramStr, ext string) (bool, error) { return s.exists(ctx, TransformKey(did, cid, paramStr, ext)) } // GetOriginal downloads the raw blob bytes from S3. func (s *Store) GetOriginal(ctx context.Context, did, cid string) ([]byte, error) { return s.get(ctx, originalKey(did, cid)) } // PutOriginal uploads raw blob bytes to S3 (no-op if already present). func (s *Store) PutOriginal(ctx context.Context, did, cid, contentType string, data []byte) error { ok, err := s.HasOriginal(ctx, did, cid) if err != nil { return err } if ok { return nil } return s.put(ctx, originalKey(did, cid), contentType, "", data) } // PutTransform uploads a transformed image to S3 with long-lived cache headers. func (s *Store) PutTransform(ctx context.Context, did, cid, paramStr, ext, contentType string, data []byte) error { return s.put(ctx, TransformKey(did, cid, paramStr, ext), contentType, "public, max-age=31536000, immutable", data) } func (s *Store) exists(ctx context.Context, key string) (bool, error) { _, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), }) if err == nil { return true, nil } var notFound *types.NotFound var apiErr smithy.APIError if errors.As(err, ¬Found) || (errors.As(err, &apiErr) && apiErr.ErrorCode() == "NotFound") { return false, nil } return false, fmt.Errorf("S3 HeadObject %s: %w", key, err) } func (s *Store) get(ctx context.Context, key string) ([]byte, error) { out, err := s.client.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), }) if err != nil { return nil, fmt.Errorf("S3 GetObject %s: %w", key, err) } defer out.Body.Close() data, err := io.ReadAll(out.Body) if err != nil { return nil, fmt.Errorf("reading S3 object %s: %w", key, err) } return data, nil } func (s *Store) put(ctx context.Context, key, contentType, cacheControl string, data []byte) error { in := &s3.PutObjectInput{ Bucket: aws.String(s.bucket), Key: aws.String(key), Body: bytes.NewReader(data), ContentType: aws.String(contentType), } if cacheControl != "" { in.CacheControl = aws.String(cacheControl) } _, err := s.client.PutObject(ctx, in) if err != nil { return fmt.Errorf("S3 PutObject %s: %w", key, err) } return nil } // defaultQuality is the encode quality used when the caller does not specify ?q. const defaultQuality = 85 // BuildParamStr constructs the canonical transform parameter string and file // extension for an S3 cache key. // // Key patterns (matching the s3-cache spec): // - No params / default WebP: ("", "webp") → default.webp // - Format only, non-WebP: ("f{fmt}[-q{q}]", ext) // - Resize (any format): ("w{w}[-h{h}]-q{q}", ext) quality always explicit // // Default quality (85) is always written into the key for lossy formats so that // implicit and explicit quality requests map to the same S3 object. func BuildParamStr(w, h, q int, format string) (paramStr, ext string) { ext = format // Normalize quality: PNG is lossless so quality is irrelevant; for lossy // formats treat 0 as "use default". effectiveQ := q if format == "png" { effectiveQ = 0 } else if effectiveQ == 0 { effectiveQ = defaultQuality } // The "default" case: WebP output, original dimensions, default quality. // Covers both no-params requests and explicit ?f=webp&q=85. if w == 0 && h == 0 && format == "webp" && effectiveQ == defaultQuality { return "", "webp" } var parts []string // Format prefix only when there is no resize and the format is non-default. if w == 0 && h == 0 && format != "webp" { parts = append(parts, "f"+format) } if w > 0 { parts = append(parts, fmt.Sprintf("w%d", w)) } if h > 0 { parts = append(parts, fmt.Sprintf("h%d", h)) } if format != "png" { parts = append(parts, fmt.Sprintf("q%d", effectiveQ)) } return strings.Join(parts, "-"), ext }