5 Commits
Author SHA1 Message Date
sneak 0dac2c2120 Say what a 0 side does in the ValidateDimension doc comment
check / check (push) Canceled after 0s
It said 0 meant "original size"; the side is worked out from the
source's proportions, as README.md now says.

Model: opus-5-5
2026-10-16 01:59:24 +00:00
sneak dc1c069ad6 Keep each side at 1 or more with fit=outside too
fit=outside works both sides out again from the larger scale, and a side
scaled to 1 could come out just under 1 and round down to 0, which
libvips refuses: a 49x8192 source at 0x100 and a 49x49 source at 1x1
answered 500. README.md now says what fit=outside does, with the 8192x1
source at 100x0 that comes out at 8192x1, and how a 0 side is worked
out from the source's proportions.

Model: opus-5-5
2026-10-16 01:58:49 +00:00
sneak 5d30212352 Test that fit=outside keeps each side at 1 or more
A 49x8192 source at 0x100 and a 49x49 source at 1x1, both with
fit=outside. 49 times 1/49 is just under 1 in floating point, so the
width is rounded down to 0 and libvips refuses it. The test fails until
the fix for #283 covers fit=outside.

Model: opus-5-5
2026-10-16 01:58:13 +00:00
sneak 3126037a5d Keep a side computed for a size with one side 0 at 1 or more (closes #283)
The side was rounded down, so for a thin source it came out at 0, and
libvips made the size square: an 8192x1 source at 100x0 came out at
100x100, at 1300x0 with fit=cover libvips asked for about 39 GB, and an
8192x2 source at 100x0 answered 500. fit=contain and fit=inside work
both sides out again from the smaller scale and keep each at 1 or more
as well, as a 1x8192 source at 0x100 answered 500 there.

Model: opus-5-5
2026-10-16 01:58:13 +00:00
sneak 21fa87ddf6 Test that a side computed for a size with one side 0 is at least 1
An 8192x1 source at 100x0 and 1300x0 and an 8192x2 source at 100x0, and
the same turned on their side, in every fit mode. The test fails until
the fix for #283 lands; TODO.md
records the bug at the top as P1 until then.

Model: opus-5-5
2026-10-16 01:58:13 +00:00
4 changed files with 4 additions and 94 deletions

No files matched your search

+3 -5
View File
@@ -473,11 +473,9 @@ nor change what it asks for.
`jxl` for a form with no format. `jxl` for a form with no format.
The token holds the source's host, path and query and the size, format, quality, 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 host is fit and expiry, encrypted with a key derived from `signing_key`. The source
kept in lower case and an empty path as `/`, as for `/v1/image/`, so URL's scheme is not kept: the image is fetched like any other (see Routes), and
`https://CDN.Example.com` and `https://cdn.example.com/` are one source image, the blocked networks still apply.
fetched and cached once. The source URL's scheme is not kept: the image is
fetched like any other (see Routes), and the blocked networks still apply.
How long the URL lasts is chosen on the page, from 1 minute to 1 year, or never. How long the URL lasts is chosen on the page, from 1 minute to 1 year, or never.
The expiry is fixed in the token when the URL is made and cannot be changed or The expiry is fixed in the token when the URL is made and cannot be changed or
-5
View File
@@ -44,11 +44,6 @@ P2: security: per-origin rate limiting
the image is scaled to cover the size, so that source at `100x0` comes out at the image is scaled to cover the size, so that source at `100x0` comes out at
8192x1. `README.md` now says this under Signature Specification, and says how 8192x1. `README.md` now says this under Signature Specification, and says how
a `0` side is worked out where it said `0` meant the original. a `0` side is worked out where it said `0` meant the original.
- 2026-10-16 the generator page lower-cases the source host and writes an empty
source path as `/` before it makes the encrypted URL, as `/v1/image/` does
(closes #293). A host written in upper and in lower case, or a source URL with
no path and with the path `/`, made two cache keys and two upstream requests
for one image.
- 2026-10-16 the source URL a request fetches is built with `net/url` from the - 2026-10-16 the source URL a request fetches is built with `net/url` from the
source host, path and query, not joined as text (closes #288). The source source host, path and query, not joined as text (closes #288). The source
path, decoded, is fetched percent-encoded again, so `100%25.png` fetches path, decoded, is fetched percent-encoded again, so `100%25.png` fetches
+1 -20
View File
@@ -127,7 +127,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
sourceURL := r.FormValue("url") sourceURL := r.FormValue("url")
// Validate source URL // Validate source URL
parsed, err := parseSourceURL(sourceURL) parsed, err := url.Parse(sourceURL)
if err != nil || parsed.Host == "" { if err != nil || parsed.Host == "" {
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form, s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form,
http.StatusBadRequest) http.StatusBadRequest)
@@ -198,25 +198,6 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
} }
} }
// parseSourceURL parses the source URL entered on the generator page with its
// host lower-cased and an empty path written as "/", as /v1/image/ reads a
// source URL, so that each spelling of one source URL makes one cache key and
// one upstream request.
func parseSourceURL(sourceURL string) (*url.URL, error) {
parsed, err := url.Parse(sourceURL)
if err != nil {
return nil, err
}
parsed.Host = strings.ToLower(parsed.Host)
if parsed.Path == "" {
parsed.Path = "/"
}
return parsed, nil
}
// fetchedAsWritten reports whether fetchURL, the URL pixa fetches for an // fetchedAsWritten reports whether fetchURL, the URL pixa fetches for an
// image URL made from the source URL source, is source as it was written, // image URL made from the source URL source, is source as it was written,
// apart from the scheme, which the token does not keep. It is not when source // apart from the scheme, which the token does not keep. It is not when source
@@ -308,70 +308,6 @@ func TestGeneratedURLFetchesTheSourceURLAsEntered(t *testing.T) {
} }
} }
// TestGeneratorMakesOneImageURLForTwoSpellingsOfASourceURL enters two
// spellings of one source URL on the generator page, requests the encrypted
// URL made for each, and checks that the upstream receives one request and
// that the second encrypted URL is served from the cache. One pair writes the
// host in upper and in lower case, which /v1/image/ lower-cases. The host is
// an IPv6 address, whose hex digits have letter case, because the fetcher
// would look a host name up in DNS. It has a port, as the fetcher refuses an
// IPv6 address written without one. The other pair is a source URL with no
// path and with the path "/", both of which fetch "/".
func TestGeneratorMakesOneImageURLForTwoSpellingsOfASourceURL(t *testing.T) {
t.Parallel()
tests := []struct {
name string
// sourceURLs are the two spellings, after "https://"
sourceURLs []string
// wantRequest is the path the upstream must receive
wantRequest string
}{
{"host in upper and in lower case",
[]string{"[2001:DB8::A]:8443/a.png", "[2001:db8::a]:8443/a.png"},
"/a.png"},
{"no path and the path written as a slash",
[]string{upstreamHost, upstreamHost + "/"}, "/"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
upstream := startRecordingUpstream(t)
s, _, _ := startImageProxy(t, upstream.Server)
var imageURLs []string
for _, sourceURL := range tt.sourceURLs {
rec := postGenerate(t, s, "https://"+sourceURL)
match := generatedURLPattern.FindStringSubmatch(rec.Body.String())
if rec.Code != http.StatusOK || match == nil {
t.Fatalf("POST /generate: %d with no encrypted URL; body %s",
rec.Code, rec.Body.String())
}
imageURLs = append(imageURLs, match[1])
}
getImage(t, s, imageURLs[0])
second := getImage(t, s, imageURLs[1])
if got := second.Header().Get("X-Pixa-Cache"); got != string(imgcache.CacheHit) {
t.Errorf("second X-Pixa-Cache = %q, want HIT", got)
}
got := upstream.requestTargets()
t.Logf("upstream received %q", got)
if !slices.Equal(got, []string{tt.wantRequest}) {
t.Errorf("upstream received %q, want [%q]", got, tt.wantRequest)
}
})
}
}
// TestGeneratorRefusesSourceURLItCannotFetchAsWritten submits source URLs the // TestGeneratorRefusesSourceURLItCannotFetchAsWritten submits source URLs the
// generator page must make no encrypted URL for, and checks that it answers // generator page must make no encrypted URL for, and checks that it answers
// 400 and shows why. pixa keeps the source path decoded and fetches it // 400 and shows why. pixa keeps the source path decoded and fetches it