From 481cd40646898d8c9bbf735d0a607ba7d459ea0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Duffeck?= Date: Fri, 23 Oct 2026 14:15:51 +0200 Subject: [PATCH] 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. --- .../thumbnails/pkg/service/http/v0/box.go | 18 +++++++++++ .../thumbnails/pkg/service/http/v0/push.go | 32 +++++++++++++++---- .../pkg/service/http/v0/push_imaging.go | 5 ++- .../pkg/service/http/v0/push_test.go | 16 ++++++++++ .../pkg/service/http/v0/push_vips.go | 5 ++- .../pkg/service/http/v0/resizegif.go | 13 +++++--- 6 files changed, 75 insertions(+), 14 deletions(-) diff --git a/services/thumbnails/pkg/service/http/v0/box.go b/services/thumbnails/pkg/service/http/v0/box.go index 5cd8ab4a8f..263e74914c 100644 --- a/services/thumbnails/pkg/service/http/v0/box.go +++ b/services/thumbnails/pkg/service/http/v0/box.go @@ -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. diff --git a/services/thumbnails/pkg/service/http/v0/push.go b/services/thumbnails/pkg/service/http/v0/push.go index 54161113b2..47e64d283a 100644 --- a/services/thumbnails/pkg/service/http/v0/push.go +++ b/services/thumbnails/pkg/service/http/v0/push.go @@ -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 } diff --git a/services/thumbnails/pkg/service/http/v0/push_imaging.go b/services/thumbnails/pkg/service/http/v0/push_imaging.go index feacc421b4..ef8a0813ed 100644 --- a/services/thumbnails/pkg/service/http/v0/push_imaging.go +++ b/services/thumbnails/pkg/service/http/v0/push_imaging.go @@ -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 } diff --git a/services/thumbnails/pkg/service/http/v0/push_test.go b/services/thumbnails/pkg/service/http/v0/push_test.go index 0a22f035f2..dd7726dcbe 100644 --- a/services/thumbnails/pkg/service/http/v0/push_test.go +++ b/services/thumbnails/pkg/service/http/v0/push_test.go @@ -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() { diff --git a/services/thumbnails/pkg/service/http/v0/push_vips.go b/services/thumbnails/pkg/service/http/v0/push_vips.go index b4094a25ef..dd5c58a449 100644 --- a/services/thumbnails/pkg/service/http/v0/push_vips.go +++ b/services/thumbnails/pkg/service/http/v0/push_vips.go @@ -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 diff --git a/services/thumbnails/pkg/service/http/v0/resizegif.go b/services/thumbnails/pkg/service/http/v0/resizegif.go index a33df01e7e..f7c584e664 100644 --- a/services/thumbnails/pkg/service/http/v0/resizegif.go +++ b/services/thumbnails/pkg/service/http/v0/resizegif.go @@ -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 }