Something went wrong. Try again.
Fetch, resize, reformat, and cache Atmosphere avatar images atp.pics
atproto
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241// Package handler implements the HTTP request handler for atp.pics.package handler
import ( "errors" "fmt" "net/http" "strconv" "strings"
"atp.pics/internal/fetch" "atp.pics/internal/resolve" "atp.pics/internal/transform")
const indexHTML = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>atp.pics</title><style>html{display:flex;align-items:center;justify-content:center;padding:1rem;height:100vh;width:100vw;padding:0}body{font-family:monospace;max-width:640px}h1{margin-top:0}h2{margin-top:2rem}table{border-collapse:collapse;width:100%}th,td{text-align:left;padding:.35rem .6rem;border:1px solid #ccc}th{background:#f5f5f5}form{display:flex;flex-wrap:wrap;gap:.5rem;align-items:flex-end}label{display:flex;flex-direction:column;gap:.2rem;font-size:.9rem}input,select{padding:.3rem .4rem;border:1px solid #ccc;border-radius:4px}#h{flex-grow: 1}input[type=number]{width:3rem}button{padding:.35rem .9rem;cursor:pointer}</style></head><body><h1>atp.pics</h1><p>Got an Atmosphere handle? Need their profile pic? Want it resized, reformatted, and cached?</p><p>atp.pics fetches, transforms, and caches AT Protocol avatar images.Request an avatar with <code>GET /{handle}</code> — the response is a <code>302</code> redirect to the cached image.</p><p>Only Bluesky profiles are supported at this time; more profiles (Tangled, Spark) will be supported shortly!</p><p><a href="https://tangled.org/graham.systems/atp.pics">See the source/report bugs on Tangled</a></p><p><a href="https://bsky.app/profile/graham.systems">Poke me on Bluesky</a></p>
<h2>Query parameters</h2><table><thead><tr><th>Param</th><th>Type</th><th>Default</th><th>Description</th></tr></thead><tbody><tr><td><code>w</code></td><td>integer</td><td>—</td><td>Output width in pixels</td></tr><tr><td><code>h</code></td><td>integer</td><td>—</td><td>Output height in pixels</td></tr><tr><td><code>q</code></td><td>integer 1–100</td><td>85</td><td>Quality (ignored for PNG)</td></tr><tr><td><code>f</code></td><td>webp | jpg | png</td><td>webp</td><td>Output format</td></tr></tbody></table>
<h2>Try it</h2><form id="f"> <label id="h">Handle or DID<input name="handle" placeholder="alice.bsky.social" required></label> <label>w<input name="w" type="number" min="1"></label> <label>h<input name="h" type="number" min="1"></label> <label>q<input name="q" type="number" min="1" max="100"></label> <label>f<select name="f"><option value="">webp</option><option value="jpg">jpg</option><option value="png">png</option></select></label> <button type="submit">Go</button></form><script>document.getElementById("f").addEventListener("submit",function(e){ e.preventDefault(); var d=new FormData(this),handle=d.get("handle").trim(); if(!handle)return; var p=new URLSearchParams(); ["w","h","q"].forEach(function(k){var v=d.get(k).trim();if(v)p.set(k,v);}); var f=d.get("f");if(f)p.set("f",f); var qs=p.toString(); window.location.href="/"+handle+(qs?"?"+qs:"");});</script></body></html>`
// Handler orchestrates resolution, caching, transformation, and redirect.type Handler struct { resolver *resolve.Resolver store *fetch.Store}
// New returns an HTTP handler wired to the given resolver and S3 store.func New(resolver *resolve.Resolver, store *fetch.Store) *Handler { return &Handler{resolver: resolver, store: store}}
// Register mounts the avatar route and health check on mux.func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("GET /", h.index) mux.HandleFunc("GET /healthz", h.healthz) mux.HandleFunc("GET /{identifier}", h.serve)}
func (h *Handler) index(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) fmt.Fprint(w, indexHTML)}
func (h *Handler) healthz(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK)}
func (h *Handler) serve(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*")
identifier := r.PathValue("identifier") if identifier == "" { http.Error(w, "missing identifier", http.StatusBadRequest) return }
p, err := parseParams(r) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return }
ctx := r.Context()
result, err := h.resolver.Resolve(ctx, identifier) if err != nil { if errors.Is(err, resolve.ErrNotFound) { http.Error(w, err.Error(), http.StatusNotFound) return } http.Error(w, "upstream error resolving identifier", http.StatusBadGateway) return }
paramStr, ext := fetch.BuildParamStr(p.Width, p.Height, p.Quality, p.Format)
// Fast path: transformed output already in S3. if ok, err := h.store.HasTransform(ctx, result.DID, result.CID, paramStr, ext); err != nil { http.Error(w, "upstream error checking cache", http.StatusBadGateway) return } else if ok { redirect(w, h.store.PublicURL(fetch.TransformKey(result.DID, result.CID, paramStr, ext))) return }
// Need the original blob — try S3 first, then PDS. var blob []byte if ok, err := h.store.HasOriginal(ctx, result.DID, result.CID); err != nil { http.Error(w, "upstream error checking original cache", http.StatusBadGateway) return } else if ok { blob, err = h.store.GetOriginal(ctx, result.DID, result.CID) if err != nil { http.Error(w, "upstream error fetching cached blob", http.StatusBadGateway) return } } else { blob, err = resolve.FetchBlob(ctx, result.PDS, result.DID, result.CID) if err != nil { if errors.Is(err, resolve.ErrNotFound) { http.Error(w, err.Error(), http.StatusNotFound) return } http.Error(w, "upstream error fetching blob", http.StatusBadGateway) return } if err = h.store.PutOriginal(ctx, result.DID, result.CID, result.MimeType, blob); err != nil { http.Error(w, "upstream error caching blob", http.StatusBadGateway) return } }
// Transform. out, contentType, err := transform.Transform(blob, result.MimeType, p) if err != nil { http.Error(w, fmt.Sprintf("transform error: %s", err), http.StatusInternalServerError) return }
// Upload transformed output. if err = h.store.PutTransform(ctx, result.DID, result.CID, paramStr, ext, contentType, out); err != nil { http.Error(w, "upstream error storing transform", http.StatusBadGateway) return }
redirect(w, h.store.PublicURL(fetch.TransformKey(result.DID, result.CID, paramStr, ext)))}
// redirect issues a 302 with no Cache-Control so browsers re-check each load.func redirect(w http.ResponseWriter, url string) { w.Header().Set("Location", url) w.WriteHeader(http.StatusFound)}
// parseParams reads and validates query parameters.func parseParams(r *http.Request) (transform.Params, error) { q := r.URL.Query() p := transform.Params{ Format: "webp", }
if raw := q.Get("w"); raw != "" { v, err := strconv.Atoi(raw) if err != nil || v <= 0 { return p, fmt.Errorf("invalid w: must be a positive integer") } p.Width = v } if raw := q.Get("h"); raw != "" { v, err := strconv.Atoi(raw) if err != nil || v <= 0 { return p, fmt.Errorf("invalid h: must be a positive integer") } p.Height = v } if raw := q.Get("q"); raw != "" { v, err := strconv.Atoi(raw) if err != nil || v < 1 || v > 100 { return p, fmt.Errorf("invalid q: must be an integer between 1 and 100") } p.Quality = v } if raw := q.Get("f"); raw != "" { f := strings.ToLower(raw) switch f { case "webp", "jpg", "jpeg", "png": if f == "jpeg" { f = "jpg" } p.Format = f default: return p, fmt.Errorf("invalid f: must be one of webp, jpg, png") } }
return p, nil}