Fetch, resize, reformat, and cache Atmosphere avatar images
atp.pics
atproto
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, ¬Found) || (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}