diff --git a/README.md b/README.md index 95d9079..bfa0451 100644 --- a/README.md +++ b/README.md @@ -175,12 +175,14 @@ path under `/v1/` answers 200, in maintenance mode too. - `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; 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 + 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. @@ -190,7 +192,8 @@ path under `/v1/` answers 200, in maintenance mode too. 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 `upstream_fetch_timeout`, but 500 when that time runs out while the image - itself is still arriving; 403, 502, 503 and 500 as for `/v1/image/`. + itself is still arriving; 400 for an `Accept` header that is not valid, and + 406, 403, 502, 503 and 500, as for `/v1/image/`. - `GET /robots.txt` — asks every crawler to stay away (`Disallow: /`). Needs: nothing. Answers: 200. - `GET /.well-known/healthcheck.json` — JSON with `status` (`ok`), `now`, @@ -239,7 +242,7 @@ 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`, `gif` + `avif`, `gif`, or `auto` (below) - ``: `orig` or `x` (e.g. `800x600`) - `sig` and `exp`: the signature and its expiry, needed unless the host is allowlisted (see Signature Specification) @@ -247,6 +250,24 @@ once, is refused with 400. both optional (values under Signature Specification). Both are part of what is cached, so each value of either is a separate cached image. +With the format `auto`, pixa chooses the format for each request from its +`Accept` header, in this order: + +1. AVIF, when the header names `image/avif`; +2. WebP, when it names `image/webp`; +3. JPEG, when the first of `image/jpeg`, `image/*` and `*/*` that it names + allows it, or when there is no `Accept` header or it is empty. + +An entry with `q=0` refuses its format; other `q` values do not change the +order. AVIF and WebP must be named, as clients that cannot show them also send +`image/*` and `*/*`. pixa never sends a format the client refused: when the +header allows none of the three, the answer is 406, and a header that does not +parse, or has a `q` that is not a number from 0 to 1, is refused with 400. The +signature, or the token of an encrypted URL, covers `auto` itself, so one URL +serves every client. Each format chosen is cached as a separate image, and every +answer that depends on `Accept` (the image, a 304, and the 400 and 406 above) +carries `Vary: Accept`, so a shared cache keeps the formats apart too. + An image is served with `Cache-Control: public, max-age=, immutable`. When the URL has an expiry (an `exp`, or the TTL of an encrypted URL), `max-age` is the whole seconds left until then, at most one year, so no browser or proxy @@ -297,7 +318,7 @@ nor change what it asks for. 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 only gives the URL a file extension, `jpg` for `orig` and `auto`. 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 @@ -353,7 +374,8 @@ 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` + signed as `orig` and `jpg` as `jpeg`; `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 cabe9e2..3cc0295 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,17 @@ P2: security: per-IP rate limiting on the image routes # Completed Steps +- 2026-10-05 the format `auto` (closes #88): a format in the `/v1/image/` path, + an encrypted URL's token and the generator page's format choice, chosen for + each request from `Accept` once the signature or token is checked: AVIF when + the header names `image/avif`, else WebP when it names `image/webp`, else JPEG + when the first of `image/jpeg`, `image/*` and `*/*` that it names allows it, + or when it names nothing; `q=0` refuses a format. AVIF and WebP must be named, + as clients that cannot show them send the wildcards too. A header that allows + none of the three answers 406, one that does not parse 400. The signature and + the token cover `auto` itself; the cache key and `ETag` use the format chosen. + Answers from the point the format is chosen carry `Vary: Accept`, next to the + CORS `Vary: Origin`; fixed-format answers do not. - 2026-10-05 the markdown is formatted with prettier (closes #100): `script/fmt` and `script/fmt-check` run prettier 3.8.1, pinned in `package.json` and `yarn.lock`, on `**/*.md` after `gofmt`, with four-space tabs and @@ -661,8 +672,6 @@ P2: security: per-IP rate limiting on the image routes - per-origin rate limiting - P2: HTTP response handling - Last-Modified headers - - Vary header for content negotiation -- P2: auto format selection (format=auto based on Accept header) - P2: configuration - YAML config file support - P2: operational diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index a0ad493..8184ce9 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -371,7 +371,7 @@ func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) stri // Determine file extension for the trailing filename ext := format - if ext == "" || ext == "orig" { + if ext == "" || ext == "orig" || ext == "auto" { ext = "jpg" // Default extension } diff --git a/internal/handlers/format_auto.go b/internal/handlers/format_auto.go new file mode 100644 index 0000000..485faed --- /dev/null +++ b/internal/handlers/format_auto.go @@ -0,0 +1,131 @@ +package handlers + +import ( + "errors" + "fmt" + "mime" + "net/http" + "strconv" + "strings" + + "sneak.berlin/go/pixa/internal/imgcache" +) + +// Errors for an Accept header that an auto URL cannot be served for. +var ( + errInvalidAccept = errors.New("invalid Accept header") + errNotAcceptable = errors.New( + "not acceptable: auto serves image/avif, image/webp or image/jpeg") +) + +// chooseAutoFormat replaces the format auto in req with the format +// formatForAccept chooses from r's Accept header, and adds Vary: Accept to the +// response, which then depends on that header. It answers 400 for an Accept +// header that is not valid and 406 for one that allows none of the formats, +// and reports whether req can be served. Any other format is left as it is. +func (s *Handlers) chooseAutoFormat( + w http.ResponseWriter, r *http.Request, req *imgcache.ImageRequest, +) bool { + if req.Format != imgcache.FormatAuto { + return true + } + + w.Header().Add("Vary", "Accept") + + format, err := formatForAccept(strings.Join(r.Header.Values("Accept"), ",")) + if errors.Is(err, errNotAcceptable) { + s.respondError(w, err.Error(), http.StatusNotAcceptable) + + return false + } + + if err != nil { + s.respondError(w, err.Error(), http.StatusBadRequest) + + return false + } + + req.Format = format + + return true +} + +// formatForAccept returns the format an auto URL is served in for the Accept +// header accept: AVIF when it names image/avif, else WebP when it names +// image/webp, else JPEG when its most specific entry of image/jpeg, image/* +// and */* allows it, or when it names nothing. A q of 0 refuses a format. +// AVIF and WebP must be named, as clients that cannot show them send image/* +// and */* too. +func formatForAccept(accept string) (imgcache.ImageFormat, error) { + qualities, err := parseAccept(accept) + if err != nil { + return "", err + } + + if len(qualities) == 0 { + return imgcache.FormatJPEG, nil + } + + if qualities["image/avif"] > 0 { + return imgcache.FormatAVIF, nil + } + + if qualities["image/webp"] > 0 { + return imgcache.FormatWebP, nil + } + + // For JPEG, the most specific entry the header has decides + quality, named := qualities["image/jpeg"] + if !named { + quality, named = qualities["image/*"] + } + + if !named { + quality = qualities["*/*"] + } + + if quality > 0 { + return imgcache.FormatJPEG, nil + } + + return "", errNotAcceptable +} + +// parseAccept returns the q of each media range the Accept header accept +// names, 1 where it gives none. A media range named more than once keeps its +// lowest q, so a refusal is never overridden. A media range that does not +// parse, or a q that is not a number from 0 to 1, is an error. +func parseAccept(accept string) (map[string]float64, error) { + qualities := make(map[string]float64) + + for entry := range strings.SplitSeq(accept, ",") { + // A header field list may hold empty entries + if strings.TrimSpace(entry) == "" { + continue + } + + mediaRange, params, err := mime.ParseMediaType(entry) + if err != nil { + return nil, fmt.Errorf("%w: %q: %w", errInvalidAccept, entry, err) + } + + quality := 1.0 + + if qParam, given := params["q"]; given { + quality, err = strconv.ParseFloat(qParam, 64) + inRange := quality >= 0 && quality <= 1 + + if err != nil || !inRange { + return nil, fmt.Errorf("%w: %q: q is not a number from 0 to 1", + errInvalidAccept, entry) + } + } + + previous, named := qualities[mediaRange] + if !named || quality < previous { + qualities[mediaRange] = quality + } + } + + return qualities, nil +} diff --git a/internal/handlers/format_auto_internal_test.go b/internal/handlers/format_auto_internal_test.go new file mode 100644 index 0000000..9b28d13 --- /dev/null +++ b/internal/handlers/format_auto_internal_test.go @@ -0,0 +1,310 @@ +package handlers + +import ( + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + "time" + + "github.com/go-chi/chi/v5" + + "sneak.berlin/go/pixa/internal/encurl" + "sneak.berlin/go/pixa/internal/imgcache" +) + +// The content types the tests below expect. +const ( + avifType = "image/avif" + webpType = "image/webp" + jpegType = "image/jpeg" + jsonType = "application/json" +) + +// TestFormatForAccept verifies the format chosen for the format auto from each +// Accept header below, and the error for one that allows none of AVIF, WebP +// and JPEG or is not valid. +func TestFormatForAccept(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + accept string + want imgcache.ImageFormat + wantErr error + }{ + {"AVIF-capable browser", + "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8", + imgcache.FormatAVIF, nil}, + {"WebP-capable browser", + "image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5", + imgcache.FormatWebP, nil}, + {"WebP only", webpType, imgcache.FormatWebP, nil}, + {"neither", "image/png,image/*;q=0.8,*/*;q=0.5", imgcache.FormatJPEG, nil}, + {"wildcard only", "*/*", imgcache.FormatJPEG, nil}, + {"image wildcard only", "image/*", imgcache.FormatJPEG, nil}, + {"absent", "", imgcache.FormatJPEG, nil}, + {"q=0 on AVIF", "image/avif;q=0,image/webp,*/*", imgcache.FormatWebP, nil}, + {"AVIF named twice, once with q=0", "image/avif,image/avif;q=0.0,*/*", + imgcache.FormatJPEG, nil}, + {"upper case and spaces", " Image/AVIF ; Q=0.5 ", imgcache.FormatAVIF, nil}, + {"q=0 on JPEG", "image/jpeg;q=0,image/*", "", errNotAcceptable}, + {"q=0 on everything", "*/*;q=0", "", errNotAcceptable}, + {"PNG only", "image/png", "", errNotAcceptable}, + {"malformed media range", "image/", "", errInvalidAccept}, + {"q not a number", "image/avif;q=high", "", errInvalidAccept}, + {"q above 1", "image/avif;q=2", "", errInvalidAccept}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := formatForAccept(tt.accept) + t.Logf("Accept %q: %q, %v", tt.accept, got, err) + + if got != tt.want || !errors.Is(err, tt.wantErr) { + t.Errorf("formatForAccept(%q) = %q, %v, want %q, %v", + tt.accept, got, err, tt.want, tt.wantErr) + } + }) + } +} + +// autoPhotoURLs returns a signed /v1/image/ URL and an encrypted /v1/e/ URL, +// both valid for a minute, for the JPEG at photoPath on signedHost at 50x50 in +// the format auto, made with h's image service and generator. +func autoPhotoURLs(t *testing.T, h *Handlers) (string, string) { + t.Helper() + + signedURL, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{ + SourceHost: signedHost, + SourcePath: photoPath, + Size: imgcache.Size{Width: 50, Height: 50}, + Format: imgcache.FormatAuto, + }, time.Minute) + if err != nil { + t.Fatalf("GenerateSignedURL() error = %v", err) + } + + token, err := h.encGen.Generate(&encurl.Payload{ + SourceHost: signedHost, + SourcePath: photoPath, + Width: 50, + Height: 50, + Format: imgcache.FormatAuto, + ExpiresAt: time.Now().Add(time.Minute).Unix(), + }) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + + return signedURL, "/v1/e/" + token + "/img.jpg" +} + +// requestImage sends method for target to srv with an Accept header line for +// each of accept, and returns the response. +func requestImage( + t *testing.T, srv http.Handler, method, target string, accept ...string, +) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequestWithContext(t.Context(), method, target, nil) + + for _, value := range accept { + req.Header.Add("Accept", value) + } + + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + t.Logf("%s %s with Accept %q: %d, Content-Type %s, Vary %v, X-Pixa-Cache %s", + method, target, accept, rec.Code, rec.Header().Get("Content-Type"), + rec.Header().Values("Vary"), rec.Header().Get("X-Pixa-Cache")) + + return rec +} + +// TestFormatAuto_ChosenFromAccept requests an auto URL on each image route +// with each Accept below, and checks the answer and that it carries +// Vary: Accept. The signed URL is signed for auto, so it is valid whatever +// Accept chooses. +func TestFormatAuto_ChosenFromAccept(t *testing.T) { + t.Parallel() + + h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler)) + signedURL, encryptedURL := autoPhotoURLs(t, h) + + tests := []struct { + name string + accept []string + wantStatus int + wantType string + }{ + {"AVIF accepted", []string{"image/avif,image/webp,*/*;q=0.8"}, + http.StatusOK, avifType}, + {"WebP accepted", []string{"image/webp,*/*;q=0.8"}, http.StatusOK, webpType}, + {"no Accept", nil, http.StatusOK, jpegType}, + {"two Accept lines", []string{"image/png", webpType}, http.StatusOK, webpType}, + {"none of the three", []string{"image/gif"}, + http.StatusNotAcceptable, jsonType}, + {"not valid", []string{"image/avif;q=high"}, http.StatusBadRequest, jsonType}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + for _, target := range []string{signedURL, encryptedURL} { + rec := requestImage(t, srv, http.MethodGet, target, tt.accept...) + gotType := rec.Header().Get("Content-Type") + + if rec.Code != tt.wantStatus || gotType != tt.wantType { + t.Errorf("%s: %d %s, want %d %s; body %s", target, + rec.Code, gotType, tt.wantStatus, tt.wantType, rec.Body) + } + + if !slices.Contains(rec.Header().Values("Vary"), "Accept") { + t.Errorf("%s: Vary = %v, want Accept in it", + target, rec.Header().Values("Vary")) + } + } + }) + } +} + +// TestFormatAuto_SignatureCoversAuto verifies that a /v1/image/ URL with the +// format auto is checked against a signature for auto, not for the format +// chosen: a URL signed for avif, with auto put in its path, is refused for a +// client whose Accept chooses AVIF. +func TestFormatAuto_SignatureCoversAuto(t *testing.T) { + t.Parallel() + + h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler)) + + signedForAVIF, err := h.imgSvc.GenerateSignedURL("", &imgcache.ImageRequest{ + SourceHost: signedHost, + SourcePath: photoPath, + Size: imgcache.Size{Width: 50, Height: 50}, + Format: imgcache.FormatAVIF, + }, time.Minute) + if err != nil { + t.Fatalf("GenerateSignedURL() error = %v", err) + } + + target := strings.Replace(signedForAVIF, "/50x50.avif?", "/50x50.auto?", 1) + if target == signedForAVIF { + t.Fatalf("no /50x50.avif? in %s", signedForAVIF) + } + + rec := requestImage(t, srv, http.MethodGet, target, avifType) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized) + } +} + +// TestFormatAuto_CachesEachFormatApart requests an auto URL for AVIF, then +// JPEG, then both again. Each format is processed once and then served from +// the cache, with an ETag of its own, so a client never gets the other format +// from the cache. +func TestFormatAuto_CachesEachFormatApart(t *testing.T) { + t.Parallel() + + h, srv := newSignedHostServer(t, slog.New(slog.DiscardHandler)) + signedURL, _ := autoPhotoURLs(t, h) + + steps := []struct { + wantType string + wantCache string + }{ + {avifType, "MISS"}, + {jpegType, "MISS"}, + {avifType, "HIT"}, + {jpegType, "HIT"}, + } + + etags := make(map[string]string) + + for _, step := range steps { + rec := requestImage(t, srv, http.MethodGet, signedURL, step.wantType) + gotType := rec.Header().Get("Content-Type") + gotCache := rec.Header().Get("X-Pixa-Cache") + + if rec.Code != http.StatusOK || gotType != step.wantType || + gotCache != step.wantCache { + t.Fatalf("Accept %s: %d %s %s, want 200 %s %s", step.wantType, + rec.Code, gotType, gotCache, step.wantType, step.wantCache) + } + + etag := rec.Header().Get("ETag") + if previous, seen := etags[gotType]; seen && previous != etag { + t.Errorf("%s ETag changed from %s to %s", gotType, previous, etag) + } + + etags[gotType] = etag + } + + if etags[avifType] == etags[jpegType] { + t.Errorf("AVIF and JPEG have the same ETag %s", etags[avifType]) + } +} + +// TestFormatAuto_Vary verifies that on each image route a HEAD answer and a +// 304 for an auto URL carry Vary: Accept, and that the answer for a URL with +// a fixed format does not. +func TestFormatAuto_Vary(t *testing.T) { + t.Parallel() + + h, _ := newSignedHostServer(t, slog.New(slog.DiscardHandler)) + + srv := chi.NewRouter() + srv.Get("/v1/image/*", h.HandleImage()) + srv.Head("/v1/image/*", h.HandleImage()) + srv.Get("/v1/e/{token}/*", h.HandleImageEnc()) + srv.Head("/v1/e/{token}/*", h.HandleImageEnc()) + + signedURL, encryptedURL := autoPhotoURLs(t, h) + + for _, urls := range [][2]string{ + {signedURL, signedPhotoURL(t, h)}, + {encryptedURL, encPhotoURL(t, h)}, + } { + autoURL, fixedURL := urls[0], urls[1] + + head := requestImage(t, srv, http.MethodHead, autoURL, webpType) + checkVaryAccept(t, head, http.StatusOK, true) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, + autoURL, nil) + req.Header.Set("Accept", webpType) + req.Header.Set("If-None-Match", head.Header().Get("ETag")) + + notModified := httptest.NewRecorder() + srv.ServeHTTP(notModified, req) + checkVaryAccept(t, notModified, http.StatusNotModified, true) + + fixed := requestImage(t, srv, http.MethodGet, fixedURL) + checkVaryAccept(t, fixed, http.StatusOK, false) + } +} + +// checkVaryAccept fails the test unless rec answered wantStatus and, as +// wantVary says, has or has not Accept in its Vary header. +func checkVaryAccept( + t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantVary bool, +) { + t.Helper() + + vary := rec.Header().Values("Vary") + t.Logf("status %d, Vary %v", rec.Code, vary) + + if rec.Code != wantStatus { + t.Errorf("status = %d, want %d", rec.Code, wantStatus) + } + + if slices.Contains(vary, "Accept") != wantVary { + t.Errorf("Vary = %v, want Accept in it: %v", vary, wantVary) + } +} diff --git a/internal/handlers/image.go b/internal/handlers/image.go index e099799..b3c3c31 100644 --- a/internal/handlers/image.go +++ b/internal/handlers/image.go @@ -43,6 +43,11 @@ func (s *Handlers) HandleImage() http.HandlerFunc { return } + // The signature covers the format auto, not the format chosen + if !s.chooseAutoFormat(w, r, req) { + return + } + // Get cache key for logging cacheKey := imgcache.CacheKey(req) diff --git a/internal/handlers/imageenc.go b/internal/handlers/imageenc.go index a9c672f..bf79054 100644 --- a/internal/handlers/imageenc.go +++ b/internal/handlers/imageenc.go @@ -30,7 +30,7 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc { start := time.Now() req, ok := s.parseImageEncRequest(w, r) - if !ok { + if !ok || !s.chooseAutoFormat(w, r, req) { return } diff --git a/internal/imgcache/imgcache.go b/internal/imgcache/imgcache.go index 1647658..7069f9e 100644 --- a/internal/imgcache/imgcache.go +++ b/internal/imgcache/imgcache.go @@ -22,6 +22,11 @@ const ( FormatWebP ImageFormat = "webp" FormatAVIF ImageFormat = "avif" FormatGIF ImageFormat = "gif" + + // FormatAuto stands for AVIF, WebP or JPEG, chosen for each request + // from its Accept header once the URL's signature or token has been + // checked; it is never processed or cached as itself. + FormatAuto ImageFormat = "auto" ) // Size represents requested image dimensions diff --git a/internal/imgcache/urlparser.go b/internal/imgcache/urlparser.go index a1a424c..b37d6ff 100644 --- a/internal/imgcache/urlparser.go +++ b/internal/imgcache/urlparser.go @@ -277,6 +277,8 @@ func parseFormat(s string) (ImageFormat, error) { return FormatAVIF, nil case "gif": return FormatGIF, nil + case "auto": + return FormatAuto, nil default: return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s) } diff --git a/internal/imgcache/urlparser_internal_test.go b/internal/imgcache/urlparser_internal_test.go index 2d617ec..9f4e68d 100644 --- a/internal/imgcache/urlparser_internal_test.go +++ b/internal/imgcache/urlparser_internal_test.go @@ -111,6 +111,21 @@ func TestParseImageURL(t *testing.T) { } } +// TestParseImageURL_AutoFormat verifies that the format auto in an image URL +// parses as FormatAuto. +func TestParseImageURL_AutoFormat(t *testing.T) { + t.Parallel() + + got, err := ParseImageURL("/v1/image/example.com/photo.jpg/200x200.auto") + if err != nil { + t.Fatalf("ParseImageURL() error = %v", err) + } + + if got.Format != FormatAuto { + t.Errorf("Format = %q, want %q", got.Format, FormatAuto) + } +} + func TestParseImageURL_Errors(t *testing.T) { t.Parallel() diff --git a/internal/server/format_auto_internal_test.go b/internal/server/format_auto_internal_test.go new file mode 100644 index 0000000..c409725 --- /dev/null +++ b/internal/server/format_auto_internal_test.go @@ -0,0 +1,46 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "slices" + "testing" +) + +// TestFormatAutoVaryNextToOrigin requests an image URL with the format auto +// through the server's routes and verifies that the answer carries +// Vary: Accept next to the Vary: Origin the CORS middleware sends. +func TestFormatAutoVaryNextToOrigin(t *testing.T) { + t.Parallel() + + source := encodeTestPNG(t, 64, 48) + + upstream := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(source) + })) + t.Cleanup(upstream.Close) + + s, _, _ := startImageProxy(t, upstream) + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, + "/v1/image/"+upstreamHost+"/photo.png/32x24.auto", nil) + req.Header.Set("Origin", "https://app.example.com") + req.Header.Set("Accept", "image/webp") + + rec := httptest.NewRecorder() + s.ServeHTTP(rec, req) + + vary := rec.Header().Values("Vary") + t.Logf("status %d, Content-Type %s, Vary %v", + rec.Code, rec.Header().Get("Content-Type"), vary) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + if want := []string{"Origin", "Accept"}; !slices.Equal(vary, want) { + t.Errorf("Vary = %v, want %v", vary, want) + } +} diff --git a/internal/templates/generator.html b/internal/templates/generator.html index 0f39502..8e6458e 100644 --- a/internal/templates/generator.html +++ b/internal/templates/generator.html @@ -95,6 +95,7 @@