Fetch, resize, reformat, and cache Atmosphere avatar images
atp.pics
atproto
1// Package handler implements the HTTP request handler for atp.pics.
2package handler
3
4import (
5 "errors"
6 "fmt"
7 "net/http"
8 "strconv"
9 "strings"
10
11 "atp.pics/internal/fetch"
12 "atp.pics/internal/resolve"
13 "atp.pics/internal/transform"
14)
15
16const indexHTML = `<!doctype html>
17<html lang="en">
18<head>
19<meta charset="utf-8">
20<meta name="viewport" content="width=device-width,initial-scale=1">
21<title>atp.pics</title>
22<style>
23html{display:flex;align-items:center;justify-content:center;padding:1rem;height:100vh;width:100vw;padding:0}
24body{font-family:monospace;max-width:640px}
25h1{margin-top:0}
26h2{margin-top:2rem}
27table{border-collapse:collapse;width:100%}
28th,td{text-align:left;padding:.35rem .6rem;border:1px solid #ccc}
29th{background:#f5f5f5}
30form{display:flex;flex-wrap:wrap;gap:.5rem;align-items:flex-end}
31label{display:flex;flex-direction:column;gap:.2rem;font-size:.9rem}
32input,select{padding:.3rem .4rem;border:1px solid #ccc;border-radius:4px}
33#h{flex-grow: 1}
34input[type=number]{width:3rem}
35button{padding:.35rem .9rem;cursor:pointer}
36</style>
37</head>
38<body>
39<h1>atp.pics</h1>
40<p>Got an Atmosphere handle? Need their profile pic? Want it resized, reformatted, and cached?</p>
41<p>atp.pics fetches, transforms, and caches AT Protocol avatar images.
42Request an avatar with <code>GET /{handle}</code> — the response is a <code>302</code> redirect to the cached image.</p>
43<p>Only Bluesky profiles are supported at this time; more profiles (Tangled, Spark) will be supported shortly!</p>
44<p><a href="https://tangled.org/graham.systems/atp.pics">See the source/report bugs on Tangled</a></p>
45<p><a href="https://bsky.app/profile/graham.systems">Poke me on Bluesky</a></p>
46
47<h2>Query parameters</h2>
48<table>
49<thead><tr><th>Param</th><th>Type</th><th>Default</th><th>Description</th></tr></thead>
50<tbody>
51<tr><td><code>w</code></td><td>integer</td><td>—</td><td>Output width in pixels</td></tr>
52<tr><td><code>h</code></td><td>integer</td><td>—</td><td>Output height in pixels</td></tr>
53<tr><td><code>q</code></td><td>integer 1–100</td><td>85</td><td>Quality (ignored for PNG)</td></tr>
54<tr><td><code>f</code></td><td>webp | jpg | png</td><td>webp</td><td>Output format</td></tr>
55</tbody>
56</table>
57
58<h2>Try it</h2>
59<form id="f">
60 <label id="h">Handle or DID<input name="handle" placeholder="alice.bsky.social" required></label>
61 <label>w<input name="w" type="number" min="1"></label>
62 <label>h<input name="h" type="number" min="1"></label>
63 <label>q<input name="q" type="number" min="1" max="100"></label>
64 <label>f<select name="f"><option value="">webp</option><option value="jpg">jpg</option><option value="png">png</option></select></label>
65 <button type="submit">Go</button>
66</form>
67<script>
68document.getElementById("f").addEventListener("submit",function(e){
69 e.preventDefault();
70 var d=new FormData(this),handle=d.get("handle").trim();
71 if(!handle)return;
72 var p=new URLSearchParams();
73 ["w","h","q"].forEach(function(k){var v=d.get(k).trim();if(v)p.set(k,v);});
74 var f=d.get("f");if(f)p.set("f",f);
75 var qs=p.toString();
76 window.location.href="/"+handle+(qs?"?"+qs:"");
77});
78</script>
79</body>
80</html>
81`
82
83// Handler orchestrates resolution, caching, transformation, and redirect.
84type Handler struct {
85 resolver *resolve.Resolver
86 store *fetch.Store
87}
88
89// New returns an HTTP handler wired to the given resolver and S3 store.
90func New(resolver *resolve.Resolver, store *fetch.Store) *Handler {
91 return &Handler{resolver: resolver, store: store}
92}
93
94// Register mounts the avatar route and health check on mux.
95func (h *Handler) Register(mux *http.ServeMux) {
96 mux.HandleFunc("GET /", h.index)
97 mux.HandleFunc("GET /healthz", h.healthz)
98 mux.HandleFunc("GET /{identifier}", h.serve)
99}
100
101func (h *Handler) index(w http.ResponseWriter, r *http.Request) {
102 w.Header().Set("Content-Type", "text/html; charset=utf-8")
103 w.WriteHeader(http.StatusOK)
104 fmt.Fprint(w, indexHTML)
105}
106
107func (h *Handler) healthz(w http.ResponseWriter, r *http.Request) {
108 w.WriteHeader(http.StatusOK)
109}
110
111func (h *Handler) serve(w http.ResponseWriter, r *http.Request) {
112 w.Header().Set("Access-Control-Allow-Origin", "*")
113
114 identifier := r.PathValue("identifier")
115 if identifier == "" {
116 http.Error(w, "missing identifier", http.StatusBadRequest)
117 return
118 }
119
120 p, err := parseParams(r)
121 if err != nil {
122 http.Error(w, err.Error(), http.StatusBadRequest)
123 return
124 }
125
126 ctx := r.Context()
127
128 result, err := h.resolver.Resolve(ctx, identifier)
129 if err != nil {
130 if errors.Is(err, resolve.ErrNotFound) {
131 http.Error(w, err.Error(), http.StatusNotFound)
132 return
133 }
134 http.Error(w, "upstream error resolving identifier", http.StatusBadGateway)
135 return
136 }
137
138 paramStr, ext := fetch.BuildParamStr(p.Width, p.Height, p.Quality, p.Format)
139
140 // Fast path: transformed output already in S3.
141 if ok, err := h.store.HasTransform(ctx, result.DID, result.CID, paramStr, ext); err != nil {
142 http.Error(w, "upstream error checking cache", http.StatusBadGateway)
143 return
144 } else if ok {
145 redirect(w, h.store.PublicURL(fetch.TransformKey(result.DID, result.CID, paramStr, ext)))
146 return
147 }
148
149 // Need the original blob — try S3 first, then PDS.
150 var blob []byte
151 if ok, err := h.store.HasOriginal(ctx, result.DID, result.CID); err != nil {
152 http.Error(w, "upstream error checking original cache", http.StatusBadGateway)
153 return
154 } else if ok {
155 blob, err = h.store.GetOriginal(ctx, result.DID, result.CID)
156 if err != nil {
157 http.Error(w, "upstream error fetching cached blob", http.StatusBadGateway)
158 return
159 }
160 } else {
161 blob, err = resolve.FetchBlob(ctx, result.PDS, result.DID, result.CID)
162 if err != nil {
163 if errors.Is(err, resolve.ErrNotFound) {
164 http.Error(w, err.Error(), http.StatusNotFound)
165 return
166 }
167 http.Error(w, "upstream error fetching blob", http.StatusBadGateway)
168 return
169 }
170 if err = h.store.PutOriginal(ctx, result.DID, result.CID, result.MimeType, blob); err != nil {
171 http.Error(w, "upstream error caching blob", http.StatusBadGateway)
172 return
173 }
174 }
175
176 // Transform.
177 out, contentType, err := transform.Transform(blob, result.MimeType, p)
178 if err != nil {
179 http.Error(w, fmt.Sprintf("transform error: %s", err), http.StatusInternalServerError)
180 return
181 }
182
183 // Upload transformed output.
184 if err = h.store.PutTransform(ctx, result.DID, result.CID, paramStr, ext, contentType, out); err != nil {
185 http.Error(w, "upstream error storing transform", http.StatusBadGateway)
186 return
187 }
188
189 redirect(w, h.store.PublicURL(fetch.TransformKey(result.DID, result.CID, paramStr, ext)))
190}
191
192// redirect issues a 302 with no Cache-Control so browsers re-check each load.
193func redirect(w http.ResponseWriter, url string) {
194 w.Header().Set("Location", url)
195 w.WriteHeader(http.StatusFound)
196}
197
198// parseParams reads and validates query parameters.
199func parseParams(r *http.Request) (transform.Params, error) {
200 q := r.URL.Query()
201 p := transform.Params{
202 Format: "webp",
203 }
204
205 if raw := q.Get("w"); raw != "" {
206 v, err := strconv.Atoi(raw)
207 if err != nil || v <= 0 {
208 return p, fmt.Errorf("invalid w: must be a positive integer")
209 }
210 p.Width = v
211 }
212 if raw := q.Get("h"); raw != "" {
213 v, err := strconv.Atoi(raw)
214 if err != nil || v <= 0 {
215 return p, fmt.Errorf("invalid h: must be a positive integer")
216 }
217 p.Height = v
218 }
219 if raw := q.Get("q"); raw != "" {
220 v, err := strconv.Atoi(raw)
221 if err != nil || v < 1 || v > 100 {
222 return p, fmt.Errorf("invalid q: must be an integer between 1 and 100")
223 }
224 p.Quality = v
225 }
226 if raw := q.Get("f"); raw != "" {
227 f := strings.ToLower(raw)
228 switch f {
229 case "webp", "jpg", "jpeg", "png":
230 if f == "jpeg" {
231 f = "jpg"
232 }
233 p.Format = f
234 default:
235 return p, fmt.Errorf("invalid f: must be one of webp, jpg, png")
236 }
237 }
238
239 return p, nil
240}