check / check (push) Waiting to run
Process looked at its context only while waiting for a processing slot, so a transcode kept its slot and worked on after its request's downstream_timeout. acquireSlot now gives the slot back when the context has ended by the time it has one, and encode returns the context's error without beginning. govips gives pixa no way to stop libvips once an encode has begun, so that encode keeps its slot. A request waiting on a shared transcode stopped at the first request's deadline is answered 503, not 500. README.md "Routes" says both. Model: opus-5-5
178 lines
5.1 KiB
Go
178 lines
5.1 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) {
|
|
if s.refuseBlockedReferer(w, r) {
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
start := time.Now()
|
|
|
|
req, ok := s.parseImageEncRequest(w, r)
|
|
if !ok || !s.chooseAutoFormat(w, r, req) {
|
|
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),
|
|
errors.Is(err, imgcache.ErrProcessingTimedOut):
|
|
s.respondError(w, "server busy, try again later",
|
|
http.StatusServiceUnavailable)
|
|
case errors.Is(err, httpfetcher.ErrInvalidContentType),
|
|
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat):
|
|
s.respondError(w, "unsupported source format",
|
|
http.StatusUnsupportedMediaType)
|
|
case errors.Is(err, imageprocessor.ErrOutputTooLarge):
|
|
s.respondError(w, fmt.Sprintf("size too large for the source: the side "+
|
|
"computed from its proportions is above %d",
|
|
imageprocessor.MaxOutputDimension), http.StatusBadRequest)
|
|
default:
|
|
s.log.Error("image request failed", "error", err)
|
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|