// 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
| Param | Type | Default | Description |
w | integer | — | Output width in pixels |
h | integer | — | Output height in pixels |
q | integer 1–100 | 85 | Quality (ignored for PNG) |
f | webp | jpg | png | webp | Output 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
}