max_concurrent_processing (default: the number of CPUs Go uses) bounds the images processed at once, and upstream_connections (default 64) the fetches from all upstream hosts together, beside the per-host limit. A request that finds either full waits up to 10 seconds, then gets 503 "server busy, try again later". The processor holds its slot from before it reads the input until it returns, and takes a free slot even after the request context has ended; a fetch holds its connection until the response body is closed, after its image is processed. libvips now starts with one worker thread per image and no operation cache. Both settings have PIXA_ variables and are in README.md and config.example.yml. Model: opus-5-5
137 lines
3.8 KiB
Go
137 lines
3.8 KiB
Go
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/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()
|
|
|
|
// 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()
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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))
|
|
|
|
// 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, 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)
|
|
}
|
|
}
|