diff --git a/README.md b/README.md index dbbde09..c000d4f 100644 --- a/README.md +++ b/README.md @@ -231,10 +231,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 +246,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 +324,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 +392,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/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(), `