check / check (push) Successful in 3m6s
The encrypted /v1/e/ route used the decrypted payload unchecked, so a token could request an over-limit size or an unknown fit mode; the generator turned unparseable numbers into 0. imgcache.ValidateDimension alone holds the MaxDimension bound and is used by the path parser, by the new ValidateImageRequest (which adds ValidateFitMode) and by the generator. Both image routes call ValidateImageRequest, so each answers 400. The generator answers 400 naming the field for a width or height that is not a number or fails that check, a quality that is not a number from 1 to 100, a ttl that is not a number from 0 to the largest the expiry calculation can hold, or an unknown fit. Empty quality is 85; empty ttl never expires. The form's size inputs stop at 8192. Model: opus-4-8 (implementation); opus-5-5 (rework)
225 wiersze
5.4 KiB
Go
225 wiersze
5.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
)
|
|
|
|
// HandleImage handles the main image proxy route:
|
|
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
|
func (s *Handlers) HandleImage() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
req, ok := s.parseImageRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Validate signature if required
|
|
err := s.imgSvc.ValidateRequest(req)
|
|
if err != nil {
|
|
s.log.Warn("signature validation failed",
|
|
"host", req.SourceHost,
|
|
"path", req.SourcePath,
|
|
"error", err,
|
|
)
|
|
s.respondError(w, "unauthorized", http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
// Get cache key for logging
|
|
cacheKey := imgcache.CacheKey(req)
|
|
|
|
// Get the image (from cache or fetch/process)
|
|
startTime := time.Now()
|
|
|
|
resp, err := s.imgSvc.Get(r.Context(), req)
|
|
if err != nil {
|
|
s.respondImageError(w, req, err)
|
|
|
|
return
|
|
}
|
|
|
|
defer func() { _ = resp.Content.Close() }()
|
|
|
|
s.writeImageResponse(w, r, req, resp, cacheKey, startTime)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// parseImageRequest parses the wildcard path and query parameters into
|
|
// an ImageRequest. On invalid input it writes an error response and
|
|
// returns false.
|
|
func (s *Handlers) parseImageRequest(
|
|
w http.ResponseWriter, r *http.Request,
|
|
) (*imgcache.ImageRequest, bool) {
|
|
// 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 nil, false
|
|
}
|
|
|
|
// 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 != "" {
|
|
exp, parseErr := strconv.ParseInt(expStr, 10, 64)
|
|
if parseErr == nil {
|
|
req.Expires = time.Unix(exp, 0)
|
|
}
|
|
}
|
|
|
|
// Parse optional quality and fit params
|
|
if qStr := query.Get("q"); qStr != "" {
|
|
q, parseErr := strconv.Atoi(qStr)
|
|
if parseErr == 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
|
|
}
|
|
|
|
// Enforce dimension and fit-mode bounds, shared with the encrypted-URL
|
|
// route. Dimensions are already bounded by the path parser above; this
|
|
// also rejects an unrecognized fit mode with 400 instead of letting it
|
|
// reach the processor as a 500.
|
|
err = imgcache.ValidateImageRequest(req)
|
|
if err != nil {
|
|
s.respondError(w, "invalid image request: "+err.Error(),
|
|
http.StatusBadRequest)
|
|
|
|
return nil, false
|
|
}
|
|
|
|
return req, true
|
|
}
|
|
|
|
// respondImageError maps image retrieval errors to HTTP responses.
|
|
func (s *Handlers) respondImageError(
|
|
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
|
) {
|
|
s.log.Error("failed to get image",
|
|
"host", req.SourceHost,
|
|
"path", req.SourcePath,
|
|
"error", err,
|
|
)
|
|
|
|
// Check for specific error types
|
|
if errors.Is(err, httpfetcher.ErrSSRFBlocked) {
|
|
s.respondError(w, "forbidden", http.StatusForbidden)
|
|
|
|
return
|
|
}
|
|
|
|
if errors.Is(err, httpfetcher.ErrUpstreamError) {
|
|
s.respondError(w, "upstream error", http.StatusBadGateway)
|
|
|
|
return
|
|
}
|
|
|
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
|
}
|
|
|
|
// writeImageResponse writes headers and streams the image content,
|
|
// handling conditional and HEAD requests.
|
|
func (s *Handlers) writeImageResponse(
|
|
w http.ResponseWriter, r *http.Request,
|
|
req *imgcache.ImageRequest, resp *imgcache.ImageResponse,
|
|
cacheKey imgcache.VariantKey, startTime time.Time,
|
|
) {
|
|
// 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)
|
|
|
|
// Check for conditional request (If-None-Match)
|
|
if ifNoneMatch := r.Header.Get("If-None-Match"); ifNoneMatch != "" {
|
|
if ifNoneMatch == resp.ETag {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle HEAD request - return headers only
|
|
if r.Method == http.MethodHead {
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
return
|
|
}
|
|
|
|
// Stream the response
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
servedBytes, err := io.Copy(w, resp.Content)
|
|
if err != nil {
|
|
s.log.Error("failed to write response",
|
|
"error", err,
|
|
)
|
|
}
|
|
|
|
// Log cache status and timing after serving
|
|
duration := time.Since(startTime)
|
|
s.log.Info("image served",
|
|
"cache_key", cacheKey,
|
|
"cache_status", resp.CacheStatus,
|
|
"duration_ms", duration.Milliseconds(),
|
|
"format", req.Format,
|
|
"served_bytes", servedBytes,
|
|
"fetched_bytes", resp.FetchedBytes,
|
|
)
|
|
}
|