Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0560cae4d |
No files matched your search
@@ -147,7 +147,7 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for pixa needs:
|
||||
times 50 MiB: on a host where pixa sees 48 CPUs, 24 times 4.2 GiB plus 3.1
|
||||
GiB, about 104 GiB. Set `PIXA_MAX_CONCURRENT_PROCESSING` and
|
||||
`PIXA_UPSTREAM_CONNECTIONS` so that this fits the memory the container may
|
||||
use.
|
||||
use. Rendering an SVG source can need more, depending on what it draws.
|
||||
- **Temporary files:** while pixa processes a large source, libvips writes its
|
||||
decoded pixels to a file in `/tmp`. The file disappears from `/tmp` once it is
|
||||
written, so a listing of `/tmp` does not show it, but it keeps its space, on
|
||||
@@ -328,6 +328,8 @@ path under `/v1/` answers 200, in maintenance mode too.
|
||||
format its `Content-Type` names.
|
||||
- 415 when the upstream sends an image whose first bytes libvips reads as
|
||||
another format.
|
||||
- 415 for an SVG source with one side more than 8192 times the other, in any
|
||||
format but `orig`.
|
||||
- 502 when the upstream answers with an error status.
|
||||
- 502 for 5 minutes after that for the same source URL, when pixa has
|
||||
neither the image nor its source image in its cache.
|
||||
@@ -472,9 +474,11 @@ format, such as an SVG whose first bytes pass the check for AVIF, is refused
|
||||
with 415 too.
|
||||
|
||||
For any format but `orig`, pixa renders an SVG source at the requested size, so
|
||||
an enlarged SVG stays sharp. At the `orig` size it is rendered at its own size,
|
||||
from the `width` and `height` of its root element. Rendering loads no file or
|
||||
URL that the SVG refers to.
|
||||
an enlarged SVG stays sharp. At the `orig` size it is rendered at its own size.
|
||||
The `orig` size of an SVG above 8192 on a side is refused with 400. An SVG with
|
||||
one side more than 8192 times the other is refused with 415 in those formats:
|
||||
rendered with no side above 8192 pixels, its shorter side would be under 1
|
||||
pixel. Rendering loads no file or URL that the SVG refers to.
|
||||
|
||||
`orig` of an SVG source is the SVG itself, unchanged, and is cached like any
|
||||
other image. It is given only at the `orig` size, since pixa does not resize an
|
||||
@@ -536,7 +540,9 @@ can set, so that no encode takes more than 40 seconds of the default
|
||||
WebP and 27 as JPEG XL, and 4096x4096, 8192x2048 or 2048x8192 up to about 34 as
|
||||
AVIF, which at quality 85 would take about 96 at 8192x8192. Without an alpha
|
||||
channel they take up to about 16, 13 and 22 seconds. Decoding such a source adds
|
||||
up to about 11 seconds.
|
||||
up to about 11 seconds. These limits do not bound rendering an SVG source, which
|
||||
happens within the encode: its time and memory grow with what the SVG draws,
|
||||
such as filter effects.
|
||||
|
||||
The login form (`POST /`) is limited to 5 attempts per minute per client
|
||||
address, counting an IPv6 client by its /64; an attempt over the limit is
|
||||
@@ -618,22 +624,24 @@ session.
|
||||
|
||||
### Image Metadata
|
||||
|
||||
pixa decodes and re-encodes every image it serves, and removes all metadata from
|
||||
the output: EXIF (GPS position, camera make, model and serial number, capture
|
||||
pixa decodes and re-encodes every image it serves, except `orig` of an SVG
|
||||
source, which is the SVG unchanged. It removes all metadata from every image it
|
||||
re-encodes: EXIF (GPS position, camera make, model and serial number, capture
|
||||
time, embedded thumbnail), XMP, IPTC and the ICC colour profile. This cannot be
|
||||
turned off. The one exception is `orig` of an SVG source, which is the SVG
|
||||
unchanged, its metadata included.
|
||||
turned off.
|
||||
|
||||
- The `orig` format means the source's own format, not the source's bytes: an
|
||||
`orig` image is re-encoded and stripped like any other.
|
||||
- For a source in any format but SVG, the `orig` format means the source's own
|
||||
format, not the source's bytes: an `orig` image is re-encoded and stripped
|
||||
like any other. For an SVG source, `orig` is the SVG's own bytes, its metadata
|
||||
included.
|
||||
- An image with an EXIF orientation is turned upright first, so it displays the
|
||||
same without the tag; a requested size applies to the upright image.
|
||||
- An image with an ICC profile is converted to sRGB first, since clients show an
|
||||
image with no profile as sRGB. Colours outside sRGB, such as the most
|
||||
saturated ones in a Display P3 photo, are clipped.
|
||||
- The one exception is JPEG XL with libvips 8.16 and later: libvips then writes
|
||||
an EXIF block of its own into the image, as govips cannot ask libvips to leave
|
||||
it out. It holds the image's size and otherwise fixed values, such as an
|
||||
- JPEG XL is an exception with libvips 8.16 and later: libvips then writes an
|
||||
EXIF block of its own into the image, as govips cannot ask libvips to leave it
|
||||
out. It holds the image's size and otherwise fixed values, such as an
|
||||
orientation of 1 and a resolution of 72 dpi, and nothing from the source.
|
||||
|
||||
### Source Hosts
|
||||
|
||||
@@ -26,13 +26,20 @@ resolved. The disk cache is now size-bounded with LRU eviction
|
||||
|
||||
# Next Step
|
||||
|
||||
P2: security: per-origin rate limiting
|
||||
P1: security: bound the time and memory of rendering an SVG source. Both grow
|
||||
with what the SVG draws, such as filter effects, and govips cannot stop or limit
|
||||
a render (https://git.eeqj.de/sneak/pixa/issues/68).
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-11-11 SVG is a source format again (closes #68), by the owner's ruling.
|
||||
- The fetcher accepts `image/svg+xml`, and `internal/magic` detects SVG.
|
||||
- A raster format renders the SVG at the output size, so it stays sharp.
|
||||
libvips' thumbnail renders it, at any scale.
|
||||
- An SVG's own size is not limited. The output checks limit the size it is
|
||||
rendered at, and no side is rendered above 8192.
|
||||
- An SVG with one side more than 8192 times the other is `ErrSVGTooThin`,
|
||||
answered 415.
|
||||
- `orig` of an SVG at the `orig` size is the SVG unchanged, cached as a
|
||||
variant. At any other size it is `ErrSVGNotOriginalSize`, answered 400.
|
||||
- The cache reads up to 1024 first bytes of a file to find its format.
|
||||
@@ -1403,6 +1410,8 @@ P2: security: per-origin rate limiting
|
||||
|
||||
# Future Steps
|
||||
|
||||
- P2: security
|
||||
- per-origin rate limiting
|
||||
- P2: HTTP response handling
|
||||
- Last-Modified headers
|
||||
- P2: configuration
|
||||
|
||||
@@ -267,6 +267,10 @@ func (s *Handlers) respondImageError(
|
||||
errors.Is(err, imageprocessor.ErrUnsupportedInputFormat):
|
||||
status = http.StatusUnsupportedMediaType
|
||||
message = "unsupported source format"
|
||||
case errors.Is(err, imageprocessor.ErrSVGTooThin):
|
||||
status = http.StatusUnsupportedMediaType
|
||||
message = fmt.Sprintf("SVG source too thin to render: one side is "+
|
||||
"more than %d times the other", imageprocessor.MaxInputDimension)
|
||||
case errors.Is(err, imageprocessor.ErrOutputTooLarge):
|
||||
status = http.StatusBadRequest
|
||||
message = fmt.Sprintf("size too large for the source: the side "+
|
||||
|
||||
@@ -47,3 +47,30 @@ func TestUnsupportedSourceFormatAnswers415(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSVGTooThinAnswers415 checks that respondImageError answers 415 with a
|
||||
// clear error for an SVG source too thin to render, wrapped as the service
|
||||
// wraps it.
|
||||
func TestSVGTooThinAnswers415(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h := &Handlers{log: slog.New(slog.DiscardHandler)}
|
||||
req := &imagerequest.Request{
|
||||
Source: imagerequest.Source{Host: "svg.example.com", Path: "/a.svg"},
|
||||
}
|
||||
err := fmt.Errorf("image processing failed: %w: 81930x10",
|
||||
imageprocessor.ErrSVGTooThin)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.respondImageError(rec, req, err)
|
||||
t.Logf("for %v: %d %s", err, rec.Code, rec.Body.String())
|
||||
|
||||
if rec.Code != http.StatusUnsupportedMediaType {
|
||||
t.Errorf("status = %d, want %d", rec.Code,
|
||||
http.StatusUnsupportedMediaType)
|
||||
}
|
||||
|
||||
if !strings.Contains(rec.Body.String(), "SVG source too thin to render") {
|
||||
t.Errorf("body = %q, want the SVG too thin error", rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,11 @@ var ErrUnsupportedInputFormat = errors.New("unsupported input format")
|
||||
var ErrSVGNotOriginalSize = errors.New(
|
||||
"an SVG is returned as SVG only at the orig size")
|
||||
|
||||
// ErrSVGTooThin is returned, before rendering, when an SVG source has one
|
||||
// side more than MaxInputDimension times the other. Rendered with its longer
|
||||
// side at most MaxInputDimension, its shorter side would be under 1 pixel.
|
||||
var ErrSVGTooThin = errors.New("SVG source too thin to render")
|
||||
|
||||
// ErrTooManyImages is returned when MaxConcurrentProcessing images are being
|
||||
// processed and none finishes within ProcessingWaitTimeout.
|
||||
var ErrTooManyImages = errors.New("too many images being processed at once")
|
||||
@@ -199,7 +204,7 @@ func (p *ImageProcessor) Process(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
img, err := loadImage(data)
|
||||
img, err := loadImage(data, sourceFormat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -229,8 +234,8 @@ func (p *ImageProcessor) Process(
|
||||
|
||||
// An SVG is rendered again at about the output size, to stay sharp
|
||||
if sourceFormat == imagerequest.FormatSVG {
|
||||
rendered, err := renderSVG(data,
|
||||
svgDensity(origWidth, origHeight, outputWidth, outputHeight))
|
||||
rendered, err := renderSVG(data, origWidth, origHeight,
|
||||
outputWidth, outputHeight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -312,8 +317,11 @@ func resolveOutputFormat(
|
||||
// loadImage decodes data and turns the image upright: encode strips the EXIF
|
||||
// orientation tag, and sizes must be worked out on the upright image. An
|
||||
// image with a side above MaxInputDimension is ErrInputTooLarge, which guards
|
||||
// against decompression bombs.
|
||||
func loadImage(data []byte) (*vips.ImageRef, error) {
|
||||
// against decompression bombs. An SVG is not checked: only its header has
|
||||
// been read, and the output checks limit the size it is rendered at.
|
||||
func loadImage(
|
||||
data []byte, sourceFormat imagerequest.Format,
|
||||
) (*vips.ImageRef, error) {
|
||||
img, err := vips.NewImageFromBuffer(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode image: %w", err)
|
||||
@@ -326,7 +334,8 @@ func loadImage(data []byte) (*vips.ImageRef, error) {
|
||||
return nil, fmt.Errorf("failed to auto-rotate: %w", err)
|
||||
}
|
||||
|
||||
if img.Width() > MaxInputDimension || img.Height() > MaxInputDimension {
|
||||
if sourceFormat != imagerequest.FormatSVG &&
|
||||
(img.Width() > MaxInputDimension || img.Height() > MaxInputDimension) {
|
||||
img.Close()
|
||||
|
||||
return nil, ErrInputTooLarge
|
||||
@@ -357,32 +366,38 @@ func svgAsItIs(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// svgDefaultDensity is the density, in dots per inch, at which libvips
|
||||
// renders an SVG unless told otherwise. At it, an SVG of width="64" is 64
|
||||
// pixels wide.
|
||||
const svgDefaultDensity = 72
|
||||
// svgRenderSize returns the least size in the proportions of width x height,
|
||||
// an SVG's own size, that covers outputWidth x outputHeight, so that resize
|
||||
// hardly changes it. Neither side is above MaxInputDimension.
|
||||
func svgRenderSize(width, height, outputWidth, outputHeight int) (int, int) {
|
||||
scale := min(
|
||||
max(float64(outputWidth)/float64(width),
|
||||
float64(outputHeight)/float64(height)),
|
||||
float64(MaxInputDimension)/float64(max(width, height)))
|
||||
|
||||
// svgDensity returns the least density at which an SVG of width x height,
|
||||
// its size at svgDefaultDensity, covers outputWidth x outputHeight, so that
|
||||
// resize hardly changes it. Neither side is rendered above
|
||||
// MaxInputDimension.
|
||||
func svgDensity(width, height, outputWidth, outputHeight int) int {
|
||||
scale := max(float64(outputWidth)/float64(width),
|
||||
float64(outputHeight)/float64(height))
|
||||
maxScale := float64(MaxInputDimension) / float64(max(width, height))
|
||||
|
||||
return min(int(math.Ceil(svgDefaultDensity*scale)),
|
||||
int(svgDefaultDensity*maxScale))
|
||||
return int(math.Round(float64(width) * scale)),
|
||||
int(math.Round(float64(height) * scale))
|
||||
}
|
||||
|
||||
// renderSVG loads the SVG data rendered at density, in dots per inch.
|
||||
// librsvg renders it and, given the SVG as bytes with no file name, loads
|
||||
// no file or URL the SVG refers to.
|
||||
func renderSVG(data []byte, density int) (*vips.ImageRef, error) {
|
||||
params := vips.NewImportParams()
|
||||
params.Density.Set(density)
|
||||
// renderSVG renders the SVG data, whose own size is width x height, at
|
||||
// svgRenderSize for outputWidth x outputHeight. An SVG with one side more
|
||||
// than MaxInputDimension times the other is ErrSVGTooThin. libvips'
|
||||
// thumbnail has librsvg render the SVG at that size directly, at any scale:
|
||||
// govips takes a density only in whole dots per inch, which cannot render
|
||||
// an SVG below 1/72 of its own size. Given the SVG as bytes with no file
|
||||
// name, librsvg loads no file or URL the SVG refers to.
|
||||
func renderSVG(
|
||||
data []byte, width, height, outputWidth, outputHeight int,
|
||||
) (*vips.ImageRef, error) {
|
||||
if max(width, height) > MaxInputDimension*min(width, height) {
|
||||
return nil, fmt.Errorf("%w: %dx%d", ErrSVGTooThin, width, height)
|
||||
}
|
||||
|
||||
img, err := vips.LoadImageFromBuffer(data, params)
|
||||
renderWidth, renderHeight := svgRenderSize(width, height,
|
||||
outputWidth, outputHeight)
|
||||
|
||||
img, err := vips.NewThumbnailWithSizeFromBuffer(data,
|
||||
renderWidth, renderHeight, vips.InterestingNone, vips.SizeBoth)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to render SVG: %w", err)
|
||||
}
|
||||
|
||||
@@ -177,46 +177,117 @@ func TestImageProcessor_SVGFetchesNothing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProcessor_RefusesSVGTooLarge checks that an SVG wider than
|
||||
// MaxInputDimension is ErrInputTooLarge, as a raster source is.
|
||||
func TestImageProcessor_RefusesSVGTooLarge(t *testing.T) {
|
||||
// TestImageProcessor_RendersSVGLargerThanMaxInputDimension checks that an
|
||||
// SVG whose own size is above MaxInputDimension is limited only by the
|
||||
// output checks: it is rendered at a raster size, orig of it is the SVG
|
||||
// itself, and a raster format at its own size is ErrOutputTooLarge. The
|
||||
// 1000000x1000000 one is rendered at a ten-thousandth of its own size.
|
||||
func TestImageProcessor_RendersSVGLargerThanMaxInputDimension(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, _, err := processSVG(t,
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="10000" height="10"/>`,
|
||||
imagerequest.Transform{
|
||||
for _, svg := range []string{
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="10000" height="10">` +
|
||||
`<rect width="10000" height="10"/></svg>`,
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="1000000" ` +
|
||||
`height="1000000"><rect width="1000000" height="1000000"/></svg>`,
|
||||
} {
|
||||
result, _, err := processSVG(t, svg, imagerequest.Transform{
|
||||
Size: imagerequest.Size{Width: 100, Height: 100},
|
||||
Format: imagerequest.FormatPNG,
|
||||
})
|
||||
if !errors.Is(err, ErrInputTooLarge) {
|
||||
t.Errorf("SVG of 10000x10: error = %v, want %v", err, ErrInputTooLarge)
|
||||
if err != nil {
|
||||
t.Errorf("%s as PNG at 100x100: %v", svg, err)
|
||||
} else {
|
||||
t.Logf("%s as PNG at 100x100: %dx%d", svg, result.Width, result.Height)
|
||||
|
||||
if result.Width != 100 || result.Height != 100 {
|
||||
t.Errorf("%s as PNG at 100x100 = %dx%d, want 100x100", svg,
|
||||
result.Width, result.Height)
|
||||
}
|
||||
}
|
||||
|
||||
result, output, err := processSVG(t, svg,
|
||||
imagerequest.Transform{Format: imagerequest.FormatOriginal})
|
||||
if err != nil || result.ContentType != imagerequest.FormatSVG.MIMEType() ||
|
||||
string(output) != svg {
|
||||
t.Errorf("orig of %s: error = %v, want the SVG itself", svg, err)
|
||||
}
|
||||
|
||||
_, _, err = processSVG(t, svg,
|
||||
imagerequest.Transform{Format: imagerequest.FormatPNG})
|
||||
if !errors.Is(err, ErrOutputTooLarge) {
|
||||
t.Errorf("%s as PNG at its own size: error = %v, want %v", svg,
|
||||
err, ErrOutputTooLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSVGDensity checks the density at which an SVG is rendered to cover
|
||||
// an output size, and that it is held to MaxInputDimension.
|
||||
func TestSVGDensity(t *testing.T) {
|
||||
// TestImageProcessor_RefusesSVGTooThin checks that an SVG with one side more
|
||||
// than MaxInputDimension times the other is ErrSVGTooThin at a raster size,
|
||||
// that one at exactly that proportion is rendered, and that orig of either
|
||||
// is the SVG itself.
|
||||
func TestImageProcessor_RefusesSVGTooThin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tooThin := `<svg xmlns="http://www.w3.org/2000/svg" width="81930" ` +
|
||||
`height="10"><rect width="81930" height="10"/></svg>`
|
||||
atTheLimit := `<svg xmlns="http://www.w3.org/2000/svg" width="81920" ` +
|
||||
`height="10"><rect width="81920" height="10"/></svg>`
|
||||
size := imagerequest.Size{Width: 100, Height: 100}
|
||||
|
||||
_, _, err := processSVG(t, tooThin,
|
||||
imagerequest.Transform{Size: size, Format: imagerequest.FormatPNG})
|
||||
if !errors.Is(err, ErrSVGTooThin) {
|
||||
t.Errorf("81930x10 SVG as PNG at 100x100: error = %v, want %v", err,
|
||||
ErrSVGTooThin)
|
||||
}
|
||||
|
||||
result, _, err := processSVG(t, atTheLimit,
|
||||
imagerequest.Transform{Size: size, Format: imagerequest.FormatPNG})
|
||||
if err != nil {
|
||||
t.Errorf("81920x10 SVG as PNG at 100x100: %v", err)
|
||||
} else if result.Width != 100 || result.Height != 100 {
|
||||
t.Errorf("81920x10 SVG as PNG at 100x100 = %dx%d, want 100x100",
|
||||
result.Width, result.Height)
|
||||
}
|
||||
|
||||
for _, svg := range []string{tooThin, atTheLimit} {
|
||||
result, output, err := processSVG(t, svg,
|
||||
imagerequest.Transform{Format: imagerequest.FormatOriginal})
|
||||
if err != nil || result.ContentType != imagerequest.FormatSVG.MIMEType() ||
|
||||
string(output) != svg {
|
||||
t.Errorf("orig of %s: error = %v, want the SVG itself", svg, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSVGRenderSize checks the size at which an SVG is rendered to cover an
|
||||
// output size, and that it is held to MaxInputDimension.
|
||||
func TestSVGRenderSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
width, height int
|
||||
outputWidth, outputHeight int
|
||||
want int
|
||||
wantWidth, wantHeight int
|
||||
}{
|
||||
{"own size", 16, 12, 16, 12, svgDefaultDensity},
|
||||
{"100 times larger", 16, 12, 1600, 1200, 7200},
|
||||
{"wider than the SVG's proportions", 16, 12, 160, 12, 720},
|
||||
{"10 times smaller", 100, 100, 10, 10, 8},
|
||||
{"held to MaxInputDimension", 8192, 1, 8192, 8192, svgDefaultDensity},
|
||||
{"held to MaxInputDimension when enlarged", 1024, 1, 8192, 8192, 576},
|
||||
{"own size", 16, 12, 16, 12, 16, 12},
|
||||
{"100 times larger", 16, 12, 1600, 1200, 1600, 1200},
|
||||
{"wider than the SVG's proportions", 16, 12, 160, 12, 160, 120},
|
||||
{"10 times smaller", 100, 100, 10, 10, 10, 10},
|
||||
{"held to MaxInputDimension", 8192, 1, 8192, 8192, 8192, 1},
|
||||
{"held to MaxInputDimension when enlarged", 1024, 1, 8192, 8192, 8192, 8},
|
||||
{"far above MaxInputDimension", 1000000, 1000000, 100, 100, 100, 100},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := svgDensity(tt.width, tt.height, tt.outputWidth, tt.outputHeight)
|
||||
if got != tt.want {
|
||||
t.Errorf("%s: svgDensity(%d, %d, %d, %d) = %d, want %d", tt.name,
|
||||
tt.width, tt.height, tt.outputWidth, tt.outputHeight, got, tt.want)
|
||||
gotWidth, gotHeight := svgRenderSize(tt.width, tt.height,
|
||||
tt.outputWidth, tt.outputHeight)
|
||||
if gotWidth != tt.wantWidth || gotHeight != tt.wantHeight {
|
||||
t.Errorf("%s: svgRenderSize(%d, %d, %d, %d) = %dx%d, want %dx%d",
|
||||
tt.name, tt.width, tt.height, tt.outputWidth, tt.outputHeight,
|
||||
gotWidth, gotHeight, tt.wantWidth, tt.wantHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user