Give /v1/e/ ETag, 304 and HEAD as /v1/image/ has (closes #84)
check / check (push) Failing after 2s
check / check (push) Failing after 2s
The ETag and If-None-Match code of the /v1/image/ handler becomes notModified, which both image handlers call; /v1/e/ answers HEAD with the headers only and is routed for HEAD. HandleImageEnc was at the 80-line function limit, so its token checks move unchanged into parseImageEncRequest, as parseImageRequest does for /v1/image/. No Vary is added: only the image routes' CORS headers depend on a request header, and go-chi/cors already sends Vary: Origin with them. Model: opus-5-5
This commit is contained in:
@@ -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/<token>/<name>` — 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/<token>/<name>` — 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
|
||||
|
||||
@@ -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
|
||||
|
||||
+21
-11
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user