check / check (push) Waiting to run
A /v1/image/ URL whose last segment is a size with no format, such as 800x600 or orig, is served as JPEG XL and signed as jxl, so it shares the signature of the same URL ending in .jxl. An encrypted URL whose token holds no format is served as JPEG XL, as encurl.DefaultFormat is now jxl. The generator page selects JPEG XL by default, and a form with an empty format, or none, makes a URL whose name ends in .jxl. The image processor no longer takes an empty format as orig: both routes give every request a format, so it refuses a request with none instead of keeping a second default. auto still ends with JPEG. Model: opus-5-5
348 lines
9.4 KiB
Go
348 lines
9.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"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"
|
|
)
|
|
|
|
// 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.SourceHost,
|
|
"path", req.SourcePath,
|
|
"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 := imgcache.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 wildcard path and query parameters into
|
|
// an ImageRequest. On invalid input it writes an error response and
|
|
// returns false.
|
|
func (s *Handlers) parseImageRequest(
|
|
w http.ResponseWriter, r *http.Request,
|
|
) (*imgcache.ImageRequest, bool) {
|
|
// Get the wildcard path from chi
|
|
pathParam := chi.URLParam(r, "*")
|
|
|
|
// Parse the URL path
|
|
parsed, err := imgcache.ParseImagePath(pathParam)
|
|
if err != nil {
|
|
s.log.Warn("failed to parse image URL",
|
|
"path", pathParam,
|
|
"error", err,
|
|
)
|
|
s.respondError(w, "invalid image URL: "+err.Error(), http.StatusBadRequest)
|
|
|
|
return nil, false
|
|
}
|
|
|
|
// Convert to ImageRequest
|
|
req := parsed.ToImageRequest()
|
|
|
|
// 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.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.FitMode = imgcache.FitMode(query.Get("fit"))
|
|
if query.Has("fit") && req.FitMode == "" {
|
|
s.respondError(w, `invalid fit: not a fit mode, got ""`, http.StatusBadRequest)
|
|
|
|
return nil, false
|
|
}
|
|
|
|
// Default fit mode if not set
|
|
if req.FitMode == "" {
|
|
req.FitMode = imgcache.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 = imgcache.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
|
|
}
|
|
|
|
// respondImageError maps image retrieval errors to HTTP responses.
|
|
func (s *Handlers) respondImageError(
|
|
w http.ResponseWriter, req *imgcache.ImageRequest, err error,
|
|
) {
|
|
s.log.Error("failed to get image",
|
|
"host", req.SourceHost,
|
|
"path", req.SourcePath,
|
|
"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) {
|
|
s.respondError(w, "server busy, try again later",
|
|
http.StatusServiceUnavailable)
|
|
|
|
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 *imgcache.ImageRequest, resp *imgcache.ImageResponse,
|
|
cacheKey imgcache.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.Format,
|
|
"served_bytes", servedBytes,
|
|
"fetched_bytes", resp.FetchedBytes,
|
|
)
|
|
}
|