Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion services/thumbnails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,9 @@ on that same header.

A side given as `0` is computed from the other one, so `/unsafe/0x100/` keeps the
aspect ratio and `/unsafe/0x0/` returns the source at its own size. A free axis
leaves nothing to crop to, so the operation makes no difference to it.
leaves nothing to crop to, so the operation makes no difference to it. The box it
resolves to is held to `THUMBNAILS_MAX_OUTPUT_*` like any other, and a larger one
is refused with `422`.

Like real imagor, the default fill resize **upscales** small sources to fill
the box exactly (a 100x100 source requested at `320x320` returns a 320x320
Expand Down
19 changes: 19 additions & 0 deletions services/thumbnails/pkg/service/http/v0/box.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package svc

import (
"errors"
"image"
"math"

Expand Down Expand Up @@ -31,6 +32,24 @@ 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. 0x0 leaves both sides to the source, so the
// source size is held to it too, even though nothing is resized.
func (r resizeRequest) freeAxis(srcW, srcH int) (int, int, bool, error) {
w, h, resize := autoAxis(srcW, srcH, r.Width, r.Height)
if (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.
Expand Down
32 changes: 25 additions & 7 deletions services/thumbnails/pkg/service/http/v0/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
5 changes: 4 additions & 1 deletion services/thumbnails/pkg/service/http/v0/push_imaging.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
22 changes: 22 additions & 0 deletions services/thumbnails/pkg/service/http/v0/push_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,28 @@ var _ = Describe("the push endpoint", func() {

Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
})

DescribeTable("refuses a free axis that resolves above it",
func(path string, source []byte, partType string) {
rec := postTo(testServiceOutput(100, 100), path, source, partType)

Expect(rec.Code).To(Equal(http.StatusUnprocessableEntity), rec.Body.String())
},
Entry("a still image", "/unsafe/0x50/", pngSource(400, 10), "image/png"),
Entry("an animation", "/unsafe/0x50/", gifSource(400, 10, 2), "image/gif"),
Entry("a still image at its own size", "/unsafe/0x0/", pngSource(400, 10), "image/png"),
Entry("an animation at its own size", "/unsafe/0x0/", gifSource(400, 10, 2), "image/gif"),
)

DescribeTable("lets a free axis within it through",
func(path string) {
rec := postTo(testServiceOutput(100, 100), path, pngSource(40, 10), "image/png")

Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
},
Entry("one free side", "/unsafe/0x20/"),
Entry("both free sides", "/unsafe/0x0/"),
)
})

Describe("the configured maximum posted image size", func() {
Expand Down
5 changes: 4 additions & 1 deletion services/thumbnails/pkg/service/http/v0/push_vips.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 8 additions & 5 deletions services/thumbnails/pkg/service/http/v0/resizegif.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
19 changes: 14 additions & 5 deletions services/webdav/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ Nothing has to be set in the single binary. The URL may also point at a real
| Old name | Now | |
| --- | --- | --- |
| `THUMBNAILS_RESOLUTIONS` | `WEBDAV_THUMBNAILS_RESOLUTIONS` | a ladder of edge lengths, not a list of boxes |
| `THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE` | `OC_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE` | read by webdav and the generator, which have to agree |
| `THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE` | `OC_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE` | read by webdav and the generator, which have to agree; the old name still wins over the new one in both, so drop it when setting the new one |
| `THUMBNAILS_TXT_FONTMAP_FILE` | `WEBDAV_THUMBNAILS_TXT_FONTMAP_FILE` | webdav renders text before it posts |
| `THUMBNAILS_FILESYSTEMSTORAGE_ROOT` | `WEBDAV_THUMBNAILS_CACHE_DIR` | the old name is the directory that holds `files/`, the new one is the directory itself |
| `THUMBNAILS_FILESYSTEMSTORAGE_ROOT` | `WEBDAV_THUMBNAILS_CACHE_DIR` | the old name is the directory that holds `files/`, and the cache goes into `cache/` next to it; the new one is the directory itself |

The thumbnail cache has to be reachable by the webdav container after the move.

Expand Down Expand Up @@ -75,7 +75,7 @@ webdav:
resolutions: ["16", "32", "2048"]
max_input_image_file_size: 50MB
txt_fontmap_file: /etc/opencloud/fontmap.json
cache_dir: /var/lib/opencloud/thumbnails/files
cache_dir: /var/lib/opencloud/thumbnails/cache
thumbnails:
thumbnail:
max_input_image_file_size: 50MB
Expand All @@ -86,15 +86,24 @@ thumbnails:
The resolutions are edge lengths now, and each axis of a request is rounded up on
its own, so a preview is no longer returned in a different orientation than it was
asked for. The default ladder ends at 2048, where it used to reach 8K, so a client
that asks for more gets 2048.
that asks for more gets 2048. A legacy `WidthxHeight` entry still counts with both
of its sides, each capped at 2048, which is the largest box the built-in generator
accepts by default. A larger length has to be written as a plain length, and the
generator's `THUMBNAILS_MAX_OUTPUT_WIDTH` and `THUMBNAILS_MAX_OUTPUT_HEIGHT`
raised to match it.

A source smaller than the box is enlarged to fill it for the cropping operations,
the way imagor's default resize does, where the previous service returned the
source size instead. `fit-in` still never enlarges.

The cache key covers the snapped box, the operation, the output format and the
file's type, so the cache is cold after the update and fills again as previews are
requested. Nothing has to be deleted.
requested. It is kept in `$OC_BASE_DATA_PATH/thumbnails/cache` now. The thumbnails
the previous version wrote to `$OC_BASE_DATA_PATH/thumbnails/files` are never read
again, so that directory can be deleted. Where a volume is mounted at
`thumbnails/files` itself rather than at its parent, `WEBDAV_THUMBNAILS_CACHE_DIR`
has to point at that mount, or the cache is written next to the volume instead of
onto it.

Which types a preview is announced and attempted for is one list for webdav, graph
and the PROPFIND property, set through `OC_THUMBNAILS_ADDITIONAL_SOURCE_TYPES`. A type the
Expand Down
4 changes: 2 additions & 2 deletions services/webdav/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Currently, the webdav service handles request for two functionalities, which are

The webdav service provides various `GET` endpoints to get the thumbnails of a file in authenticated and unauthenticated contexts. It also provides thumbnails for spaces on different endpoints.

Generated thumbnails are cached by the webdav service itself. The cache backend defaults to `file`, storing entries under `$OC_BASE_DATA_PATH/thumbnails/files` (override with `WEBDAV_THUMBNAILS_CACHE_BACKEND` and `WEBDAV_THUMBNAILS_CACHE_DIR`). Use the `s3` backend when running multiple instances behind a load balancer so they share one cache. A backend nobody can serve, an unknown name or an `s3` backend without bucket, region and credentials, fails at startup instead of serving without a cache, as long as previews are enabled. A service that is not AWS needs `WEBDAV_THUMBNAILS_CACHE_S3_ENDPOINT` too, and it is always addressed over https. The `memory` backend is bounded and drops its oldest entries; `none` disables caching on purpose.
Generated thumbnails are cached by the webdav service itself. The cache backend defaults to `file`, storing entries under `$OC_BASE_DATA_PATH/thumbnails/cache` (override with `WEBDAV_THUMBNAILS_CACHE_BACKEND` and `WEBDAV_THUMBNAILS_CACHE_DIR`). Use the `s3` backend when running multiple instances behind a load balancer so they share one cache. A backend nobody can serve, an unknown name or an `s3` backend without bucket, region and credentials, fails at startup instead of serving without a cache, as long as previews are enabled. A service that is not AWS needs `WEBDAV_THUMBNAILS_CACHE_S3_ENDPOINT` too, and it is always addressed over https. The `memory` backend is bounded and drops its oldest entries; `none` disables caching on purpose.

#### Thumbnail Query String Parameters

Expand All @@ -37,7 +37,7 @@ Example with the default ladder (16, 32, 64, 128, 192, 256, 320, 384, 448, 512,
Requested: 18x12\
Returned: 32x16

Entries written as `WidthxHeight` are still accepted and contribute both of their sides to the ladder, so a configuration written for the old box list keeps working.
Entries written as `WidthxHeight` are still accepted and contribute both of their sides to the ladder, each capped at 2048, the largest box the built-in generator accepts by default. A configuration written for the old box list, which reached 7680x4320, keeps working that way. A larger length has to be a plain one, with the generator's `THUMBNAILS_MAX_OUTPUT_WIDTH` and `THUMBNAILS_MAX_OUTPUT_HEIGHT` raised to match.

Rounding onto the ladder is also what the cache is keyed on, so requests that produce the same image share one entry.

Expand Down
8 changes: 4 additions & 4 deletions services/webdav/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ type Config struct {
type Thumbnails struct {
GeneratorURL string `yaml:"generator_url" env:"WEBDAV_THUMBNAILS_GENERATOR_URL" desc:"Base URL of the thumbnail generator service, e.g. http://thumbnails:9186" introductionVersion:"%%NEXT%%"`
GeneratorTimeout string `yaml:"generator_timeout" env:"WEBDAV_THUMBNAILS_GENERATOR_TIMEOUT" desc:"HTTP timeout for requests to the thumbnail generator, e.g. 30s" introductionVersion:"%%NEXT%%"`
MaxInputFileSize string `yaml:"max_input_image_file_size" env:"THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE;OC_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE;WEBDAV_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE" desc:"Maximum file size of an input image a thumbnail is made of. The generator refuses a larger body, so both read the same name by default. Usable common abbreviations: [KB, KiB, MB, MiB, GB, GiB], example: 50MB" introductionVersion:"%%NEXT%%"`
MaxInputFileSize string `yaml:"max_input_image_file_size" env:"OC_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE;THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE;WEBDAV_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE" desc:"Maximum file size of an input image a thumbnail is made of. The generator refuses a larger body, so both read the same name by default. Usable common abbreviations: [KB, KiB, MB, MiB, GB, GiB], example: 50MB" introductionVersion:"%%NEXT%%"`

CacheBackend string `yaml:"cache_backend" env:"WEBDAV_THUMBNAILS_CACHE_BACKEND" desc:"Cache backend for imagor thumbnails: 'none', 'memory', 'file', or 's3'. Default: file." introductionVersion:"%%NEXT%%"`
CacheDir string `yaml:"cache_dir" env:"WEBDAV_THUMBNAILS_CACHE_DIR" desc:"Directory for file-based thumbnail cache (Default: $OC_BASE_DATA_PATH/thumbnails/files). Only used when WEBDAV_THUMBNAILS_CACHE_BACKEND is 'file'." introductionVersion:"%%NEXT%%"`
CacheDir string `yaml:"cache_dir" env:"WEBDAV_THUMBNAILS_CACHE_DIR" desc:"Directory for file-based thumbnail cache (Default: $OC_BASE_DATA_PATH/thumbnails/cache). Only used when WEBDAV_THUMBNAILS_CACHE_BACKEND is 'file'." introductionVersion:"%%NEXT%%"`
// kept so a deployment that configured the thumbnails service keeps its cache
FilesystemStorageRoot string `yaml:"filesystemstorage_root" env:"THUMBNAILS_FILESYSTEMSTORAGE_ROOT" desc:"Directory the thumbnails service stored its files in. Read as a fallback for WEBDAV_THUMBNAILS_CACHE_DIR." introductionVersion:"1.0.0" deprecationVersion:"%%NEXT%%" removalVersion:"%%NEXT_PRODUCTION_VERSION%%" deprecationInfo:"THUMBNAILS_FILESYSTEMSTORAGE_ROOT is deprecated. The webdav service owns the thumbnail cache now, so use WEBDAV_THUMBNAILS_CACHE_DIR instead." deprecationReplacement:"WEBDAV_THUMBNAILS_CACHE_DIR"`
Resolutions []string `yaml:"resolutions" env:"THUMBNAILS_RESOLUTIONS;WEBDAV_THUMBNAILS_RESOLUTIONS" desc:"Allowed thumbnail edge lengths, like 512. Each axis of a requested size is rounded up to the next one of these before the box is sent to the generator. Legacy WidthxHeight entries are still accepted and contribute both of their sides." introductionVersion:"%%NEXT%%"`
FilesystemStorageRoot string `yaml:"filesystemstorage_root" env:"THUMBNAILS_FILESYSTEMSTORAGE_ROOT" desc:"Directory the thumbnails service stored its files in. Read as a fallback for WEBDAV_THUMBNAILS_CACHE_DIR, which is then its cache subdirectory." introductionVersion:"1.0.0" deprecationVersion:"%%NEXT%%" removalVersion:"%%NEXT_PRODUCTION_VERSION%%" deprecationInfo:"THUMBNAILS_FILESYSTEMSTORAGE_ROOT is deprecated. The webdav service owns the thumbnail cache now, so use WEBDAV_THUMBNAILS_CACHE_DIR instead." deprecationReplacement:"WEBDAV_THUMBNAILS_CACHE_DIR"`
Resolutions []string `yaml:"resolutions" env:"THUMBNAILS_RESOLUTIONS;WEBDAV_THUMBNAILS_RESOLUTIONS" desc:"Allowed thumbnail edge lengths, like 512. Each axis of a requested size is rounded up to the next one of these before the box is sent to the generator. Legacy WidthxHeight entries are still accepted and contribute both of their sides, each capped at 2048, the largest box the built-in generator accepts by default." introductionVersion:"%%NEXT%%"`
CacheS3Bucket string `yaml:"cache_s3_bucket" env:"WEBDAV_THUMBNAILS_CACHE_S3_BUCKET" desc:"S3 bucket name for thumbnail cache when WEBDAV_THUMBNAILS_CACHE_BACKEND is 's3'." introductionVersion:"%%NEXT%%"`
CacheS3Region string `yaml:"cache_s3_region" env:"WEBDAV_THUMBNAILS_CACHE_S3_REGION" desc:"S3 region for thumbnail cache when WEBDAV_THUMBNAILS_CACHE_BACKEND is 's3'." introductionVersion:"%%NEXT%%"`
CacheS3Endpoint string `yaml:"cache_s3_endpoint" env:"WEBDAV_THUMBNAILS_CACHE_S3_ENDPOINT" desc:"S3 endpoint URL (for MinIO or compatible services) when WEBDAV_THUMBNAILS_CACHE_BACKEND is 's3'." introductionVersion:"%%NEXT%%"`
Expand Down
48 changes: 48 additions & 0 deletions services/webdav/pkg/config/parser/parse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// SPDX-License-Identifier: Apache-2.0

package parser_test

import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

thumbnailsdefaults "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/config/defaults"
thumbnailsparser "github.com/opencloud-eu/opencloud/services/thumbnails/pkg/config/parser"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/config/defaults"
"github.com/opencloud-eu/opencloud/services/webdav/pkg/config/parser"
)

var _ = Describe("ParseConfig", func() {
BeforeEach(func() {
GinkgoT().Setenv("OC_CONFIG_DIR", GinkgoT().TempDir())
})

// a body webdav allows and the generator refuses is downloaded and posted for nothing
DescribeTable("reads the maximum input file size the way the thumbnails service does",
func(env map[string]string, want string) {
for k, v := range env {
GinkgoT().Setenv(k, v)
}

cfg := defaults.DefaultConfig()
Expect(parser.ParseConfig(cfg)).To(Succeed())

generator := thumbnailsdefaults.DefaultConfig()
Expect(thumbnailsparser.ParseConfig(generator)).To(Succeed())

Expect(cfg.Thumbnails.MaxInputFileSize).To(Equal(want))
Expect(generator.Thumbnail.MaxInputImageFileSize).To(Equal(want))
},
Entry("from the shared name", map[string]string{
"OC_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE": "100MB",
}, "100MB"),
Entry("from the old name", map[string]string{
"THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE": "20MB",
}, "20MB"),
Entry("from the old name when both are set", map[string]string{
"THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE": "20MB",
"OC_THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE": "100MB",
}, "20MB"),
)
})
16 changes: 16 additions & 0 deletions services/webdav/pkg/config/parser/parser_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright 2026 OpenCloud GmbH <mail@opencloud.eu>
// SPDX-License-Identifier: Apache-2.0

package parser_test

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestParser(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Parser Suite")
}
Loading