diff --git a/README.md b/README.md index b0287be..e847aaf 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,9 @@ path under `/v1/` answers 200, in maintenance mode too. `blocked_networks`); 502 when the upstream answered with an error status, and for 5 minutes after that for the same source URL; 503 when pixa is busy or in maintenance mode; 500 for any other failure. -- `GET /v1/e//` — an image through an encrypted URL (see Encrypted - URLs). Needs: nothing but the URL. Answers: 200; 400 for a token that does not +- `GET` or `HEAD` `/v1/e//` — an image through an encrypted URL + (see Encrypted URLs). Needs: nothing but the URL. Answers: 200; 304 when + `If-None-Match` matches the image's `ETag`; 400 for a token that does not decrypt, or that asks for a size or fit that is not valid; 410 once it has expired; 504 when the upstream has not sent its response headers within `upstream_fetch_timeout`, but 500 when that time runs out while the image diff --git a/TODO.md b/TODO.md index 0c4ac74..2188291 100644 --- a/TODO.md +++ b/TODO.md @@ -29,6 +29,19 @@ P2: security: referer blacklist # Completed Steps +- 2026-10-04 request IDs returned and passed on, and `/v1/e/` revalidates + (closes #84): a middleware right after chi's `RequestID` sets `X-Request-ID` + on every response from the ID `RequestID` stores in the request context, which + is the request's own `X-Request-ID` when it sent one; the upstream fetch sends + that ID, and the "upstream fetched", "image converted" and "image served" log + lines carry it as `request_id`, a fetch shared by several requests carrying + the first request's; `/v1/e/` sets `ETag`, answers a matching `If-None-Match` + with 304 and is routed for `HEAD`, the `ETag` and 304 code being + `notModified`, which `/v1/image/` calls too; its token checks moved unchanged + into `parseImageEncRequest` to keep `HandleImageEnc` within the line limit; no + `Vary` is added, as no response depends on a request header except the image + routes' CORS headers, for which `go-chi/cors` already sends `Vary: Origin`; + `Vary: Accept` is left to #88. - 2026-10-04 routes, encrypted URLs and config file documented (closes #75): "Routes" in `README.md` lists every route with its method, purpose, what it needs and the status codes it answers with, and says `q` and `fit` are part @@ -438,7 +451,6 @@ P2: security: referer blacklist - P2: HTTP response handling - Last-Modified headers - Vary header for content negotiation - - X-Request-ID propagation - P2: auto format selection (format=auto based on Accept header) - P2: configuration - YAML config file support diff --git a/internal/handlers/image.go b/internal/handlers/image.go index a239ebf..1a46a58 100644 --- a/internal/handlers/image.go +++ b/internal/handlers/image.go @@ -248,6 +248,25 @@ func cacheControl(expires time.Time) string { return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second)) } +// 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( @@ -266,17 +285,8 @@ func (s *Handlers) writeImageResponse( 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 - } - } + if notModified(w, r, resp.ETag) { + return } // Handle HEAD request - return headers only diff --git a/internal/handlers/imageenc.go b/internal/handlers/imageenc.go index 8e5e564..dadb6bd 100644 --- a/internal/handlers/imageenc.go +++ b/internal/handlers/imageenc.go @@ -25,43 +25,8 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc { ctx := r.Context() start := time.Now() - // Extract token from URL - token := chi.URLParam(r, "token") - if token == "" { - s.respondError(w, "missing token", http.StatusBadRequest) - - return - } - - // Decrypt and validate the payload - payload, err := s.encGen.Parse(token) - if err != nil { - if errors.Is(err, encurl.ErrExpired) { - s.log.Debug("encrypted URL expired", "error", err) - s.respondError(w, "URL has expired", http.StatusGone) - - return - } - - s.log.Debug("failed to decrypt URL", "error", err) - s.respondError(w, "invalid encrypted URL", http.StatusBadRequest) - - return - } - - // Convert payload to ImageRequest - req := payload.ToImageRequest() - - // Apply the same dimension and fit-mode bounds as the plain image - // route: a sealed payload is trusted for its origin, not for staying - // within limits, so an over-limit size or unknown fit mode is a 400 - // here rather than an out-of-memory or a 500 from the processor. - err = imgcache.ValidateImageRequest(req) - if err != nil { - s.log.Debug("encrypted URL failed validation", "error", err) - s.respondError(w, "invalid encrypted URL: "+err.Error(), - http.StatusBadRequest) - + req, ok := s.parseImageEncRequest(w, r) + if !ok { return } @@ -95,6 +60,17 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc { w.Header().Set("Cache-Control", cacheControl(req.Expires)) w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus)) + if notModified(w, r, resp.ETag) { + return + } + + // A HEAD request gets the headers only + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + + return + } + // Stream the response written, err := io.Copy(w, resp.Content) if err != nil { @@ -118,6 +94,56 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc { } } +// parseImageEncRequest decrypts the token of an encrypted image URL into an +// ImageRequest and checks it. On a token that is missing, does not decrypt, +// has expired or asks for something not valid, it writes an error response +// and returns false. +func (s *Handlers) parseImageEncRequest( + w http.ResponseWriter, r *http.Request, +) (*imgcache.ImageRequest, bool) { + // Extract token from URL + token := chi.URLParam(r, "token") + if token == "" { + s.respondError(w, "missing token", http.StatusBadRequest) + + return nil, false + } + + // Decrypt and validate the payload + payload, err := s.encGen.Parse(token) + if err != nil { + if errors.Is(err, encurl.ErrExpired) { + s.log.Debug("encrypted URL expired", "error", err) + s.respondError(w, "URL has expired", http.StatusGone) + + return nil, false + } + + s.log.Debug("failed to decrypt URL", "error", err) + s.respondError(w, "invalid encrypted URL", http.StatusBadRequest) + + return nil, false + } + + // Convert payload to ImageRequest + req := payload.ToImageRequest() + + // Apply the same dimension and fit-mode bounds as the plain image + // route: a sealed payload is trusted for its origin, not for staying + // within limits, so an over-limit size or unknown fit mode is a 400 + // here rather than an out-of-memory or a 500 from the processor. + err = imgcache.ValidateImageRequest(req) + if err != nil { + s.log.Debug("encrypted URL failed validation", "error", err) + s.respondError(w, "invalid encrypted URL: "+err.Error(), + http.StatusBadRequest) + + return nil, false + } + + return req, true +} + // handleImageError converts image service errors to HTTP responses. func (s *Handlers) handleImageError(w http.ResponseWriter, err error) { switch { diff --git a/internal/server/routes.go b/internal/server/routes.go index f92b472..d7d515b 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -98,6 +98,7 @@ func (s *Server) SetupRoutes() { // The trailing filename (e.g., /img.jpg) is ignored but helps // browsers with content type r.Get("/e/{token}/*", s.h.HandleImageEnc()) + r.Head("/e/{token}/*", s.h.HandleImageEnc()) }) })