1 Commits
Author SHA1 Message Date
sneak 0d03600ab0 Serve both image routes with one function and one error mapping (closes #316)
check / check (push) Waiting to run
HandleImage parses the path and verifies the signature, and HandleImageEnc
decrypts the token. Each then calls serveImage, which chooses the format for
auto, gets the image and serves it. respondImageError answers the image
service's errors on both routes, and handleImageError is gone. /v1/image/ now
answers 504 when the upstream sends no response headers within
upstream_fetch_timeout, as README.md documented for /v1/e/. respondImageError
logs a 500 at error level and the other answers at warn. The image service
alone logs a source refused for its first bytes. A failed write is logged once,
at warn, with the request and the bytes served. README.md lists each image
route's answers as bullets. Tests send the same errors through both routes.

Model: opus-5-5
2026-10-26 14:02:41 +00:00
5 changed files with 22 additions and 14 deletions
+2 -1
View File
@@ -289,7 +289,8 @@ path under `/v1/` answers 200, in maintenance mode too.
- 400 for a token that does not decrypt.
- 400 for a token that asks for a size or fit that is not valid.
- 410 once the token has expired.
- 400 for an `Accept` header that is not valid.
- 400 when the token's format is `auto` and the `Accept` header is not
valid.
- These answers, for the same causes as on `/v1/image/`:
- 406
- 403
+6 -3
View File
@@ -54,9 +54,12 @@ P2: security: per-origin rate limiting
warn level.
- The "image served" line carries the host, the path and `fetched_bytes` on
both routes. The debug line "encrypted image request" is gone.
- A failed write is logged once, as "failed to write response" at error
level. It carries the `request_id`, the host, the path, `served_bytes` and
the error, and is not also logged as "image served".
- The debug line "failed to decrypt URL" is now "invalid encrypted URL". A
token that decrypts to a payload that does not decode also gets it.
- A failed write is logged once, as "failed to write response" at warn
level, since most often the client has gone away. It carries the
`request_id`, the host, the path, `served_bytes` and the error, and is not
also logged as "image served".
- 2026-10-26 every key derived from the signing key is derived in one place
(closes #315): `seal.DeriveKeys` returns them as one `seal.Keys`, with their
salts declared beside it. `internal/providers` calls it once at startup. Each
+8 -5
View File
@@ -190,8 +190,8 @@ func (s *Handlers) validateSignature(
}
// 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
// missing from the URL gives the zero time, which the signature check refuses
// as a missing exp. 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") {
@@ -221,8 +221,9 @@ func (s *Handlers) HandleTooManyRequests() http.HandlerFunc {
// respondImageError answers err, the image service's error for req, on both
// image routes. It logs a 500 at error level, as pixa's own failure. It logs
// every other answer at warn level, since the client, the upstream or a full
// limit caused it.
// the other answers at warn level, since the client, the upstream or a full
// limit caused them. The 415 for a source refused for its first bytes is not
// logged here, because the image service logs it.
func (s *Handlers) respondImageError(
w http.ResponseWriter, req *imagerequest.Request, err error,
) {
@@ -394,9 +395,11 @@ func (s *Handlers) serveImage(
// Stream the response
w.WriteHeader(http.StatusOK)
// A failed write is most often a client that has gone away, not pixa's
// failure, so it is logged at warn level
servedBytes, err := io.Copy(w, resp.Content)
if err != nil {
s.log.Error("failed to write response",
s.log.Warn("failed to write response",
"request_id", middleware.GetReqID(r.Context()),
"host", req.Source.Host,
"path", req.Source.Path,
@@ -518,7 +518,7 @@ func (failingResponseWriter) Write([]byte) (int, error) {
}
// TestImageRoutes_FailedWriteLoggedOnce checks that both image routes log a
// failed write of the image once, at error level, with the request's ID, the
// failed write of the image once, at warn level, with the request's ID, the
// source host and path, the bytes served and the error. Neither may also log
// the image as served.
func TestImageRoutes_FailedWriteLoggedOnce(t *testing.T) {
@@ -568,7 +568,7 @@ func TestImageRoutes_FailedWriteLoggedOnce(t *testing.T) {
}
want := map[string]any{
"level": slog.LevelError.String(),
"level": slog.LevelWarn.String(),
"request_id": requestID,
"host": signedHost,
"path": photoPath,
+4 -3
View File
@@ -54,7 +54,7 @@ func (s *Handlers) parseImageEncRequest(
return nil, time.Time{}, false
}
s.log.Debug("failed to decrypt URL", "error", err)
s.log.Debug("invalid encrypted URL", "error", err)
s.respondError(w, "invalid encrypted URL", http.StatusBadRequest)
return nil, time.Time{}, false
@@ -62,8 +62,9 @@ func (s *Handlers) parseImageEncRequest(
// 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.
// within limits. This refuses an over-limit size before the source is
// fetched. An unknown fit mode is a 400 here rather than a 500 from the
// processor.
err = imagerequest.ValidateImageRequest(req)
if err != nil {
s.log.Debug("encrypted URL failed validation", "error", err)