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(), `