Something went wrong. Try again.
Fetch, resize, reformat, and cache Atmosphere avatar images atp.pics
atproto
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186// 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}