A community based topic aggregation platform built on atproto
at main 189 lines 6.1 kB view raw
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}