package handlers import ( "errors" "io" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "sneak.berlin/go/pixa/internal/imgcache" ) // HandleImage handles the main image proxy route: // /v1/image///x. func (s *Handlers) HandleImage() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Get the wildcard path from chi pathParam := chi.URLParam(r, "*") // Parse the URL path parsed, err := imgcache.ParseImagePath(pathParam) if err != nil { s.log.Warn("failed to parse image URL", "path", pathParam, "error", err, ) s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest) return } // Convert to ImageRequest req := parsed.ToImageRequest() // Parse signature params from query string query := r.URL.Query() req.Signature = query.Get("sig") if expStr := query.Get("exp"); expStr != "" { if exp, err := strconv.ParseInt(expStr, 10, 64); err == nil { req.Expires = time.Unix(exp, 0) } } // Parse optional quality and fit params if qStr := query.Get("q"); qStr != "" { if q, err := strconv.Atoi(qStr); err == nil && q > 0 && q <= 100 { req.Quality = q } } if fit := query.Get("fit"); fit != "" { req.FitMode = imgcache.FitMode(fit) } // Default quality if not set if req.Quality == 0 { req.Quality = 85 } // Default fit mode if not set if req.FitMode == "" { req.FitMode = imgcache.FitCover } // Validate signature if required if err := s.imgSvc.ValidateRequest(req); err != nil { s.log.Warn("signature validation failed", "host", req.SourceHost, "path", req.SourcePath, "error", err, ) s.respondError(w, "unauthorized", http.StatusUnauthorized) return } // Get the image (from cache or fetch/process) resp, err := s.imgSvc.Get(ctx, req) if err != nil { s.log.Error("failed to get image", "host", req.SourceHost, "path", req.SourcePath, "error", err, ) // Check for specific error types if errors.Is(err, imgcache.ErrSSRFBlocked) { s.respondError(w, "forbidden", http.StatusForbidden) return } if errors.Is(err, imgcache.ErrUpstreamError) { s.respondError(w, "upstream error", http.StatusBadGateway) return } s.respondError(w, "internal error", http.StatusInternalServerError) 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 control headers w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus)) if resp.ETag != "" { w.Header().Set("ETag", resp.ETag) } // Stream the response w.WriteHeader(http.StatusOK) _, err = io.Copy(w, resp.Content) if err != nil { s.log.Error("failed to write response", "error", err, ) } } } // HandleRobotsTxt serves robots.txt to prevent search engine crawling. func (s *Handlers) HandleRobotsTxt() http.HandlerFunc { robotsTxt := []byte("User-agent: *\nDisallow: /\n") return func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Length", strconv.Itoa(len(robotsTxt))) w.WriteHeader(http.StatusOK) _, _ = w.Write(robotsTxt) } }