package handlers import ( "errors" "fmt" "io" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "sneak.berlin/go/pixa/internal/encurl" "sneak.berlin/go/pixa/internal/imgcache" ) // HandleImageEnc handles requests to /v1/e/{token}/* for encrypted image URLs. // The trailing path (e.g., /img.jpg) is ignored but helps browsers identify the content type. func (s *Handlers) HandleImageEnc() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() start := time.Now() // Extract token from URL token := chi.URLParam(r, "token") if token == "" { s.respondError(w, "missing token", http.StatusBadRequest) return } // Decrypt and validate the payload payload, err := s.encGen.Parse(token) if err != nil { if errors.Is(err, encurl.ErrExpired) { s.log.Debug("encrypted URL expired", "error", err) s.respondError(w, "URL has expired", http.StatusGone) return } s.log.Debug("failed to decrypt URL", "error", err) s.respondError(w, "invalid encrypted URL", http.StatusBadRequest) return } // Convert payload to ImageRequest req := payload.ToImageRequest() // Log the request s.log.Debug("encrypted image request", "host", req.SourceHost, "path", req.SourcePath, "dimensions", fmt.Sprintf("%dx%d", req.Size.Width, req.Size.Height), "format", req.Format, ) // Fetch and process the image (no signature validation needed - encrypted URL is trusted) resp, err := s.imgSvc.Get(ctx, req) if err != nil { s.handleImageError(w, err) return } defer func() { _ = resp.Content.Close() }() // Set response headers w.Header().Set("Content-Type", resp.ContentType) if resp.ContentLength > 0 { w.Header().Set("Content-Length", strconv.FormatInt(resp.ContentLength, 10)) } // Cache headers - encrypted URLs can be cached since they're immutable w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus)) // Stream the response written, err := io.Copy(w, resp.Content) if err != nil { s.log.Error("failed to write response", "error", err) return } // Log completion duration := time.Since(start) s.log.Info("image served", "cache_key", imgcache.CacheKey(req), "host", req.SourceHost, "path", req.SourcePath, "format", req.Format, "cache_status", resp.CacheStatus, "served_bytes", written, "duration_ms", duration.Milliseconds(), ) } } // handleImageError converts image service errors to HTTP responses. func (s *Handlers) handleImageError(w http.ResponseWriter, err error) { switch { case errors.Is(err, imgcache.ErrSSRFBlocked): s.respondError(w, "forbidden", http.StatusForbidden) case errors.Is(err, imgcache.ErrUpstreamError): s.respondError(w, "upstream error", http.StatusBadGateway) case errors.Is(err, imgcache.ErrUpstreamTimeout): s.respondError(w, "upstream timeout", http.StatusGatewayTimeout) default: s.log.Error("image request failed", "error", err) s.respondError(w, "internal error", http.StatusInternalServerError) } }