check / check (push) Waiting to run
The ETag and If-None-Match code of the /v1/image/ handler becomes notModified, which both image handlers call; /v1/e/ answers HEAD with the headers only and is routed for HEAD. HandleImageEnc was at the 80-line function limit, so its token checks move unchanged into parseImageEncRequest, as parseImageRequest does for /v1/image/. No Vary is added: only the image routes' CORS headers depend on a request header, and go-chi/cors already sends Vary: Origin with them. Model: opus-5-5
165 lines
4.5 KiB
Go
165 lines
4.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
|
|
"sneak.berlin/go/pixa/internal/encurl"
|
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
"sneak.berlin/go/pixa/internal/imageprocessor"
|
|
"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()
|
|
|
|
req, ok := s.parseImageEncRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// 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: max-age ends at the URL's expiry
|
|
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
|
|
|
if notModified(w, r, resp.ETag) {
|
|
return
|
|
}
|
|
|
|
// A HEAD request gets the headers only
|
|
if r.Method == http.MethodHead {
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
return
|
|
}
|
|
|
|
// 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",
|
|
"request_id", middleware.GetReqID(ctx),
|
|
"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(),
|
|
)
|
|
}
|
|
}
|
|
|
|
// parseImageEncRequest decrypts the token of an encrypted image URL into an
|
|
// ImageRequest and checks it. On a token that is missing, does not decrypt,
|
|
// has expired or asks for something not valid, it writes an error response
|
|
// and returns false.
|
|
func (s *Handlers) parseImageEncRequest(
|
|
w http.ResponseWriter, r *http.Request,
|
|
) (*imgcache.ImageRequest, bool) {
|
|
// Extract token from URL
|
|
token := chi.URLParam(r, "token")
|
|
if token == "" {
|
|
s.respondError(w, "missing token", http.StatusBadRequest)
|
|
|
|
return nil, false
|
|
}
|
|
|
|
// 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 nil, false
|
|
}
|
|
|
|
s.log.Debug("failed to decrypt URL", "error", err)
|
|
s.respondError(w, "invalid encrypted URL", http.StatusBadRequest)
|
|
|
|
return nil, false
|
|
}
|
|
|
|
// Convert payload to ImageRequest
|
|
req := payload.ToImageRequest()
|
|
|
|
// Apply the same dimension and fit-mode bounds as the plain image
|
|
// route: a sealed payload is trusted for its origin, not for staying
|
|
// within limits, so an over-limit size or unknown fit mode is a 400
|
|
// here rather than an out-of-memory or a 500 from the processor.
|
|
err = imgcache.ValidateImageRequest(req)
|
|
if err != nil {
|
|
s.log.Debug("encrypted URL failed validation", "error", err)
|
|
s.respondError(w, "invalid encrypted URL: "+err.Error(),
|
|
http.StatusBadRequest)
|
|
|
|
return nil, false
|
|
}
|
|
|
|
return req, true
|
|
}
|
|
|
|
// handleImageError converts image service errors to HTTP responses.
|
|
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
|
switch {
|
|
case errors.Is(err, httpfetcher.ErrSSRFBlocked):
|
|
s.respondError(w, "forbidden", http.StatusForbidden)
|
|
case errors.Is(err, httpfetcher.ErrUpstreamError):
|
|
s.respondError(w, "upstream error", http.StatusBadGateway)
|
|
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
|
|
s.respondError(w, "upstream timeout", http.StatusGatewayTimeout)
|
|
case errors.Is(err, httpfetcher.ErrTooManyConnections),
|
|
errors.Is(err, imageprocessor.ErrTooManyImages):
|
|
s.respondError(w, "server busy, try again later",
|
|
http.StatusServiceUnavailable)
|
|
default:
|
|
s.log.Error("image request failed", "error", err)
|
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|