Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78fe6957de | ||
|
|
339c149a4e | ||
|
|
afe000fb0f | ||
|
|
c593eae603 | ||
|
|
d104d5d936 | ||
|
|
a997ede32b | ||
|
|
8b4dcda549 | ||
|
|
6d271d4617 |
@@ -473,9 +473,11 @@ nor change what it asks for.
|
||||
`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
|
||||
URL's scheme is not kept: the image is fetched like any other (see Routes), and
|
||||
the blocked networks still apply.
|
||||
fit and expiry, encrypted with a key derived from `signing_key`. The host is
|
||||
kept in lower case and an empty path as `/`, as for `/v1/image/`, so
|
||||
`https://CDN.Example.com` and `https://cdn.example.com/` are one source image,
|
||||
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.
|
||||
The expiry is fixed in the token when the URL is made and cannot be changed or
|
||||
@@ -534,8 +536,13 @@ Where:
|
||||
- `query` — source query string as under Routes, empty string if none, with `%`
|
||||
and `:` written as in `path`; neither then holds a `:`, so path `/a:b` with no
|
||||
query and path `/a` with query `b:` are signed differently
|
||||
- `width` — requested width in pixels, `0` for original
|
||||
- `height` — requested height in pixels, `0` for original
|
||||
- `width` — requested width in pixels, or `0`: the width is then worked out from
|
||||
the source's proportions and the requested height, rounded down but at least
|
||||
1, or is the source's own width when the height is `0` too (the `orig` size)
|
||||
- `height` — requested height in pixels, or `0`: the height is then worked out
|
||||
from the source's proportions and the requested width, rounded down but at
|
||||
least 1, or is the source's own height when the width is `0` too (the `orig`
|
||||
size)
|
||||
- `format` — output format, one of those listed under Routes, with `original`
|
||||
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
|
||||
@@ -548,7 +555,10 @@ Where:
|
||||
with 400
|
||||
- `fit` — the URL's `fit` query parameter (cover, contain, fill, inside,
|
||||
outside), or `cover` when the URL has no `fit`; a request whose `fit` is
|
||||
anything else, an empty `fit=` included, is refused with 400
|
||||
anything else, an empty `fit=` included, is refused with 400. `outside` scales
|
||||
the image to cover the requested size, keeping its proportions, so it can come
|
||||
out larger than that size on one side: an 8192x1 source at `100x0`, which is
|
||||
100x1 by its proportions, comes out at 8192x1
|
||||
|
||||
The URL's `sig` is the HMAC-SHA256 result in base64url (the URL-safe alphabet of
|
||||
RFC 4648) with the trailing `=` padding kept, 44 characters in all. pixa
|
||||
|
||||
@@ -38,6 +38,25 @@ P2: security: per-origin rate limiting
|
||||
characters as `%25`, `%3F` and `%23`. For a `state_dir` of `/` or one starting
|
||||
with `//` it starts `file:////`, so SQLite no longer reads the first name in
|
||||
the path as a host.
|
||||
- 2026-10-16 a size with one side `0` keeps the side computed from the source's
|
||||
proportions at 1 or more (closes #283): it was rounded down, so for a thin
|
||||
source it came out at 0, and libvips then made the size square. An 8192x1
|
||||
source at `100x0` came out at 100x100, and at `1300x0` with `fit=cover` made
|
||||
libvips ask for about 39 GB at once; an 8192x2 source at `100x0` answered 500.
|
||||
`fit=contain` and `fit=inside` work both sides out again from the smaller of
|
||||
the two scales and `fit=outside` from the larger, and each keeps both sides at
|
||||
1 or more too, so a 1x8192 source at `0x100` with `fit=contain`, and a 49x8192
|
||||
source at `0x100` or a 49x49 source at `1x1` with `fit=outside`, no longer
|
||||
answer 500. An 8192x1 source at `1300x0` now comes out at 1300x1, and the
|
||||
process running it alone peaked at about 34 MiB resident. With `fit=outside`
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -127,7 +127,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
||||
sourceURL := r.FormValue("url")
|
||||
|
||||
// Validate source URL
|
||||
parsed, err := url.Parse(sourceURL)
|
||||
parsed, err := parseSourceURL(sourceURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form,
|
||||
http.StatusBadRequest)
|
||||
@@ -198,6 +198,25 @@ 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
|
||||
// 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
|
||||
|
||||
@@ -50,3 +50,69 @@ func TestImageProcessor_RefusesComputedSideAboveMaximum(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProcessor_ComputedSideIsAtLeastOne verifies that, for a size with
|
||||
// one side 0, the side computed from the input's proportions is 1 where it
|
||||
// would round down to 0, and that Process makes that size in every fit mode
|
||||
// but FitOutside, which scales the input to cover the size and so can make
|
||||
// it longer. Each case checks the size targetDimensions gives before it
|
||||
// calls Process: with that side at 0, an 8192x1 input at 1300x0 makes
|
||||
// libvips ask for about 39 GB with FitCover.
|
||||
func TestImageProcessor_ComputedSideIsAtLeastOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
inputWidth, inputHeight int
|
||||
size Size
|
||||
wantWidth, wantHeight int
|
||||
}{
|
||||
{"8192x1 at 100x0", 8192, 1, Size{Width: 100}, 100, 1},
|
||||
{"8192x1 at 1300x0", 8192, 1, Size{Width: 1300}, 1300, 1},
|
||||
{"8192x2 at 100x0", 8192, 2, Size{Width: 100}, 100, 1},
|
||||
{"1x8192 at 0x100", 1, 8192, Size{Height: 100}, 1, 100},
|
||||
{"1x8192 at 0x1300", 1, 8192, Size{Height: 1300}, 1, 1300},
|
||||
{"2x8192 at 0x100", 2, 8192, Size{Height: 100}, 1, 100},
|
||||
}
|
||||
|
||||
fits := []FitMode{FitCover, FitContain, FitFill, FitInside, FitOutside}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, fit := range fits {
|
||||
t.Run(tt.name+" "+string(fit), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
width, height, err := targetDimensions(tt.size,
|
||||
tt.inputWidth, tt.inputHeight)
|
||||
if err != nil || width != tt.wantWidth || height != tt.wantHeight {
|
||||
t.Fatalf("targetDimensions() = %dx%d, %v, want %dx%d",
|
||||
width, height, err, tt.wantWidth, tt.wantHeight)
|
||||
}
|
||||
|
||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||
|
||||
result, err := New(Params{}).Process(t.Context(),
|
||||
bytes.NewReader(input),
|
||||
&Request{Size: tt.size, Format: FormatJPEG, FitMode: fit})
|
||||
if err != nil {
|
||||
t.Fatalf("Process() error = %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = result.Content.Close() }()
|
||||
|
||||
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
||||
|
||||
ok := result.Width == tt.wantWidth && result.Height == tt.wantHeight
|
||||
if fit == FitOutside {
|
||||
ok = result.Width >= tt.wantWidth && result.Height >= tt.wantHeight
|
||||
}
|
||||
|
||||
if !ok {
|
||||
t.Errorf("Process() made %dx%d, want %dx%d (at least, for %s)",
|
||||
result.Width, result.Height, tt.wantWidth, tt.wantHeight,
|
||||
FitOutside)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,3 +60,45 @@ func TestImageProcessor_RefusesFitOutsideAboveMaximum(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProcessor_FitOutsideSideIsAtLeastOne verifies that FitOutside
|
||||
// keeps each side at 1 or more when it works both sides out again from the
|
||||
// larger scale. A side of 49 scaled to 1 is 49 times 1/49, which in floating
|
||||
// point is just under 1 and so rounded down to 0, a width libvips refuses.
|
||||
func TestImageProcessor_FitOutsideSideIsAtLeastOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
inputWidth, inputHeight int
|
||||
size Size
|
||||
wantWidth, wantHeight int
|
||||
}{
|
||||
{"49x8192 at 0x100", 49, 8192, Size{Height: 100}, 1, 167},
|
||||
{"49x49 at 1x1", 49, 49, Size{Width: 1, Height: 1}, 1, 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
input := createTestJPEG(t, tt.inputWidth, tt.inputHeight)
|
||||
|
||||
result, err := New(Params{}).Process(t.Context(),
|
||||
bytes.NewReader(input),
|
||||
&Request{Size: tt.size, Format: FormatJPEG, FitMode: FitOutside})
|
||||
if err != nil {
|
||||
t.Fatalf("Process() error = %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = result.Content.Close() }()
|
||||
|
||||
t.Logf("Process() made %dx%d", result.Width, result.Height)
|
||||
|
||||
if result.Width != tt.wantWidth || result.Height != tt.wantHeight {
|
||||
t.Errorf("Process() made %dx%d, want %dx%d",
|
||||
result.Width, result.Height, tt.wantWidth, tt.wantHeight)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,8 +311,10 @@ func (p *ImageProcessor) Process(
|
||||
|
||||
// targetDimensions calculates the output dimensions for a requested size,
|
||||
// scaling proportionally when only one dimension is given and keeping the
|
||||
// original dimensions when both are zero. A side scaled proportionally can
|
||||
// come out above MaxOutputDimension, which is ErrOutputTooLarge.
|
||||
// original dimensions when both are zero. A side scaled proportionally is
|
||||
// rounded down but kept at 1 or more, as for a thin image it would round
|
||||
// down to 0, a side of no pixels. It can come out above MaxOutputDimension,
|
||||
// which is ErrOutputTooLarge.
|
||||
func targetDimensions(size Size, origWidth, origHeight int) (int, int, error) {
|
||||
width, height := size.Width, size.Height
|
||||
|
||||
@@ -322,10 +324,10 @@ func targetDimensions(size Size, origWidth, origHeight int) (int, int, error) {
|
||||
return origWidth, origHeight, nil
|
||||
case width == 0:
|
||||
// Only height specified: calculate width proportionally
|
||||
width = origWidth * height / origHeight
|
||||
width = max(1, origWidth*height/origHeight)
|
||||
case height == 0:
|
||||
// Only width specified: calculate height proportionally
|
||||
height = origHeight * width / origWidth
|
||||
height = max(1, origHeight*width/origWidth)
|
||||
}
|
||||
|
||||
if width > MaxOutputDimension || height > MaxOutputDimension {
|
||||
@@ -479,13 +481,16 @@ func (p *ImageProcessor) resize(
|
||||
return cropCentreAndResize(img, width, height)
|
||||
|
||||
case FitContain:
|
||||
// Resize to fit within dimensions, maintaining aspect ratio
|
||||
// Resize to fit within dimensions, maintaining aspect ratio. Scaled
|
||||
// by the smaller scale, the short side of a thin image can round
|
||||
// down to 0, which libvips refuses as a width, so each side is at
|
||||
// least 1.
|
||||
imgW, imgH := img.Width(), img.Height()
|
||||
scaleW := float64(width) / float64(imgW)
|
||||
scaleH := float64(height) / float64(imgH)
|
||||
scale := min(scaleW, scaleH)
|
||||
newW := int(float64(imgW) * scale)
|
||||
newH := int(float64(imgH) * scale)
|
||||
newW := max(1, int(float64(imgW)*scale))
|
||||
newH := max(1, int(float64(imgH)*scale))
|
||||
|
||||
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
||||
|
||||
@@ -503,19 +508,22 @@ func (p *ImageProcessor) resize(
|
||||
scaleW := float64(width) / float64(imgW)
|
||||
scaleH := float64(height) / float64(imgH)
|
||||
scale := min(scaleW, scaleH)
|
||||
newW := int(float64(imgW) * scale)
|
||||
newH := int(float64(imgH) * scale)
|
||||
newW := max(1, int(float64(imgW)*scale))
|
||||
newH := max(1, int(float64(imgH)*scale))
|
||||
|
||||
return img.Thumbnail(newW, newH, vips.InterestingNone)
|
||||
|
||||
case FitOutside:
|
||||
// Resize so smallest dimension fits, may exceed target on other dimension
|
||||
// Resize so smallest dimension fits, may exceed target on other
|
||||
// dimension. A side scaled to 1 can come out just under 1 in
|
||||
// floating point, as 49 times 1/49 does, and round down to 0, which
|
||||
// libvips refuses as a width, so each side is at least 1.
|
||||
imgW, imgH := img.Width(), img.Height()
|
||||
scaleW := float64(width) / float64(imgW)
|
||||
scaleH := float64(height) / float64(imgH)
|
||||
scale := max(scaleW, scaleH)
|
||||
newW := int(float64(imgW) * scale)
|
||||
newH := int(float64(imgH) * scale)
|
||||
newW := max(1, int(float64(imgW)*scale))
|
||||
newH := max(1, int(float64(imgH)*scale))
|
||||
|
||||
// The dimension that exceeds the target is held to the maximum too
|
||||
if newW > MaxOutputDimension || newH > MaxOutputDimension {
|
||||
|
||||
@@ -29,7 +29,9 @@ var (
|
||||
const MaxDimension = imageprocessor.MaxOutputDimension
|
||||
|
||||
// ValidateDimension checks one requested width or height; name ("width" or
|
||||
// "height") appears in the error. 0 means "original size" and is valid.
|
||||
// "height") appears in the error. 0 is valid: that side is then worked out from
|
||||
// the source's proportions and the other requested side, rounded down but at
|
||||
// least 1, or is the source's own side when the other side is 0 too.
|
||||
func ValidateDimension(name string, value int) error {
|
||||
if value < 0 {
|
||||
return fmt.Errorf("%w: %s is negative", ErrInvalidSize, name)
|
||||
|
||||
@@ -308,6 +308,70 @@ 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
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user