From 847bd360872addc4bde99629fb5542a48362dbb4 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Thu, 8 Oct 2026 09:14:32 +0000 Subject: [PATCH] Serve JPEG XL when a request names no format (closes #222) A /v1/image/ URL whose last segment is a size with no format, such as 800x600 or orig, is served as JPEG XL and signed as jxl, so it shares the signature of the same URL ending in .jxl. An encrypted URL whose token holds no format is served as JPEG XL, as encurl.DefaultFormat is now jxl. The generator page selects JPEG XL by default, and a form with an empty format, or none, makes a URL whose name ends in .jxl. The image processor no longer takes an empty format as orig: both routes give every request a format, so it refuses a request with none instead of keeping a second default. auto still ends with JPEG. Model: opus-5-5 --- README.md | 51 +++--- TODO.md | 9 + internal/encurl/encurl.go | 4 +- internal/handlers/auth.go | 11 +- internal/handlers/image.go | 3 +- .../handlers/jpegxl_default_internal_test.go | 162 ++++++++++++++++++ .../empty_format_internal_test.go | 21 +++ internal/imageprocessor/imageprocessor.go | 4 +- internal/imgcache/service.go | 4 +- internal/imgcache/urlparser.go | 28 ++- internal/server/routes.go | 3 +- internal/templates/generator.html | 2 +- 12 files changed, 259 insertions(+), 43 deletions(-) create mode 100644 internal/handlers/jpegxl_default_internal_test.go create mode 100644 internal/imageprocessor/empty_format_internal_test.go diff --git a/README.md b/README.md index dbbde09..8b6b032 100644 --- a/README.md +++ b/README.md @@ -175,20 +175,21 @@ path under `/v1/` answers 200, in maintenance mode too. with the page naming a field that is not valid; 500 when the URL cannot be made. - `GET /logout` — end the login session. Needs: nothing. Answers: 303 to `/`. -- `GET` or `HEAD` `/v1/image///.` — an image, fetched, - resized and converted (below). Needs: a signature, unless the host is - allowlisted (see Source Hosts). Answers: 200; 304 when `If-None-Match` matches - the image's `ETag`; 400 for a URL or parameter that is not valid, or for the - format `auto` an `Accept` header that is not valid; 406 for the format `auto` - when `Accept` allows none of the formats it chooses from; 401 for a missing or - wrong signature, a missing `exp` or an `exp` in the past; 403 when the - request's `Referer` names a host in `referer_blocklist`, checked before the - signature, the cache and the upstream fetch; 403 when the upstream host, or a - host it redirects to, is `localhost`, ends in `.localhost` or `.local`, or has - an address in a blocked network (see `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` or `HEAD` `/v1/image///.`, or + `/v1/image///` with no format — an image, fetched, resized + and converted (below). Needs: a signature, unless the host is allowlisted (see + Source Hosts). Answers: 200; 304 when `If-None-Match` matches the image's + `ETag`; 400 for a URL or parameter that is not valid, or for the format `auto` + an `Accept` header that is not valid; 406 for the format `auto` when `Accept` + allows none of the formats it chooses from; 401 for a missing or wrong + signature, a missing `exp` or an `exp` in the past; 403 when the request's + `Referer` names a host in `referer_blocklist`, checked before the signature, + the cache and the upstream fetch; 403 when the upstream host, or a host it + redirects to, is `localhost`, ends in `.localhost` or `.local`, or has an + address in a blocked network (see `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` 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 @@ -231,10 +232,11 @@ proxy in front of pixa must pass that header on unchanged. A form body over 1 MiB is refused with 413. The image routes answer the errors listed for them with JSON holding `error`, `status` and `timestamp`. -An image URL has this form: +An image URL has one of these forms, the second with no format: ``` /v1/image///.?sig=&exp=&q=&fit= +/v1/image///?sig=&exp=&q=&fit= ``` Images are only fetched from origins using TLS with valid certificates, unless @@ -245,7 +247,9 @@ A request whose query string cannot be decoded, or gives any parameter more than once, is refused with 400. - ``: one of `orig` (or `original`), `jpeg` (or `jpg`), `png`, `webp`, - `avif`, `jxl` (JPEG XL), `gif`, or `auto` (below) + `avif`, `jxl` (JPEG XL), `gif`, or `auto` (below). A URL with no format (the + second form, with no dot after the size) is served as JPEG XL, the default, as + with `jxl` - ``: `orig` or `x` (e.g. `800x600`) - `sig` and `exp`: the signature and its expiry, needed unless the host is allowlisted (see Signature Specification) @@ -321,13 +325,15 @@ nor change what it asks for. lasts 30 days, or until `/logout`. 2. On the generator page, give the source image's URL, the width and height, the format, quality and fit, and how long the URL lasts, then submit the form - (`POST /generate`). Width and height both empty or `0` keep the original - size; if only one of them is empty or `0`, that side is scaled to keep the - image's proportions. + (`POST /generate`). The format is JPEG XL unless another is chosen; a form + sent with an empty format, or none, also makes a JPEG XL URL. Width and + height both empty or `0` keep the original size; if only one of them is empty + or `0`, that side is scaled to keep the image's proportions. 3. The page shows the URL, `https:///v1/e//img.`, and when it expires. `` is the host the page was opened on, and the URL starts with `http` instead while `debug` is on. The name after the token is ignored - and only gives the URL a file extension, `jpg` for `orig` and `auto`. + and only gives the URL a file extension, `jpg` for `orig` and `auto`, and + `jxl` for a form with no format. The token holds the source's host, path and query and the size, format, quality, fit and expiry, encrypted with a key derived from `signing_key`. The source @@ -387,8 +393,9 @@ Where: - `width` — requested width in pixels, `0` for original - `height` — requested height in pixels, `0` for original - `format` — output format, one of those listed under Routes, with `original` - signed as `orig` and `jpg` as `jpeg`; `auto` is signed as `auto`, not as the - format chosen for the request + signed as `orig`, `jpg` as `jpeg`, and no format as `jxl`, so a URL with no + format has the signature of the same URL ending in `.jxl`; `auto` is signed as + `auto`, not as the format chosen for the request - `expiration` — the URL's `exp` query parameter, the Unix timestamp when the signature expires; a request whose `exp` is not a whole number, an empty `exp=` included, is refused with 400 diff --git a/TODO.md b/TODO.md index 4622779..4bea044 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,15 @@ P2: security: per-IP rate limiting on the image routes # Completed Steps +- 2026-10-08 JPEG XL is the default output (closes #222): a `/v1/image/` URL + whose last segment is a size with no format, such as `800x600` or `orig`, is + served as JPEG XL and signed as `jxl`, so it has the signature of the same URL + ending in `.jxl`. An encrypted URL whose token holds no format is served as + JPEG XL (`encurl.DefaultFormat`). The generator page selects JPEG XL until + another format is chosen, and a form with an empty format, or none, makes a + JPEG XL URL whose name ends in `.jxl`. The image processor no longer takes an + empty format as `orig`: both routes give every request a format, and it + refuses a request with none. `auto` still ends with JPEG. - 2026-10-08 JPEG XL as an input and output format (part of #222): a source whose bytes start with either JPEG XL signature, the bare codestream's `FF 0A` or the container's, is detected as `image/jxl`, which the upstream fetch diff --git a/internal/encurl/encurl.go b/internal/encurl/encurl.go index 8815bf0..13a5772 100644 --- a/internal/encurl/encurl.go +++ b/internal/encurl/encurl.go @@ -14,7 +14,7 @@ import ( // Default values for optional fields. const ( DefaultQuality = 85 - DefaultFormat = imgcache.FormatOriginal + DefaultFormat = imgcache.FormatJXL DefaultFitMode = imgcache.FitCover // HKDF salt for URL encryption key derivation @@ -37,7 +37,7 @@ type Payload struct { SourceQuery string `cbor:"q,omitempty"` // optional Width int `cbor:"w,omitempty"` // 0 = original Height int `cbor:"ht,omitempty"` // 0 = original - Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: orig + Format imgcache.ImageFormat `cbor:"f,omitempty"` // default: jxl Quality int `cbor:"ql,omitempty"` // default: 85 FitMode imgcache.FitMode `cbor:"fm,omitempty"` // default: cover ExpiresAt int64 `cbor:"e,omitempty"` // 0 = never expires diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 8184ce9..052ccd2 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -369,10 +369,15 @@ func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) stri scheme = "http" } - // Determine file extension for the trailing filename + // Determine file extension for the trailing filename. A form with no + // format makes a token with none, which is served as encurl.DefaultFormat. ext := format - if ext == "" || ext == "orig" || ext == "auto" { - ext = "jpg" // Default extension + + switch format { + case "": + ext = string(encurl.DefaultFormat) + case "orig", "auto": + ext = "jpg" } return scheme + "://" + r.Host + "/v1/e/" + url.PathEscape(token) + "/img." + ext diff --git a/internal/handlers/image.go b/internal/handlers/image.go index b3c3c31..1aee1cc 100644 --- a/internal/handlers/image.go +++ b/internal/handlers/image.go @@ -18,7 +18,8 @@ import ( ) // HandleImage handles the main image proxy route: -// /v1/image///x. +// /v1/image///x., or with no format +// /v1/image///x func (s *Handlers) HandleImage() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if s.refuseBlockedReferer(w, r) { diff --git a/internal/handlers/jpegxl_default_internal_test.go b/internal/handlers/jpegxl_default_internal_test.go new file mode 100644 index 0000000..1d22bca --- /dev/null +++ b/internal/handlers/jpegxl_default_internal_test.go @@ -0,0 +1,162 @@ +package handlers + +import ( + "fmt" + "log/slog" + "maps" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/davidbyttow/govips/v2/vips" + + "sneak.berlin/go/pixa/internal/encurl" + "sneak.berlin/go/pixa/internal/imgcache" + "sneak.berlin/go/pixa/internal/signature" +) + +// requireJPEGXL requires that rec answers 200 with a JPEG XL image. +func requireJPEGXL(t *testing.T, rec *httptest.ResponseRecorder) { + t.Helper() + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body %q", + rec.Code, http.StatusOK, rec.Body.String()) + } + + if got := rec.Header().Get("Content-Type"); got != jxlType { + t.Errorf("Content-Type = %q, want %s", got, jxlType) + } + + if got := vips.DetermineImageType(rec.Body.Bytes()); got != vips.ImageTypeJXL { + t.Errorf("body is %s, want jxl", vips.ImageTypes[got]) + } +} + +// TestImageWithoutFormat_ServesJPEGXL verifies that a /v1/image/ URL whose +// last segment is a size with no format, 50x50 or orig, answers JPEG XL. +func TestImageWithoutFormat_ServesJPEGXL(t *testing.T) { + t.Parallel() + + route := newImageRoute(t, newPhotoFetcher(t, allowlistedHost)) + + for _, size := range []string{"50x50", "orig"} { + target := "/v1/image/" + allowlistedHost + photoPath + "/" + size + requireJPEGXL(t, sendGet(t, route, target)) + } +} + +// TestImageWithoutFormat_SignedAsJXL verifies that a /v1/image/ URL with no +// format is signed as jxl: the signature made for the URL ending in .jxl is +// accepted for the same URL without .jxl. +func TestImageWithoutFormat_SignedAsJXL(t *testing.T) { + t.Parallel() + + route := newImageRoute(t, newPhotoFetcher(t, signedHost)) + expires := time.Now().Add(time.Hour) + + sig := signature.New(testSigningKey).Sign(&signature.Request{ + SourceHost: signedHost, + SourcePath: photoPath, + Width: 50, + Height: 50, + Format: string(imgcache.FormatJXL), + Quality: encurl.DefaultQuality, + FitMode: string(imgcache.FitCover), + Expires: expires, + }) + query := fmt.Sprintf("?sig=%s&exp=%d", sig, expires.Unix()) + + for _, size := range []string{"50x50.jxl", "50x50"} { + target := "/v1/image/" + signedHost + photoPath + "/" + size + query + requireJPEGXL(t, sendGet(t, route, target)) + } +} + +// TestImageEncWithoutFormat_ServesJPEGXL verifies that an encrypted URL whose +// token holds no format answers JPEG XL. +func TestImageEncWithoutFormat_ServesJPEGXL(t *testing.T) { + t.Parallel() + + h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler)) + + token, err := h.encGen.Generate(&encurl.Payload{ + SourceHost: signedHost, + SourcePath: photoPath, + Width: 50, + Height: 50, + }) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + + requireJPEGXL(t, getEncToken(srv, token)) +} + +// TestGeneratorPage_SelectsJPEGXL verifies that the generator page's format +// choice is JPEG XL until another is chosen. +func TestGeneratorPage_SelectsJPEGXL(t *testing.T) { + t.Parallel() + + h, srv := newCSRFTestRouter(t) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) + req.AddCookie(newSessionCookie(t, h)) + + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if !strings.Contains(rec.Body.String(), `