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
|
`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
|
for 5 minutes after that for the same source URL; 503 when pixa is busy or in
|
||||||
maintenance mode; 500 for any other failure.
|
maintenance mode; 500 for any other failure.
|
||||||
- `GET /v1/e/<token>/<name>` — an image through an encrypted URL (see Encrypted
|
- `GET` or `HEAD` `/v1/e/<token>/<name>` — an image through an encrypted URL
|
||||||
URLs). Needs: nothing but the URL. Answers: 200; 400 for a token that does not
|
(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
|
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
|
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
|
`upstream_fetch_timeout`, but 500 when that time runs out while the image
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ P2: security: referer blacklist
|
|||||||
|
|
||||||
# Completed Steps
|
# 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):
|
- 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
|
"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
|
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
|
- P2: HTTP response handling
|
||||||
- Last-Modified headers
|
- Last-Modified headers
|
||||||
- Vary header for content negotiation
|
- Vary header for content negotiation
|
||||||
- X-Request-ID propagation
|
|
||||||
- P2: auto format selection (format=auto based on Accept header)
|
- P2: auto format selection (format=auto based on Accept header)
|
||||||
- P2: configuration
|
- P2: configuration
|
||||||
- YAML config file support
|
- YAML config file support
|
||||||
|
|||||||
+20
-10
@@ -248,6 +248,25 @@ func cacheControl(expires time.Time) string {
|
|||||||
return fmt.Sprintf("public, max-age=%d, immutable", int64(maxAge/time.Second))
|
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,
|
// writeImageResponse writes headers and streams the image content,
|
||||||
// handling conditional and HEAD requests.
|
// handling conditional and HEAD requests.
|
||||||
func (s *Handlers) writeImageResponse(
|
func (s *Handlers) writeImageResponse(
|
||||||
@@ -266,18 +285,9 @@ func (s *Handlers) writeImageResponse(
|
|||||||
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
||||||
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
||||||
|
|
||||||
if resp.ETag != "" {
|
if notModified(w, r, 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
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle HEAD request - return headers only
|
// Handle HEAD request - return headers only
|
||||||
if r.Method == http.MethodHead {
|
if r.Method == http.MethodHead {
|
||||||
|
|||||||
@@ -25,43 +25,8 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
// Extract token from URL
|
req, ok := s.parseImageEncRequest(w, r)
|
||||||
token := chi.URLParam(r, "token")
|
if !ok {
|
||||||
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)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +60,17 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
w.Header().Set("Cache-Control", cacheControl(req.Expires))
|
||||||
w.Header().Set("X-Pixa-Cache", string(resp.CacheStatus))
|
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
|
// Stream the response
|
||||||
written, err := io.Copy(w, resp.Content)
|
written, err := io.Copy(w, resp.Content)
|
||||||
if err != nil {
|
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.
|
// handleImageError converts image service errors to HTTP responses.
|
||||||
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
func (s *Handlers) handleImageError(w http.ResponseWriter, err error) {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ func (s *Server) SetupRoutes() {
|
|||||||
// The trailing filename (e.g., /img.jpg) is ignored but helps
|
// The trailing filename (e.g., /img.jpg) is ignored but helps
|
||||||
// browsers with content type
|
// browsers with content type
|
||||||
r.Get("/e/{token}/*", s.h.HandleImageEnc())
|
r.Get("/e/{token}/*", s.h.HandleImageEnc())
|
||||||
|
r.Head("/e/{token}/*", s.h.HandleImageEnc())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user