check / check (push) Canceled after 0s
The new package internal/imagerequest holds the request type, its two parts Source and Transform, the Format and Fit types, the dimension and fit checks, the /v1/image/ path parsing and the variant key; it imports no other package of pixa. imgcache.ParsedURL, imgcache.ImageRequest, signature.Request, imageprocessor.Request, the second Size, format and fit types and the conversions between them are gone. The image processor takes a Transform only. encurl keeps a private struct with the token's short CBOR field names. VariantKey moves with the function that makes it. Tests follow the moved and renamed code; the tests of the removed conversions go with them. Model: opus-5-5
394 lines
11 KiB
Go
394 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"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/imagerequest"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
"sneak.berlin/go/pixa/internal/magic"
|
|
)
|
|
|
|
// 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, 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.Source.Host,
|
|
"path", req.Source.Path,
|
|
"error", err,
|
|
)
|
|
s.respondError(w, "unauthorized", http.StatusUnauthorized)
|
|
|
|
return
|
|
}
|
|
|
|
// The signature covers the format auto, not the format chosen
|
|
if !s.chooseAutoFormat(w, r, req) {
|
|
return
|
|
}
|
|
|
|
// Get cache key for logging
|
|
cacheKey := imagerequest.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 image URL's path and query parameters into
|
|
// an image request. On invalid input it writes an error response and
|
|
// returns false.
|
|
func (s *Handlers) parseImageRequest(
|
|
w http.ResponseWriter, r *http.Request,
|
|
) (*imagerequest.Request, 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, 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, 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, false
|
|
}
|
|
}
|
|
|
|
req.Signature = query.Get("sig")
|
|
|
|
req.Expires, err = parseExpires(query)
|
|
if err != nil {
|
|
s.respondError(w, err.Error(), http.StatusBadRequest)
|
|
|
|
return nil, 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, 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, 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, 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, false
|
|
}
|
|
|
|
return req, true
|
|
}
|
|
|
|
// 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 takes
|
|
// as no expiration. 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 maps image retrieval errors to HTTP responses.
|
|
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
|
|
}
|
|
|
|
s.log.Error("failed to get image",
|
|
"host", req.Source.Host,
|
|
"path", req.Source.Path,
|
|
"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
|
|
}
|
|
|
|
if 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)
|
|
|
|
return
|
|
}
|
|
|
|
if errors.Is(err, httpfetcher.ErrInvalidContentType) ||
|
|
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat) {
|
|
s.respondError(w, "unsupported source format",
|
|
http.StatusUnsupportedMediaType)
|
|
|
|
return
|
|
}
|
|
|
|
if 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)
|
|
|
|
return
|
|
}
|
|
|
|
if errors.Is(err, imageprocessor.ErrAVIFOutputTooLarge) {
|
|
s.respondError(w, fmt.Sprintf("size too large for AVIF: the output "+
|
|
"would be above %d pixels", imageprocessor.MaxAVIFOutputPixels),
|
|
http.StatusBadRequest)
|
|
|
|
return
|
|
}
|
|
|
|
s.respondError(w, "internal error", http.StatusInternalServerError)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// writeImageResponse writes headers and streams the image content,
|
|
// handling conditional and HEAD requests.
|
|
func (s *Handlers) writeImageResponse(
|
|
w http.ResponseWriter, r *http.Request,
|
|
req *imagerequest.Request, resp *imgcache.ImageResponse,
|
|
cacheKey imagerequest.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", cacheControl(req.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)
|
|
|
|
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",
|
|
"request_id", middleware.GetReqID(r.Context()),
|
|
"cache_key", cacheKey,
|
|
"cache_status", resp.CacheStatus,
|
|
"duration_ms", duration.Milliseconds(),
|
|
"format", req.Transform.Format,
|
|
"served_bytes", servedBytes,
|
|
"fetched_bytes", resp.FetchedBytes,
|
|
)
|
|
}
|