A community based topic aggregation platform built on atproto
1// Package imageproxy provides HTTP handlers for the image proxy service.
2// It handles requests for proxied and transformed images from AT Protocol PDSes.
3package imageproxy
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "log/slog"
10 "net/http"
11
12 "github.com/go-chi/chi/v5"
13
14 "Coves/internal/atproto/identity"
15 "Coves/internal/core/imageproxy"
16)
17
18// Service defines the interface for the image proxy service.
19// This interface is implemented by the imageproxy package's service layer.
20type Service interface {
21 // GetImage retrieves and processes an image from a PDS.
22 // preset: the image transformation preset (e.g., "avatar", "banner")
23 // did: the DID of the user who owns the blob
24 // cid: the content identifier of the blob
25 // pdsURL: the URL of the user's PDS
26 GetImage(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error)
27}
28
29// Handler handles HTTP requests for the image proxy.
30type Handler struct {
31 service Service
32 identityResolver identity.Resolver
33}
34
35// NewHandler creates a new image proxy handler.
36func NewHandler(service Service, resolver identity.Resolver) *Handler {
37 return &Handler{
38 service: service,
39 identityResolver: resolver,
40 }
41}
42
43// HandleImage handles GET /img/{preset}/plain/{did}/{cid}
44// It fetches the image from the user's PDS, transforms it according to the preset,
45// and returns the result with appropriate caching headers.
46func (h *Handler) HandleImage(w http.ResponseWriter, r *http.Request) {
47 // Parse URL parameters
48 preset := chi.URLParam(r, "preset")
49 did := chi.URLParam(r, "did")
50 cid := chi.URLParam(r, "cid")
51
52 // Validate required parameters
53 if preset == "" || did == "" || cid == "" {
54 writeErrorResponse(w, http.StatusBadRequest, "missing required parameters")
55 return
56 }
57
58 // Validate preset exists before proceeding
59 if _, err := imageproxy.GetPreset(preset); err != nil {
60 if errors.Is(err, imageproxy.ErrInvalidPreset) {
61 writeErrorResponse(w, http.StatusBadRequest, "invalid preset: "+preset)
62 return
63 }
64 writeErrorResponse(w, http.StatusBadRequest, "invalid preset")
65 return
66 }
67
68 // Validate DID format (must be did:plc: or did:web:)
69 if err := imageproxy.ValidateDID(did); err != nil {
70 writeErrorResponse(w, http.StatusBadRequest, "invalid DID format")
71 return
72 }
73
74 // Validate CID format (must be valid base32/base58 CID)
75 if err := imageproxy.ValidateCID(cid); err != nil {
76 writeErrorResponse(w, http.StatusBadRequest, "invalid CID format")
77 return
78 }
79
80 // Generate ETag for caching
81 etag := fmt.Sprintf(`"%s-%s"`, preset, cid)
82
83 // Check If-None-Match header for 304 response
84 if r.Header.Get("If-None-Match") == etag {
85 w.WriteHeader(http.StatusNotModified)
86 return
87 }
88
89 // Resolve DID to get PDS URL
90 didDoc, err := h.identityResolver.ResolveDID(r.Context(), did)
91 if err != nil {
92 slog.Warn("[IMAGE-PROXY] failed to resolve DID",
93 "did", did,
94 "error", err,
95 )
96 writeErrorResponse(w, http.StatusBadGateway, "failed to resolve DID")
97 return
98 }
99
100 // Extract PDS URL from DID document
101 pdsURL := getPDSEndpoint(didDoc)
102 if pdsURL == "" {
103 slog.Warn("[IMAGE-PROXY] no PDS endpoint found in DID document",
104 "did", did,
105 )
106 writeErrorResponse(w, http.StatusBadGateway, "no PDS endpoint found")
107 return
108 }
109
110 // Fetch and process the image
111 imageData, err := h.service.GetImage(r.Context(), preset, did, cid, pdsURL)
112 if err != nil {
113 handleServiceError(w, err)
114 return
115 }
116
117 // Set response headers
118 w.Header().Set("Content-Type", "image/jpeg")
119 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
120 w.Header().Set("ETag", etag)
121
122 // Write image data
123 w.WriteHeader(http.StatusOK)
124 if _, err := w.Write(imageData); err != nil {
125 slog.Warn("[IMAGE-PROXY] failed to write image response",
126 "preset", preset,
127 "did", did,
128 "cid", cid,
129 "error", err,
130 )
131 }
132}
133
134// getPDSEndpoint extracts the PDS service endpoint from a DID document.
135func getPDSEndpoint(doc *identity.DIDDocument) string {
136 if doc == nil {
137 return ""
138 }
139 for _, service := range doc.Service {
140 if service.Type == "AtprotoPersonalDataServer" {
141 return service.ServiceEndpoint
142 }
143 }
144 return ""
145}
146
147// handleServiceError converts service errors to appropriate HTTP responses.
148func handleServiceError(w http.ResponseWriter, err error) {
149 switch {
150 case errors.Is(err, imageproxy.ErrPDSNotFound):
151 writeErrorResponse(w, http.StatusNotFound, "blob not found")
152 case errors.Is(err, imageproxy.ErrPDSTimeout):
153 writeErrorResponse(w, http.StatusGatewayTimeout, "request timed out")
154 case errors.Is(err, imageproxy.ErrPDSFetchFailed):
155 writeErrorResponse(w, http.StatusBadGateway, "failed to fetch blob from PDS")
156 case errors.Is(err, imageproxy.ErrInvalidPreset):
157 writeErrorResponse(w, http.StatusBadRequest, "invalid preset")
158 case errors.Is(err, imageproxy.ErrInvalidDID):
159 writeErrorResponse(w, http.StatusBadRequest, "invalid DID format")
160 case errors.Is(err, imageproxy.ErrInvalidCID):
161 writeErrorResponse(w, http.StatusBadRequest, "invalid CID format")
162 case errors.Is(err, imageproxy.ErrUnsupportedFormat):
163 writeErrorResponse(w, http.StatusBadRequest, "unsupported image format")
164 case errors.Is(err, imageproxy.ErrImageTooLarge):
165 writeErrorResponse(w, http.StatusBadRequest, "image too large")
166 case errors.Is(err, imageproxy.ErrProcessingFailed):
167 writeErrorResponse(w, http.StatusInternalServerError, "image processing failed")
168 default:
169 slog.Error("[IMAGE-PROXY] unhandled service error",
170 "error", err,
171 )
172 writeErrorResponse(w, http.StatusInternalServerError, "internal server error")
173 }
174}
175
176// writeErrorResponse writes a plain text error response.
177// For the image proxy, we use simple text responses rather than JSON
178// since the expected response is binary image data.
179func writeErrorResponse(w http.ResponseWriter, status int, message string) {
180 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
181 w.WriteHeader(status)
182 if _, err := w.Write([]byte(message)); err != nil {
183 slog.Warn("[IMAGE-PROXY] failed to write error response",
184 "status", status,
185 "message", message,
186 "error", err,
187 )
188 }
189}