check / check (push) Waiting to run
The fetcher no longer accepts image/svg+xml, so an SVG source gets the refusal every unaccepted upstream type gets, which both image routes now answer with 415 where they answered 500. orig of an input the processor cannot name is ErrUnsupportedInputFormat, also answered 415, where it was saved as JPEG. README.md drops SVG from the source formats and lists the 415. Model: opus-5-5
366 lines
10 KiB
Go
366 lines
10 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
|
|
}
|
|
|
|
// 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 *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
|
|
}
|
|
|
|
if errors.Is(err, httpfetcher.ErrInvalidContentType) ||
|
|
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat) {
|
|
s.respondError(w, "unsupported source format",
|
|
http.StatusUnsupportedMediaType)
|
|
|
|
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,
|
|
)
|
|
}
|