// 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 = ` atp.pics

atp.pics

Got an Atmosphere handle? Need their profile pic? Want it resized, reformatted, and cached?

atp.pics fetches, transforms, and caches AT Protocol avatar images. Request an avatar with GET /{handle} — the response is a 302 redirect to the cached image.

Only Bluesky profiles are supported at this time; more profiles (Tangled, Spark) will be supported shortly!

See the source/report bugs on Tangled

Poke me on Bluesky

Query parameters

ParamTypeDefaultDescription
wintegerOutput width in pixels
hintegerOutput height in pixels
qinteger 1–10085Quality (ignored for PNG)
fwebp | jpg | pngwebpOutput format

Try it

` // 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 }