Hold a free thumbnail axis to the maximum thumbnail size

A box like 0x2048 passed the check, and the aspect ratio of a very wide source then sized the other side without a bound.
This commit is contained in:
André Duffeck committed 2026-10-23 14:15:51 +02:00
1 parent 3884562e8f
commit 481cd40646
6 files changed
+75 -14

No files matched your search

@@ -1,6 +1,7 @@
package svc
import (
"errors"
"image"
"math"
@@ -31,6 +32,23 @@ func autoAxis(srcW, srcH, boxW, boxH int) (int, int, bool) {
}
}
// errBoxTooLarge is what a resize answers when a free axis resolves past the
// configured maximum thumbnail size.
var errBoxTooLarge = errors.New("the resolved box exceeds the maximum thumbnail size")
// freeAxis is autoAxis for the request, held to the maximum thumbnail size. The
// box the request names was checked before anything was read, but a side the
// aspect ratio decides is only known once the image is: 0x2048 on a 7680x1
// source is a box of 15728640x2048.
func (r resizeRequest) freeAxis(srcW, srcH int) (int, int, bool, error) {
w, h, resize := autoAxis(srcW, srcH, r.Width, r.Height)
if resize && ((r.MaxOutWidth > 0 && w > r.MaxOutWidth) || (r.MaxOutHeight > 0 && h > r.MaxOutHeight)) {
return 0, 0, false, errBoxTooLarge
}
return w, h, resize, nil
}
// fitIn scales img into the box with its aspect ratio kept and never enlarges
// it, which is imagor's fit-in without the upscale() filter. Not imaging.Fit:
// that truncates the scaled side where vips and imagor round it.
@@ -136,7 +136,10 @@ func (s Thumbnails) pushHandler(w http.ResponseWriter, r *http.Request) {
noUpscale := filterPresent(filtersSegment, "no_upscale")
// The box and operation are chosen by webdav (the sizing brain); this service
// is a dumb, imagor-like executor that just fits within the given box.
req := resizeRequest{Width: width, Height: height, Operation: operation, NoUpscale: noUpscale, Smart: smart}
req := resizeRequest{
Width: width, Height: height, Operation: operation, NoUpscale: noUpscale, Smart: smart,
MaxOutWidth: s.maxOutWidth, MaxOutHeight: s.maxOutHeight,
}
// An animated gif is the one source both builds treat the same way: neither
// libvips nor the pure Go path has anything better for a paletted animation,
@@ -177,6 +180,11 @@ func (s Thumbnails) pushHandler(w http.ResponseWriter, r *http.Request) {
}
processed, err := resizeSource(src, req)
if errors.Is(err, errBoxTooLarge) {
writeMaxResolution(w)
return
}
if err != nil {
writeNotAcceptable(w, "failed to process image")
return
@@ -264,8 +272,15 @@ func (s Thumbnails) serveAnimation(w http.ResponseWriter, imgData []byte, req re
return
}
resized, err := resizeGIF(g, req)
if err != nil {
writeMaxResolution(w)
return
}
var buf bytes.Buffer
if err := gif.EncodeAll(&buf, resizeGIF(g, req)); err != nil {
if err := gif.EncodeAll(&buf, resized); err != nil {
s.logger.Error().Err(err).Msg("could not encode the gif")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintln(w, "failed to encode image")
@@ -461,10 +476,13 @@ func filterPresent(segment, name string) bool {
// resizeRequest is what the executor is asked to do. webdav chose the box and
// the operation; noUpscale and smart mirror imagor's filters of those names.
// MaxOutWidth and MaxOutHeight bound a box a free axis resolves to.
type resizeRequest struct {
Width int
Height int
Operation string
NoUpscale bool
Smart bool
Width int
Height int
Operation string
NoUpscale bool
Smart bool
MaxOutWidth int
MaxOutHeight int
}
@@ -138,7 +138,10 @@ func resizeSourceImaging(src *imagingSource, req resizeRequest) (image.Image, er
srcBounds := img.Bounds()
if req.Width == 0 || req.Height == 0 {
boxW, boxH, resize := autoAxis(srcBounds.Dx(), srcBounds.Dy(), req.Width, req.Height)
boxW, boxH, resize, err := req.freeAxis(srcBounds.Dx(), srcBounds.Dy())
if err != nil {
return nil, err
}
if !resize {
return img, nil
}
@@ -42,6 +42,22 @@ var _ = Describe("the push endpoint", func() {
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
})
DescribeTable("refuses a free axis the aspect ratio takes above it",
func(source []byte, partType string) {
rec := postTo(testServiceOutput(100, 100), "/unsafe/0x50/", source, partType)
Expect(rec.Code).To(Equal(http.StatusUnprocessableEntity), rec.Body.String())
},
Entry("a still image", pngSource(400, 10), "image/png"),
Entry("an animation", gifSource(400, 10, 2), "image/gif"),
)
It("lets a free axis within it through", func() {
rec := postTo(testServiceOutput(100, 100), "/unsafe/0x20/", pngSource(40, 10), "image/png")
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
})
})
Describe("the configured maximum posted image size", func() {
@@ -53,7 +53,10 @@ func extForSourceFormatVips(src *vips.ImageRef) string {
// imagor's no_upscale() filter.
func resizeSourceVips(src *vips.ImageRef, req resizeRequest) (*vips.ImageRef, error) {
if req.Width == 0 || req.Height == 0 {
boxW, boxH, resize := autoAxis(src.Width(), src.Height(), req.Width, req.Height)
boxW, boxH, resize, err := req.freeAxis(src.Width(), src.Height())
if err != nil {
return nil, err
}
if resize {
if err := scaleTo(src, boxW, boxH); err != nil {
return nil, err
@@ -13,15 +13,18 @@ import (
// Floyd-Steinberg dithering. Both backends share it: the frames go through
// imaging either way, because neither libvips nor the pure Go path has anything
// better for a paletted animation. Code adapted from
// https://github.com/willnorris/gifresize.
func resizeGIF(m *gif.GIF, req resizeRequest) *gif.GIF {
// https://github.com/willnorris/gifresize. The only error is errBoxTooLarge.
func resizeGIF(m *gif.GIF, req resizeRequest) (*gif.GIF, error) {
srcX, srcY := m.Config.Width, m.Config.Height
width, height, operation, noUpscale := req.Width, req.Height, req.Operation, req.NoUpscale
if width == 0 || height == 0 {
resolvedW, resolvedH, resize := autoAxis(srcX, srcY, width, height)
resolvedW, resolvedH, resize, err := req.freeAxis(srcX, srcY)
if err != nil {
return nil, err
}
if !resize {
return m
return m, nil
}
width, height, operation = resolvedW, resolvedH, OpStretch
}
@@ -65,5 +68,5 @@ func resizeGIF(m *gif.GIF, req resizeRequest) *gif.GIF {
m.Config.Width, m.Config.Height = b.Dx(), b.Dy()
}
return m
return m, nil
}