Fetch, resize, reformat, and cache Atmosphere avatar images atp.pics
atproto
at optimize-resolution 240 lines 7.7 kB view raw
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}