Fetch, resize, reformat, and cache Atmosphere avatar images atp.pics
atproto
at optimize-resolution 185 lines 6.0 kB view raw
1// Package fetch handles S3 storage of original and transformed avatar blobs. 2package fetch 3 4import ( 5 "bytes" 6 "context" 7 "errors" 8 "fmt" 9 "io" 10 "strings" 11 12 "github.com/aws/aws-sdk-go-v2/aws" 13 "github.com/aws/aws-sdk-go-v2/service/s3" 14 "github.com/aws/aws-sdk-go-v2/service/s3/types" 15 "github.com/aws/smithy-go" 16) 17 18// Store wraps an S3 client with bucket configuration. 19type Store struct { 20 client *s3.Client 21 bucket string 22 publicHost string 23} 24 25// New returns a Store for the given S3 client, bucket, and optional public 26// host override. When publicHost is non-empty it is used as the hostname for 27// public object URLs; otherwise the computed Tigris virtual-hosted URL is used. 28func New(client *s3.Client, bucket, publicHost string) *Store { 29 return &Store{client: client, bucket: bucket, publicHost: publicHost} 30} 31 32// originalKey returns the S3 key for a raw blob. 33func originalKey(did, cid string) string { 34 return "avatars/" + did + "/original/" + cid 35} 36 37// TransformKey returns the S3 key for a transformed output given DID, CID, 38// and the canonical transform parameter string (e.g. "w200-h200-q85"). 39func TransformKey(did, cid, paramStr, ext string) string { 40 if paramStr == "" { 41 return "avatars/" + did + "/" + cid + "/default." + ext 42 } 43 return "avatars/" + did + "/" + cid + "/" + paramStr + "." + ext 44} 45 46// PublicURL returns the public HTTPS URL for an object key. When a custom 47// public host is configured it is used as the hostname; otherwise the URL is 48// derived from the Tigris virtual-hosted bucket domain. 49func (s *Store) PublicURL(key string) string { 50 if s.publicHost != "" { 51 return "https://" + s.publicHost + "/" + key 52 } 53 return "https://" + s.bucket + ".fly.storage.tigris.dev/" + key 54} 55 56// HasOriginal reports whether the raw blob is already cached in S3. 57func (s *Store) HasOriginal(ctx context.Context, did, cid string) (bool, error) { 58 return s.exists(ctx, originalKey(did, cid)) 59} 60 61// HasTransform reports whether a transformed output already exists in S3. 62func (s *Store) HasTransform(ctx context.Context, did, cid, paramStr, ext string) (bool, error) { 63 return s.exists(ctx, TransformKey(did, cid, paramStr, ext)) 64} 65 66// GetOriginal downloads the raw blob bytes from S3. 67func (s *Store) GetOriginal(ctx context.Context, did, cid string) ([]byte, error) { 68 return s.get(ctx, originalKey(did, cid)) 69} 70 71// PutOriginal uploads raw blob bytes to S3 (no-op if already present). 72func (s *Store) PutOriginal(ctx context.Context, did, cid, contentType string, data []byte) error { 73 ok, err := s.HasOriginal(ctx, did, cid) 74 if err != nil { 75 return err 76 } 77 if ok { 78 return nil 79 } 80 return s.put(ctx, originalKey(did, cid), contentType, "", data) 81} 82 83// PutTransform uploads a transformed image to S3 with long-lived cache headers. 84func (s *Store) PutTransform(ctx context.Context, did, cid, paramStr, ext, contentType string, data []byte) error { 85 return s.put(ctx, TransformKey(did, cid, paramStr, ext), contentType, "public, max-age=31536000, immutable", data) 86} 87 88func (s *Store) exists(ctx context.Context, key string) (bool, error) { 89 _, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ 90 Bucket: aws.String(s.bucket), 91 Key: aws.String(key), 92 }) 93 if err == nil { 94 return true, nil 95 } 96 var notFound *types.NotFound 97 var apiErr smithy.APIError 98 if errors.As(err, &notFound) || (errors.As(err, &apiErr) && apiErr.ErrorCode() == "NotFound") { 99 return false, nil 100 } 101 return false, fmt.Errorf("S3 HeadObject %s: %w", key, err) 102} 103 104func (s *Store) get(ctx context.Context, key string) ([]byte, error) { 105 out, err := s.client.GetObject(ctx, &s3.GetObjectInput{ 106 Bucket: aws.String(s.bucket), 107 Key: aws.String(key), 108 }) 109 if err != nil { 110 return nil, fmt.Errorf("S3 GetObject %s: %w", key, err) 111 } 112 defer out.Body.Close() 113 data, err := io.ReadAll(out.Body) 114 if err != nil { 115 return nil, fmt.Errorf("reading S3 object %s: %w", key, err) 116 } 117 return data, nil 118} 119 120func (s *Store) put(ctx context.Context, key, contentType, cacheControl string, data []byte) error { 121 in := &s3.PutObjectInput{ 122 Bucket: aws.String(s.bucket), 123 Key: aws.String(key), 124 Body: bytes.NewReader(data), 125 ContentType: aws.String(contentType), 126 } 127 if cacheControl != "" { 128 in.CacheControl = aws.String(cacheControl) 129 } 130 _, err := s.client.PutObject(ctx, in) 131 if err != nil { 132 return fmt.Errorf("S3 PutObject %s: %w", key, err) 133 } 134 return nil 135} 136 137// defaultQuality is the encode quality used when the caller does not specify ?q. 138const defaultQuality = 85 139 140// BuildParamStr constructs the canonical transform parameter string and file 141// extension for an S3 cache key. 142// 143// Key patterns (matching the s3-cache spec): 144// - No params / default WebP: ("", "webp") → default.webp 145// - Format only, non-WebP: ("f{fmt}[-q{q}]", ext) 146// - Resize (any format): ("w{w}[-h{h}]-q{q}", ext) quality always explicit 147// 148// Default quality (85) is always written into the key for lossy formats so that 149// implicit and explicit quality requests map to the same S3 object. 150func BuildParamStr(w, h, q int, format string) (paramStr, ext string) { 151 ext = format 152 153 // Normalize quality: PNG is lossless so quality is irrelevant; for lossy 154 // formats treat 0 as "use default". 155 effectiveQ := q 156 if format == "png" { 157 effectiveQ = 0 158 } else if effectiveQ == 0 { 159 effectiveQ = defaultQuality 160 } 161 162 // The "default" case: WebP output, original dimensions, default quality. 163 // Covers both no-params requests and explicit ?f=webp&q=85. 164 if w == 0 && h == 0 && format == "webp" && effectiveQ == defaultQuality { 165 return "", "webp" 166 } 167 168 var parts []string 169 170 // Format prefix only when there is no resize and the format is non-default. 171 if w == 0 && h == 0 && format != "webp" { 172 parts = append(parts, "f"+format) 173 } 174 if w > 0 { 175 parts = append(parts, fmt.Sprintf("w%d", w)) 176 } 177 if h > 0 { 178 parts = append(parts, fmt.Sprintf("h%d", h)) 179 } 180 if format != "png" { 181 parts = append(parts, fmt.Sprintf("q%d", effectiveQ)) 182 } 183 184 return strings.Join(parts, "-"), ext 185}