check / check (push) Successful in 3m25s
Both image routes sent Cache-Control: public, max-age=31536000, immutable, so a browser or proxy could keep serving an image for a year after its signed or encrypted URL had expired. The header is now built from the request's Expires: max-age is the whole seconds left until the URL expires, never negative, or one year for a URL with no expiry. ToImageRequest now carries an encrypted URL's expiry onto the request, as the image route already does with exp. immutable stays: it only stops revalidation while a copy is fresh, and freshness now ends at the expiry. README.md documents the header. Model: opus-5-5
300 lines
8.1 KiB
Go
300 lines
8.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"sneak.berlin/go/pixa/internal/encurl"
|
|
"sneak.berlin/go/pixa/internal/httpfetcher"
|
|
"sneak.berlin/go/pixa/internal/imgcache"
|
|
)
|
|
|
|
// HandleImage handles the main image proxy route:
|
|
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
|
func (s *Handlers) HandleImage() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
// 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 resp.ETag != "" {
|
|
w.Header().Set("ETag", resp.ETag)
|
|
|
|
// Check for conditional request (If-None-Match)
|
|
if ifNoneMatch := r.Header.Get("If-None-Match"); ifNoneMatch != "" {
|
|
if ifNoneMatch == resp.ETag {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
|
|
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",
|
|
"cache_key", cacheKey,
|
|
"cache_status", resp.CacheStatus,
|
|
"duration_ms", duration.Milliseconds(),
|
|
"format", req.Format,
|
|
"served_bytes", servedBytes,
|
|
"fetched_bytes", resp.FetchedBytes,
|
|
)
|
|
}
|