check / check (push) Canceled after 0s
The fetcher accepts image/svg+xml again and internal/magic detects SVG. A raster format renders the SVG at the least size that covers the output, held to 8192 on a side, through libvips' thumbnail, which renders at any scale, so an enlarged SVG stays sharp. An SVG's own size is not limited; one with a side more than 8192 times the other is ErrSVGTooThin, answered 415. orig of an SVG at the orig size is the SVG unchanged, cached as a variant; at any other size it is ErrSVGNotOriginalSize, answered 400. The cache reads up to 1024 first bytes of a file to find its format. Render time and memory grow with what the SVG draws: P1 in TODO.md. Tests that pinned SVG as refused now expect it to be read. Model: opus-5-5
458 lines
15 KiB
Go
458 lines
15 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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/imageproxy"
|
|
"sneak.berlin/go/pixa/internal/imagerequest"
|
|
"sneak.berlin/go/pixa/internal/magic"
|
|
)
|
|
|
|
// statusClientClosedRequest is the status nginx calls "client closed
|
|
// request". The image routes answer it when the client goes away before its
|
|
// image is ready, unless its request is the one making the image. Nobody reads
|
|
// it; it is for logs and metrics.
|
|
const statusClientClosedRequest = 499
|
|
|
|
// HandleImage handles the main image proxy route:
|
|
// /v1/image/<host>/<path>/<width>x<height>.<format>, or with no format
|
|
// /v1/image/<host>/<path>/<width>x<height>
|
|
func (s *Handlers) HandleImage() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if s.refuseBlockedReferer(w, r) {
|
|
return
|
|
}
|
|
|
|
req, sig, expires, ok := s.parseImageRequest(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Validate signature if required
|
|
err := s.validateSignature(req, sig, expires)
|
|
if err != nil {
|
|
s.log.Warn("signature validation failed",
|
|
"host", req.Source.Host,
|
|
"path", req.Source.Path,
|
|
"error", err,
|
|
)
|
|
s.respondError(w, "unauthorized", http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
s.serveImage(w, r, req, expires)
|
|
}
|
|
}
|
|
|
|
// 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 image URL's path and query parameters into
|
|
// an image request, and returns it with the URL's signature and expiry. On
|
|
// invalid input it writes an error response and returns false.
|
|
func (s *Handlers) parseImageRequest(
|
|
w http.ResponseWriter, r *http.Request,
|
|
) (*imagerequest.Request, string, time.Time, bool) {
|
|
// The path is parsed decoded once, as r.URL.Path holds it, and not from
|
|
// chi's wildcard: chi routes on the path as the client wrote it whenever
|
|
// that is not Go's own encoding of it, so its wildcard would still hold
|
|
// the %3F before a source query when, say, a "/" in the query is written
|
|
// as %2F
|
|
req, err := imagerequest.ParseImageURL(r.URL.Path)
|
|
if err != nil {
|
|
s.log.Warn("failed to parse image URL",
|
|
"path", r.URL.Path,
|
|
"error", err,
|
|
)
|
|
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
|
|
|
|
return nil, "", time.Time{}, false
|
|
}
|
|
|
|
// Parse signature params from query string. r.URL.Query() would silently
|
|
// drop a pair it cannot decode, such as q=80%, so that q would be served
|
|
// at 85; a query string that cannot be decoded is refused instead. A
|
|
// parameter given more than once is refused too, as only its first value
|
|
// would be read.
|
|
query, err := url.ParseQuery(r.URL.RawQuery)
|
|
if err != nil {
|
|
s.respondError(w, fmt.Sprintf("invalid query string %q: %v",
|
|
r.URL.RawQuery, err), http.StatusBadRequest)
|
|
|
|
return nil, "", time.Time{}, false
|
|
}
|
|
|
|
for name, values := range query {
|
|
if len(values) > 1 {
|
|
s.respondError(w, fmt.Sprintf("invalid %s: given more than once",
|
|
name), http.StatusBadRequest)
|
|
|
|
return nil, "", time.Time{}, false
|
|
}
|
|
}
|
|
|
|
expires, err := parseExpires(query)
|
|
if err != nil {
|
|
s.respondError(w, err.Error(), http.StatusBadRequest)
|
|
|
|
return nil, "", time.Time{}, false
|
|
}
|
|
|
|
// Parse optional quality and fit params. Only a q missing from the URL is
|
|
// 85. A q in the URL that is not a whole number from 1 to 100, an empty
|
|
// one included, is refused, checked as the generator checks its quality
|
|
// field; that check alone would take an empty q as missing.
|
|
qStr := query.Get("q")
|
|
if query.Has("q") && qStr == "" {
|
|
s.respondError(w, `invalid q: not a number, got ""`,
|
|
http.StatusBadRequest)
|
|
|
|
return nil, "", time.Time{}, false
|
|
}
|
|
|
|
req.Transform.Quality, err = parseFormInt(query, "q",
|
|
encurl.DefaultQuality, minQuality, maxQuality)
|
|
if err != nil {
|
|
s.respondError(w, fmt.Sprintf("%v, got %q", err, qStr),
|
|
http.StatusBadRequest)
|
|
|
|
return nil, "", time.Time{}, false
|
|
}
|
|
|
|
// Only a fit missing from the URL is cover. A fit in the URL that is not a
|
|
// fit mode is refused by the fit-mode check below; that check would take an
|
|
// empty fit as missing, so an empty one is refused here.
|
|
req.Transform.FitMode = imagerequest.Fit(query.Get("fit"))
|
|
if query.Has("fit") && req.Transform.FitMode == "" {
|
|
s.respondError(w, `invalid fit: not a fit mode, got ""`, http.StatusBadRequest)
|
|
|
|
return nil, "", time.Time{}, false
|
|
}
|
|
|
|
// Default fit mode if not set
|
|
if req.Transform.FitMode == "" {
|
|
req.Transform.FitMode = imagerequest.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 = imagerequest.ValidateImageRequest(req)
|
|
if err != nil {
|
|
s.respondError(w, "invalid image request: "+err.Error(),
|
|
http.StatusBadRequest)
|
|
|
|
return nil, "", time.Time{}, false
|
|
}
|
|
|
|
return req, query.Get("sig"), expires, true
|
|
}
|
|
|
|
// validateSignature returns nil when the source host of req is on
|
|
// allowlist_hosts and names no port. Otherwise it verifies sig, the URL's
|
|
// signature of req, and expires, the URL's expiry.
|
|
func (s *Handlers) validateSignature(
|
|
req *imagerequest.Request, sig string, expires time.Time,
|
|
) error {
|
|
// An allowlist entry names no port, so a host followed by ":", with or
|
|
// without a port, needs a signature. Go's URL type reads the port as the
|
|
// fetcher does: in an IPv6 address written without brackets, such as
|
|
// 2001:db8::1:8443, it is what follows the last ":". This is checked here
|
|
// and not in allowlist.IsAllowed, which also matches referer_blocklist,
|
|
// where a Referer must be blocked whatever its port. Port and IsAllowed
|
|
// read only the host of a URL.
|
|
sourceURL := &url.URL{Host: req.Source.Host}
|
|
hostHasPort := sourceURL.Port() != "" ||
|
|
strings.HasSuffix(sourceURL.Host, ":")
|
|
|
|
if !hostHasPort && s.allowlist.IsAllowed(sourceURL) {
|
|
return nil
|
|
}
|
|
|
|
// Signature required for non-allowed hosts
|
|
return s.signer.Verify(req, sig, expires)
|
|
}
|
|
|
|
// parseExpires reads the exp query parameter, a Unix time in seconds. An exp
|
|
// missing from the URL gives the zero time, which the signature check refuses
|
|
// as a missing exp. An exp in the URL that is not a whole number, an empty
|
|
// one included, is an error naming exp and the value.
|
|
func parseExpires(query url.Values) (time.Time, error) {
|
|
if !query.Has("exp") {
|
|
return time.Time{}, nil
|
|
}
|
|
|
|
expStr := query.Get("exp")
|
|
|
|
exp, err := strconv.ParseInt(expStr, 10, 64)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("%w exp: not a number, got %q",
|
|
errInvalidFormField, expStr)
|
|
}
|
|
|
|
return time.Unix(exp, 0), nil
|
|
}
|
|
|
|
// HandleTooManyRequests answers 429 Too Many Requests with the JSON error
|
|
// body the image routes send for their other errors. The image routes' rate
|
|
// limit calls it for a request over the limit.
|
|
func (s *Handlers) HandleTooManyRequests() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, _ *http.Request) {
|
|
s.respondError(w, "too many requests, try again later",
|
|
http.StatusTooManyRequests)
|
|
}
|
|
}
|
|
|
|
// respondImageError answers err, the image service's error for req, on both
|
|
// image routes. It logs a 500 at error level, as pixa's own failure. It logs
|
|
// the other answers at warn level, since the client, the upstream or a full
|
|
// limit caused them. The 415 for a source refused for its first bytes is not
|
|
// logged here, because the image service logs it.
|
|
func (s *Handlers) respondImageError(
|
|
w http.ResponseWriter, req *imagerequest.Request, err error,
|
|
) {
|
|
// An upstream body whose first bytes are not those of the format its
|
|
// Content-Type names is the upstream's error, not pixa's: the image
|
|
// service has logged it as a refused source.
|
|
if errors.Is(err, magic.ErrMagicByteMismatch) ||
|
|
errors.Is(err, magic.ErrUnknownFormat) ||
|
|
errors.Is(err, magic.ErrNotEnoughData) {
|
|
s.respondError(w, "unsupported source format",
|
|
http.StatusUnsupportedMediaType)
|
|
|
|
return
|
|
}
|
|
|
|
var (
|
|
status int
|
|
message string
|
|
)
|
|
|
|
switch {
|
|
case errors.Is(err, httpfetcher.ErrSSRFBlocked):
|
|
status, message = http.StatusForbidden, "forbidden"
|
|
case errors.Is(err, httpfetcher.ErrUpstreamError):
|
|
status, message = http.StatusBadGateway, "upstream error"
|
|
case errors.Is(err, httpfetcher.ErrUpstreamTimeout):
|
|
status, message = http.StatusGatewayTimeout, "upstream timeout"
|
|
case errors.Is(err, httpfetcher.ErrTooManyConnections),
|
|
errors.Is(err, imageprocessor.ErrTooManyImages),
|
|
errors.Is(err, imageproxy.ErrProcessingTimedOut):
|
|
status = http.StatusServiceUnavailable
|
|
message = "server busy, try again later"
|
|
case errors.Is(err, httpfetcher.ErrInvalidContentType),
|
|
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat):
|
|
status = http.StatusUnsupportedMediaType
|
|
message = "unsupported source format"
|
|
case errors.Is(err, imageprocessor.ErrSVGTooThin):
|
|
status = http.StatusUnsupportedMediaType
|
|
message = fmt.Sprintf("SVG source too thin to render: one side is "+
|
|
"more than %d times the other", imageprocessor.MaxInputDimension)
|
|
case errors.Is(err, imageprocessor.ErrOutputTooLarge):
|
|
status = http.StatusBadRequest
|
|
message = fmt.Sprintf("size too large for the source: the side "+
|
|
"computed from its proportions is above %d",
|
|
imageprocessor.MaxOutputDimension)
|
|
case errors.Is(err, imageprocessor.ErrSVGNotOriginalSize):
|
|
status = http.StatusBadRequest
|
|
message = "an SVG source is returned as SVG only at the orig size; " +
|
|
"ask for another format to resize it"
|
|
case errors.Is(err, imageprocessor.ErrAVIFOutputTooLarge):
|
|
status = http.StatusBadRequest
|
|
message = fmt.Sprintf("size too large for AVIF: the output "+
|
|
"would be above %d pixels", imageprocessor.MaxAVIFOutputPixels)
|
|
default:
|
|
s.log.Error("failed to get image",
|
|
"host", req.Source.Host,
|
|
"path", req.Source.Path,
|
|
"error", err,
|
|
)
|
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
s.log.Warn("failed to get image",
|
|
"host", req.Source.Host,
|
|
"path", req.Source.Path,
|
|
"error", err,
|
|
)
|
|
s.respondError(w, message, status)
|
|
}
|
|
|
|
// cacheControl returns the Cache-Control header for an image served through a
|
|
// URL that expires at expires, or never when expires is the zero time. A cache
|
|
// may keep the image for a year, but not past the URL's expiry, after which
|
|
// pixa refuses the URL. The seconds left are rounded down and never negative.
|
|
// immutable only stops revalidation while the image is fresh, so it also ends
|
|
// at the expiry.
|
|
func cacheControl(expires time.Time) string {
|
|
const oneYear = 365 * 24 * time.Hour
|
|
|
|
maxAge := oneYear
|
|
|
|
if !expires.IsZero() {
|
|
maxAge = min(maxAge, max(time.Until(expires), 0))
|
|
}
|
|
|
|
return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second))
|
|
}
|
|
|
|
// refuseBlockedReferer answers 403 with a JSON error when the request's Referer
|
|
// names a host on referer_blocklist, and reports whether it answered. A request
|
|
// with no Referer, or one that does not parse as a URL with a host, is not
|
|
// refused.
|
|
func (s *Handlers) refuseBlockedReferer(
|
|
w http.ResponseWriter, r *http.Request,
|
|
) bool {
|
|
referer, err := url.Parse(r.Referer())
|
|
if err != nil || !s.refererBlocklist.IsAllowed(referer) {
|
|
return false
|
|
}
|
|
|
|
s.respondError(w, "referer blocked", http.StatusForbidden)
|
|
|
|
return true
|
|
}
|
|
|
|
// notModified sets the ETag header to etag and, when the request's
|
|
// If-None-Match is that ETag, answers 304 Not Modified. It reports whether it
|
|
// answered. An empty etag sets no header and never answers.
|
|
func notModified(w http.ResponseWriter, r *http.Request, etag string) bool {
|
|
if etag == "" {
|
|
return false
|
|
}
|
|
|
|
w.Header().Set("ETag", etag)
|
|
|
|
if r.Header.Get("If-None-Match") != etag {
|
|
return false
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNotModified)
|
|
|
|
return true
|
|
}
|
|
|
|
// serveImage serves the image that req asks for. Both image routes call it
|
|
// once they have the request and its URL's expiry, which ends the
|
|
// Cache-Control max-age. It chooses the format for the format auto, answers
|
|
// If-None-Match with 304 and HEAD with the headers only. It answers 499 when
|
|
// the image service stopped because the client went away, and any other error
|
|
// of the image service with respondImageError.
|
|
func (s *Handlers) serveImage(
|
|
w http.ResponseWriter, r *http.Request,
|
|
req *imagerequest.Request, expires time.Time,
|
|
) {
|
|
// The signature or the token covers the format auto, not the format chosen
|
|
format, ok := s.chooseAutoFormat(w, r, req.Transform.Format)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
req.Transform.Format = format
|
|
|
|
startTime := time.Now()
|
|
|
|
resp, err := s.imgSvc.Get(r.Context(), req)
|
|
|
|
// The client went away before its image was ready, which is not pixa's
|
|
// failure. A cancellation while the request's context is live is pixa's,
|
|
// and respondImageError answers it 500.
|
|
if errors.Is(err, context.Canceled) && r.Context().Err() != nil {
|
|
s.log.Info("client went away before the image was ready",
|
|
"request_id", middleware.GetReqID(r.Context()),
|
|
"host", req.Source.Host,
|
|
"path", req.Source.Path,
|
|
)
|
|
w.WriteHeader(statusClientClosedRequest)
|
|
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
s.respondImageError(w, req, 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 control headers
|
|
w.Header().Set("Cache-Control", cacheControl(expires))
|
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
|
|
|
if notModified(w, r, resp.ETag) {
|
|
return
|
|
}
|
|
|
|
// Handle HEAD request - return headers only
|
|
if r.Method == http.MethodHead {
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
return
|
|
}
|
|
|
|
// Stream the response
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
// A failed write is most often a client that has gone away, not pixa's
|
|
// failure, so it is logged at warn level
|
|
servedBytes, err := io.Copy(w, resp.Content)
|
|
if err != nil {
|
|
s.log.Warn("failed to write response",
|
|
"request_id", middleware.GetReqID(r.Context()),
|
|
"host", req.Source.Host,
|
|
"path", req.Source.Path,
|
|
"served_bytes", servedBytes,
|
|
"error", err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Log cache status and timing after serving
|
|
duration := time.Since(startTime)
|
|
s.log.Info("image served",
|
|
"request_id", middleware.GetReqID(r.Context()),
|
|
"cache_key", imagerequest.CacheKey(req),
|
|
"host", req.Source.Host,
|
|
"path", req.Source.Path,
|
|
"cache_status", resp.CacheStatus,
|
|
"duration_ms", duration.Milliseconds(),
|
|
"format", req.Transform.Format,
|
|
"served_bytes", servedBytes,
|
|
"fetched_bytes", resp.FetchedBytes,
|
|
)
|
|
}
|