Skip to content

Push based thumbnailer architecture - #3397

Open
butonic wants to merge 30 commits into
mainfrom
imagor
Open

butonic wants to merge 30 commits into
mainfrom
imagor

Conversation

@butonic

@butonic butonic commented Aug 24, 2026 •

Copy link
Copy Markdown
Member

This PR tries to fix #1128 in a backwards compatible way.

Thumbnail generation used to live entirely in the thumbnails service behind a gRPC API: webdav called GetThumbnail, the service fetched the source from storage, preprocessed and generated the image, stored it on its own filesystem, and returned a JWT-signed URL that webdav had to follow with a second authenticated HTTP call just to get the bytes back. This branch inverts that: the thumbnails service becomes a stateless imagor-compatible resizer (one POST endpoint — image bytes in, resized bytes out; no auth, storage, or gRPC), and webdav owns the whole workflow via a single ThumbnailWorkflow type: stat via gateway → cache check → download source → preprocess → POST to generator → cache → respond stat via gateway → snap the requested size → cache check → download source → preprocess → POST to generator → cache → respond.

Webdav gains what it needs to own that pipeline: the preprocessors (text→image, audio cover art) moved over from the thumbnails service, a new checksum-keyed thumbnail cache with memory/file/S3/noop backends, and a pkg/generator package that builds resizer URLs and posts multipart images. Config shrinks to one generator URL plus timeout and max input file size — the URL can point at the built-in thumbnails service or any imagor instance.

The old architecture is deleted from the thumbnails service: proto files, gRPC handler, JWT transfer tokens, filesystem storage, source fetchers, the /data endpoint, and duplicated utilities (~4,000 lines), plus leftover dead config, no-op metrics wrappers, and opencloud init's now-meaningless transfer-secret generation. Net diff: 102 files, +3,337/−4,336 118 files, +6,863/−4,861; thumbnail requests no longer need the second round-trip, and the resizer is trivially replaceable.

Update: sizing and caching

THUMBNAILS_RESOLUTIONS is a ladder of edge lengths, not a list of boxes: each axis is rounded up on its own, so a square request can no longer land in a landscape box, which is what cropped and shrank portrait previews. Legacy WidthxHeight entries still count with both sides, and the cache is keyed on the snapped box, so x=447 and x=448 share an entry.

Clients no longer need to know the list: x and y are independently optional, an omitted axis is unconstrained and capped at the largest length, while an explicit 0 stays the 400 it has always been. A cropping processor plus an unconstrained axis is a 400, no dimensions at all stays the legacy 32x32. A public link that needs a password answers 404 again instead of 500.

Update: what a thumbnail is made of

Three settings, read as a chain. For a .heic on a deployment running a stock imagor:

image/heic                                                  the source type, from the file
OC_THUMBNAIL_SOURCE_TYPES           image/heic              a preview is announced and attempted
WEBDAV_THUMBNAIL_TYPE_MAPPING       image/heic:image/jpeg   the type it is returned in
WEBDAV_THUMBNAIL_GENERATOR_FORMATS  image/jpeg:jpg          what the generator calls that type

POST /unsafe/fit-in/256x256/filters:format(jpg)/

Each one merges onto the built-in defaults, so naming a type does not take the answer away from the others. A target the generator has no format for is refused at startup, a source it cannot read answers 404. image/heic and image/avif, both unsupported today, were verified end to end this way, no code change.

Update: parity suite

services/thumbnails/pkg/parity posts the same image to the built-in generator and to two real imagor images and compares status, dimensions, format, frames and alpha, with imagor from testcontainers locally and service containers in CI. A generator that answers differently is held to that answer by a recorded override carrying the reason, which fails once the divergence disappears.

It runs twice, once per resize implementation, because make test compiles the pure Go path while the images ship vips, so the shipped resize code had no test at all. That exposed an older problem, not fixed here: CI's libvips is not the one the images ship, so enable_vips tests measure capabilities we do not deliver (#3746).

Follow-ups

  • oc:has-preview is answered from the same configured list, which needed a change in reva: fix(ocdav): answer has-preview from the ocdav configuration reva#885. ocdav takes the types from its own configuration instead of importing an OpenCloud package, and the frontend hands in what webdav attempts. The vendored copy carries it until that PR lands. It also removes the import that makes a module cycle, reva's go.mod requires opencloud v1.1.0, so a standalone reva announced previews from a frozen OpenCloud list. The requirement itself stays, pkg/app/registry/micro and pkg/micro/ocdav still use opencloud/pkg/registry.
  • image/jpg cannot occur, reva maps jpg to image/jpeg. Remove it from the supported list and the encode step if nothing outside the repo depends on it.
  • Turning previews off for a single type has no lever; the gate is the source type list, read by webdav, graph and reva.
  • Extracted previews (an audio cover, a rendered text page) are not cached themselves, only the thumbnail made from them, so a second size extracts again. @butonic wanted them cached, @dschmidt at index time (feat(search): honest has-preview prop and thumbnails relationship #3210).
  • Pages are still unbounded. THUMBNAILS_MAX_ANIMATION_FRAMES now caps a gif from its block structure before a frame is decoded, but THUMBNAILS_MAX_INPUT_WIDTH and _HEIGHT describe one page, so a multi-page heic or tiff is bounded only by both backends loading a single page today.
  • S3Cache has two gaps the unit tests cannot see, because the mock is not minio. Secure: true is hard-wired in BuildS3CacheConfig, and minio.New prepends the scheme itself, so the http://host:port form the config text asks for is refused as an invalid endpoint. Deriving the scheme from the endpoint and handing minio a bare host fixes both, and a real MinIO behind testcontainers (the imagortest pattern) is what should pin it.
  • ThumbnailCache.Get takes no context, so the s3 backend reads on context.Background() and ignores the request deadline.
  • One http.Client carrying WEBDAV_THUMBNAIL_GENERATOR_TIMEOUT is shared by the generator POST and the source download, so the generator timeout silently doubles as the download timeout for a source of up to WEBDAV_THUMBNAIL_MAX_INPUT_IMAGE_FILE_SIZE.
  • FontLoader is rebuilt for every text preview, because preprocessor.ForType runs per request.
  • A thumbnail response carries no ETag and no Cache-Control, although the cache key (checksum, box, operation, format) is a content-addressed ETag already.
  • The push endpoint's HTTP behaviour is asserted twice, in push_test.go and in the parity suite. A parity case runs the built-in generator in-process, so it needs no container and compares imagor on top; the service specs should keep only what a non-default config decides (the concurrency limiter, the input limits) and the helper unit tests.
  • Without processor and without a, the default is now fill with upscaling, where main capped the result at the source size; previewsAutoAdustedSizing.feature carries the new numbers. Web always sends a processor, so this is visible only to a client that sends a bare x/y.
  • THUMBNAILS_DATA_ENDPOINT and THUMBNAILS_TRANSFER_TOKEN are gone without a deprecation path: they configured the pull pipeline this PR removes, so there is nothing to point them at. THUMBNAILS_FILESYSTEMSTORAGE_ROOT is still read as a fallback for WEBDAV_THUMBNAIL_CACHE_DIR and marked deprecated.

Related:
#630
#3364
#3332
opencloud-eu/reva#781
opencloud-eu/reva#773
owncloud/core#31268 - discusses a and mode parameters
Thumbnails lessons from oc10 #1378

Discussion:
https://github.com/orgs/opencloud-eu/discussions/2368
https://github.com/orgs/opencloud-eu/discussions/1090

@dschmidt This PR is not ready, but I want to bring your attention to this approach, which is why I am pushing this code now.

Taken over from @butonic with his and the team's agreement, and rebased onto current main. The sizing and caching changes described above are mine. ~ @dschmidt

@butonic butonic self-assigned this Aug 24, 2026
@github-project-automation github-project-automation Bot moved this to Qualification in OpenCloud Team Board Aug 24, 2026
@butonic butonic added Type:Maintenance E.g. technical debt, packaging, etc. Type:Enhancement labels Aug 24, 2026
@butonic butonic moved this from Qualification to In Progress in OpenCloud Team Board Aug 24, 2026
@codacy-production

codacy-production Bot commented Aug 24, 2026 •

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 12 critical · 37 minor

Alerts:
⚠ 49 issues (≤ 0 issues of at least minor severity)

Results:
49 new issues

Category Results
Security 12 critical
CodeStyle 37 minor

View in Codacy

🟢 Metrics 229 complexity · 312 duplication

Metric Results
Complexity 229
Duplication 312

View in Codacy

🟢 Coverage 65.15% diff coverage

Metric Results
Coverage variation Report missing for 5a7afe01
Diff coverage ✅ 65.15% diff coverage

View coverage diff in Codacy

Coverage variation details
Coverable lines Covered lines Coverage
Common ancestor commit (5a7afe0) Report Missing Report Missing Report Missing
Head commit (9eb1bcd) 85287 20874 24.48%

Coverage variation is the difference between the coverage for the head and common ancestor commits of the pull request branch: <coverage of head commit> - <coverage of common ancestor commit>

Diff coverage details
Coverable lines Covered lines Diff coverage
Pull request (#3397) 1145 746 65.15%

Diff coverage is the percentage of lines that are covered by tests out of the coverable lines that the pull request added or modified: <covered lines added or modified>/<coverable lines added or modified> * 100%

1 Codacy didn't receive coverage data for the commit, or there was an error processing the received data. Check your integration for errors and validate that your coverage setup is correct.

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@dschmidt

Copy link
Copy Markdown
Contributor

How do audio artwork, geogebra thumbnail and tiff preview extraction fit into this approach?

Have you seen that I reworked the tiff extraction pr to use tika?

I already upstreamed audio artwork extraction and tiff extraction to tika. Geogebra is still pending, but I expect it to be merged very soon as well.

So I would like to see a preprocessing step for extraction of embedded pictures before they are handed over to imagor for resizing (with tika it would be push based and out of process too).

I was a bit surprised to see that you are moving so much into the webdav service, my expectation would have been that you just replace the Decoder in the thumbnails service - can you elaborate a little why you chose this way more invasive approach?

@butonic
butonic force-pushed the imagor branch 2 times, most recently from 3a7874b to 6aaa4ec Compare August 25, 2026 14:20
@butonic

butonic commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

How do audio artwork, geogebra thumbnail and tiff preview extraction fit into this approach?
The preprocessing pipeline moved to the webdav service. I would add the extraction there as well.

Have you seen that I reworked the tiff extraction pr to use tika?
Yes, which is why I pinged you in this PR, because I think using a sandboxable thumbnailer this architecture allows is crucial for security.

I already upstreamed audio artwork extraction and tiff extraction to tika. Geogebra is still pending, but I expect it to be merged very soon as well.

So I would like to see a preprocessing step for extraction of embedded pictures before they are handed over to imagor for resizing (with tika it would be push based and out of process too).
I tried to move the preprocesor package from thumbnailer to webdav as is. More extractors should be added here, or did you have to refactor a lot for your extractors? In any case that should work for this approach as well.

I was a bit surprised to see that you are moving so much into the webdav service, my expectation would have been that you just replace the Decoder in the thumbnails service - can you elaborate a little why you chose this way more invasive approach?
Sure. On main, webdav doesn't own the pipeline at all: each thumbnail request is a grpc GetThumbnail call where the thumbnails service does stat, cache check, source fetch, preprocess (the decoder), generate, and disk storage for caching, then returns a JWT-signed download URL that webdav has to make a second HTTP call to just retrieve the bytes. So swapping only the decoder would leave that grpc hop, the JWT endpoint, the storage layer, and the double-fetch intact, fixing how a file becomes an image but not where the pipeline runs or the extra indirection.

We can move the workflow into webdav, because webdav already has everything it needs locally (the CS3 gateway client for stat + download, the auth context, the response writer), which lets the generator shrink to a stateless resizer (POST bytes in, resized bytes out) that's swappable with an external imagor via a single URL. The thumbnail generation process is owned, end-to-end, by webdav instead of logic split across two services and two round-trips.

And we can use an external container to support HEIC thumbnails, just by changing the URL. And I really want to be able to reuse the thumbnailer like tika, because it now becomes stateless.

@dschmidt

Copy link
Copy Markdown
Contributor

How do audio artwork, geogebra thumbnail and tiff preview extraction fit into this approach?
The preprocessing pipeline moved to the webdav service. I would add the extraction there as well.

Okay, alright. Works for me.

Have you seen that I reworked the tiff extraction pr to use tika?
Yes, which is why I pinged you in this PR, because I think using a sandboxable thumbnailer this architecture allows is crucial for security.

👍🏻

I already upstreamed audio artwork extraction and tiff extraction to tika. Geogebra is still pending, but I expect it to be merged very soon as well.
So I would like to see a preprocessing step for extraction of embedded pictures before they are handed over to imagor for resizing (with tika it would be push based and out of process too).
I tried to move the preprocesor package from thumbnailer to webdav as is. More extractors should be added here, or did you have to refactor a lot for your extractors? In any case that should work for this approach as well.

I'm fine with that, I just wanted you to be aware. No problem at all to port it. It was just important to me that extractors still have a place in the architecture and we don't need a magical one service that can handle all file types.

I was a bit surprised to see that you are moving so much into the webdav service, my expectation would have been that you just replace the Decoder in the thumbnails service - can you elaborate a little why you chose this way more invasive approach?
Sure. On main, webdav doesn't own the pipeline at all: each thumbnail request is a grpc GetThumbnail call where the thumbnails service does stat, cache check, source fetch, preprocess (the decoder), generate, and disk storage for caching, then returns a JWT-signed download URL that webdav has to make a second HTTP call to just retrieve the bytes. So swapping only the decoder would leave that grpc hop, the JWT endpoint, the storage layer, and the double-fetch intact, fixing how a file becomes an image but not where the pipeline runs or the extra indirection.

Fair enough, thanks!

We can move the workflow into webdav, because webdav already has everything it needs locally (the CS3 gateway client for stat + download, the auth context, the response writer), which lets the generator shrink to a stateless resizer (POST bytes in, resized bytes out) that's swappable with an external imagor via a single URL. The thumbnail generation process is owned, end-to-end, by webdav instead of logic split across two services and two round-trips.

And we can use an external container to support HEIC thumbnails, just by changing the URL. And I really want to be able to reuse the thumbnailer like tika, because it now becomes stateless.

That part was clear :)

One more note: It might be the time to at least quickly think about the "honest hasPreview" implementation.
I need the availability and dimensions of embedded previews at index time (#3210).
That leads me to the following questions:

  1. Should we use the extraction pipeline as library code or do we need an (internal) api to just extract the original size embedded preview?
  2. Should extracted previews be cached? For search service and thumbnails, but also simply so that multiple thumbnail sizes don't need the extraction through tika multiple times

Thoughts? Do you see any problems with your drafted architecture? (I don't see any, just to be sure)

@butonic

butonic commented Aug 27, 2026 •

Copy link
Copy Markdown
Member Author

One more note: It might be the time to at least quickly think about the "honest hasPreview" implementation. I need the availability and dimensions of embedded previews at index time (#3210).

👀 🤔 I'll comment there...

That leads me to the following questions:

  1. Should we use the extraction pipeline as library code or do we need an (internal) api to just extract the original size embedded preview?

I would prefer library code. I was thinking of moving it to /pkg, in case other services also call the grpc api.

  1. Should extracted previews be cached? For search service and thumbnails, but also simply so that multiple thumbnail sizes don't need the extraction through tika multiple times

Yes, they should be cached.

Thoughts? Do you see any problems with your drafted architecture? (I don't see any, just to be sure)
Nope.

Althought, I plan to add a configurable blobstore for the cache so we can store thumbnails in nats or s3 ... with a checksum based key. But that would be the topic of a future PR.

@butonic
butonic force-pushed the imagor branch 4 times, most recently from 78a9428 to 0a9e86a Compare August 28, 2026 23:15
@butonic

butonic commented Aug 28, 2026 •

Copy link
Copy Markdown
Member Author

Some more thoughts on why POST and not GET:

With POST the image generator does not need to be granted access to all instances it should GET images from. In a multi instance deployment or at hosters all instances can just POST to the imagor service. New instances do not need to be registered in the firewall or network configuration to let imagor make requests to them.

The imagor service itself can just be horizontally scaled.

Finally, if malicious bytes manage to take over the container/pod it can still not make any requests to the outside world.

So POST really is driven by security and scalability. This also explains why we cannot let clients make signed GET requests directly to the imagor service. It would have to be granted access to all instances. IMO that is too much of an attack surface, even if the requests are signed by the instance itself. At the network level you dont want to inspect every request to ensure it really is a GET / download request for a file ... but if you don't the request might also POST to another endpoint to probe the infrastructure ... nah. Lock it down: no egress whatsoever.

As a sidenote: the image cache uses hash based keys, so all instances could share the same objectstorage as well. And the objectstorage can then implement the cache invalidation.

@dschmidt

dschmidt commented Aug 31, 2026 •

Copy link
Copy Markdown
Contributor

Found a compatibility bug while pointing the generator URL at a real imagor (imagorvideo): generator.BuildURL emits two filter segments (filters:no_upscale()/filters:format(x)/), but imagor only reads a single filters segment, so format() is silently ignored and e.g. PNG input comes back as PNG despite format(jpeg). Our own thumbnails router parses the two-segment form, which hides this.

Related: for the default fill operation (no operation segment) the router has no route with filters:no_upscale(), so every default-operation request 404s.

Fix is building a single segment and matching the routes:

--- a/services/webdav/pkg/generator/url.go
+++ b/services/webdav/pkg/generator/url.go
@@
 	box := fmt.Sprintf("%dx%d", width, height)
-	noUpscale := "filters:no_upscale()"
-	formatFilter := fmt.Sprintf("filters:format(%s)", outputExt)
+	filterSegment := fmt.Sprintf("filters:no_upscale():format(%s)", outputExt)
 
 	segment := operationSegment(operation)
 	if segment == "" {
-		return fmt.Sprintf("%s/unsafe/%s/%s/%s/", base, box, noUpscale, formatFilter)
+		return fmt.Sprintf("%s/unsafe/%s/%s/", base, box, filterSegment)
 	}
-	return fmt.Sprintf("%s/unsafe/%s/%s/%s/%s/", base, segment, box, noUpscale, formatFilter)
+	return fmt.Sprintf("%s/unsafe/%s/%s/%s/", base, segment, box, filterSegment)
--- a/services/thumbnails/pkg/service/http/v0/service.go
+++ b/services/thumbnails/pkg/service/http/v0/service.go
@@
-	m.Post("/unsafe/{operation}/{width}x{height}/filters:no_upscale()/filters:format({format})/", handler)
-	m.Post("/unsafe/{operation}/{width}x{height}/filters:no_upscale()/filters:format({format})", handler)
-	m.Post("/unsafe/{width}x{height}/filters:format({format})/", handler)
-	m.Post("/unsafe/{width}x{height}/filters:format({format})", handler)
+	m.Post("/unsafe/{operation}/{width}x{height}/filters:no_upscale():format({format})/", handler)
+	m.Post("/unsafe/{operation}/{width}x{height}/filters:no_upscale():format({format})", handler)
+	m.Post("/unsafe/{width}x{height}/filters:no_upscale():format({format})/", handler)
+	m.Post("/unsafe/{width}x{height}/filters:no_upscale():format({format})", handler)

(plus the URL literals in generator_test.go / push_test.go). Verified against imagorvideo: with one segment format() is honored and the default operation stops 404ing.

@butonic
butonic force-pushed the imagor branch 2 times, most recently from 026b932 to 07eff1b Compare September 1, 2026 05:12
@butonic

butonic commented Sep 1, 2026

Copy link
Copy Markdown
Member Author

The web ui fetches previews with

  • ?scalingup=0&preview=1&a=1&processor=thumbnail&x=32&y=32 for the list view and
  • ?scalingup=0&preview=1&a=1&processor=fit&x=320&y=320 for the tiles view
  • ?scalingup=0&preview=1&a=1&processor=fit&x=1920&y=1920 for the gallery or even
  • ?scalingup=0&preview=1&a=1&processor=fit&x=3840&y=3840 based an the available space

The tests fetch a 1200x1200 image to test text rendering ...

However, the available preview resolutions are configured on the server side and NONE of the resolutions is in the default list (save 32x32): "16x16", "32x32", "64x64", "128x128", "500x280", "280x500", "1000x560", "560x1000", "512x2048", "1080x1920", "1920x1080", "2160x3840", "3840x2160", "4320x7680", "7680x4320".

The only real difference is the processor.

  • thumbnail (aka imagor fill = the default) resizes the image to the given width W and height H, auto-cropping the excess to fill the box. The crop is centered.
  • fit (aka imagor fit-in) resizes the image to fit within the given dimensions without cropping. The result may be letterboxed

THUMBNAILS_RESOLUTIONS was used to limit the number of thumbnail resolutions that will be cached AND returned by the API. So the quare resolutions requested by the web ui with fit (fit-in), produce landscape or portrait previews, depending on the source image dimensions.

If the source image happens to be wider or taller than the configured resolution, the largest configured resolution will be generated and cached. This might be smaller than the requested width or height.

But since the web ui does not always know the size of the image all it can do is tell the server to fit the image into a rectangular box and the server will fit-in the image. For fit-in the response never is a square, which the web ui already expects.

I think we can delete all non square resolutions, because in effect only they are requested. thumbnail and fit are only used to make the server crop the image to the exact resolution (thumbnail), or fill the image to the box without cropping.

@dschmidt

dschmidt commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Not sure, happy to discuss (if needed at all) - but how do I request previews for ultrawide pictures?

I'm requesting non-quadratic previews here: opencloud-eu/web-extensions#529

@JammingBen

Copy link
Copy Markdown
Member

Not sure if or how this affects the web client, but if it does in any capacity, please check in with one of us to verify the desired behavior. We've made quite some performance improvements over the last few weeks by optimizing requested and delivered resolutions, it would be a shame if we regressed on that.

@codacy-production

codacy-production Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 12 critical · 37 minor

Alerts:
⚠ 49 issues (≤ 0 issues of at least minor severity)

Results:
49 new issues

Category Results
Security 12 critical
CodeStyle 37 minor

View in Codacy

🟢 Metrics 941 complexity

Metric Results
Complexity 941

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

The multipart part carries the source's own type, because imagor's upload
loader accepts image/* only. A public link that needs a password is a 404 again, a preview carries
nosniff and a content length, and the input size limit is read with reva's
bytesize again.
A source type maps onto the type its thumbnail is returned in, and that
type is asked for by the name the generator knows it under. The answer
carries that type whether it was just generated or came from the cache,
which is also what the cache key says.
Nothing is decoded in webdav any more, which also ends the libvips build
split in the preview path.
webdav, graph and ocdav read the same list, so a configured type is
announced and attempted by all three. A type the generator cannot answer
is refused at startup, and ocdav takes the types from its own
configuration instead of importing an OpenCloud package, which the
vendored reva carries until opencloud-eu/reva#885 lands.
A converter that found nothing returns nil, so the workflow decides what to
do with it instead of reading an error.
The upload part has to be typed, a malformed request is a 400 while a
source it cannot read is a 406, an animated gif flattens when the output
is still, a still source can be written as a gif, libvips writes webp, and
fit-in rounds the way vips and imagor round it.
The fixtures are named after what they are, and the leftovers of the
deleted grpc service are gone.
A cache backend nobody can serve fails at startup instead of dropping
entries silently, the memory backend is bounded, a busy generator's 429
reaches the client with its Retry-After, and a 400 from the generator is
read as this service's own bug instead of a missing preview.
An unconstrained axis is fitted instead of refused, a decides when the
client sent it, and an empty resolution ladder is refused at startup
instead of letting the client pick the cache key.
The gif loop is shared by both builds, the executor takes a request
struct, the route table halves, the aspect flag is a pointer instead of
two bools, and the cache's budget is named after what it is.
…gors

The same image goes to the built-in generator and to both imagor images,
and status, dimensions, format, frames and alpha are compared. A generator
that answers differently is held to that answer by a recorded override, so
the test fails once the divergence disappears. It runs once per resize
implementation, because make test builds the pure Go path while the images
ship vips.
The repeated post-and-assert bodies collapse into tables, and the sources
are built without gomega because they run while the spec tree is built.
@dschmidt
dschmidt requested a review from aduffeck October 17, 2026 08:14
An explicit x=0 or y=0 is an error again, only an omitted axis is unconstrained, and the text preview fixtures carry the resolution the generator writes now.
The a and processor defaults, that every named processor overrules a, the shared OC_THUMBNAIL_* names, the gif output type, the ladder making the response a different size, the empty filters segment that answers 404, and 406 instead of 400 for a format the build cannot write.
The push endpoint takes an empty filters segment, ignores an image key behind the box and computes a side given as 0 from the other one, all measured against both imagors and pinned in the parity suite. Plus the review leftovers: no em dashes, the request test on ginkgo, comments that contradicted the code, one stat for Head and generate, one thumbnail handler, the gif fit-in rounding of vips, and the new env names last so they win.
The dimension guard and the input-format detection used Go's decoder registry in both builds, so with libvips they were silently skipped for everything Go cannot read, and a webp source came back as a JPEG where imagor keeps it a webp. Each build now reads the header with the library that will decode the image, the guard runs on that, and the animated gif path is shared instead of living in both encoders. The hook names stay per build, the function variables are typed per build, so the any and its seven type assertions are gone.
A processor that needs the whole box is not a cropping one, so targetsExactBox says what it decides. An output format without an encoder answers 406 like every other thing this build cannot write. The configured defaults are no longer seeded twice, the constructors merge them anyway, like they do for the source types. And webp needs no build tag: both builds read it, the pure Go one answers the JPEG the mapping asks for, which the parity suite pins.
The thumbnails service kept a single map of supported mime types that nothing in that service used any more, only webdav read it, and the comment still claimed reva reads it there. It is webdav's announcement, so DefaultSourceTypes lives next to SourceTypes now, the leftover package is gone, and no webdav file imports the thumbnails service any longer.
With previews off a thumbnail path answered 405, because the REPORT patterns match the same paths and the GET routes were left out; the routes stay now and the shared parse step answers 404, which is what the setting promises. The deprecated cache directory is read again: its default no longer sits in the config struct, where it made the fallback unreachable. A processor nobody knows is treated like none, in the parser too. A comma-joined resolutions entry in yaml keeps working. Plus the comments and the readme line that described something else than the code does.
The four writable formats were a whitelist in the filter parser and a second list of content types in the encode step; they are one map now, so a format nobody writes stays a 400 and one without an encoder in this build stays a 406. The fit-in shape existed twice since the gif loop started rounding like vips, it lives in box.go now. And the push routes are back inline: mountPushRoutes only existed so a test mux could not drift from them, and the specs go through the real NewService instead.
…thing else

The aspect-ignored header blamed a processor for a decision the free axis can make too. fit-in does not letterbox, it answers smaller than the box. The s3 cache checks bucket, region and credentials, not the endpoint. A mapping onto a type the generator cannot write is refused at startup instead of quietly becoming a JPEG. And a processor name nobody knows resolves like none, which three comments and a readme line still denied.
Two readme sentences promised a startup refusal without saying that nothing reads these settings when OC_DISABLE_PREVIEWS is set, so a typo surfaces when previews come back on.
@orgaralf

Copy link
Copy Markdown

Disclaimer: this is an AI-assisted comment, posted by me (Ralf). I asked an AI to read the full branch at 5e2af30 (not just the diff), the whole thread and all 27 commits, and I went through its findings and tried to understand them before posting. The analysis and the wording below are the AI's; the decision to post is mine. Please treat it as an extra pair of eyes from outside the team, not as a verdict, and tell me where it is wrong.


Overall: the architecture is right and I would merge this after a short list of fixes, not a rework.

What this gets right

  • Production code shrinks. ~4,000 lines of gRPC, JWT transfer tokens, storage and fetchers are gone, opencloud init no longer mints a transfer secret, and the thumbnails service is a ~400-line stateless executor behind one POST route. That is a smaller attack surface by construction, not by policy.
  • ThumbnailWorkflow is one readable file with four narrow interfaces (Stater, FileDownloader, SpaceLookup, UserResolver), which is why it can carry 1,677 lines of tests. Source images stream storage → webdav → generator through an io.Pipe with a typed part and no buffering; only text/audio/ggs sources are read into memory, and those are capped by the 50 MB stat check before the download starts.
  • Error mapping is honest now: 404 only for not-found / unsupported / generator-406, 403 for too-large and missing download permission, 425 while processing, 429 with the generator's own Retry-After, 500 for "generator down". The earlier "everything becomes 404 and clients cache it as no-preview" problem is gone.
  • The ladder plus the snapped cache key is the best change in the PR: cache entries per file go from client-controlled (unbounded, reachable through public links) to ladder² × operations, and x=447 and x=448 share an entry.
  • The parity suite against two real imagor images, with recorded overrides that carry a reason and fail once the divergence disappears, is how compatibility should be tested. It is also the proof that "imagor-compatible" was an untested claim until October.
  • Config hygiene: aliases for THUMBNAILS_RESOLUTIONS, _MAX_INPUT_IMAGE_FILE_SIZE, _TXT_FONTMAP_FILE; proper deprecation metadata on THUMBNAILS_FILESYSTEMSTORAGE_ROOT; OC_DISABLE_PREVIEWS is back; startup refuses an unknown cache backend, an incomplete S3 config, an empty ladder, or a type mapping the generator cannot write.

Blocking, in my opinion

  1. Hand-patched vendor/. The vendored reva carries fix(ocdav): answer has-preview from the ocdav configuration reva#885 ahead of a reva release. Any go mod vendor reverts it, and upstream reva still imports opencloud/services/thumbnails/pkg/thumbnail, which this PR deletes, so the build breaks the moment anyone bumps a dependency. Merge order has to be reva#885 → reva tag → go.mod bump → this PR. I would not merge with the patch sitting in vendor/.
  2. GIF frame bomb (push.go#L219). serveAnimation guards the logical screen via DecodeConfig and then calls gif.DecodeAll with no frame cap. At the default 7680×7680 a paletted frame is 59 MB; LZW compresses a flat frame roughly 2,000:1, so a 2–3 MB file well under the 50 MB limit allocates several GB, and THUMBNAILS_MAX_CONCURRENT_REQUESTS defaults to unlimited. The description lists this as a follow-up. Given that the PR's thesis is "the bytes are parsed in a process you can lock down", and that in the single binary this process is OpenCloud itself, I would cap it here: pre-scan the 0x2C image descriptors and refuse above N frames or a frames×w×h pixel budget. imagor has -vips-max-animation-frames; this needs the same.
  3. Zip bomb in GgsDecoder (preprocessor.go#L64). io.ReadAll on a zip entry inside webdav, deflate at ~1,000:1, so a 50 MB .ggs can expand to ~50 GB inside the one process that holds the user's auth context. One io.LimitReader fixes it. The TODO comments already say these extractors are the attack vector; until Tika replaces them they should at least be bounded.
  4. Unannounced default change. No processor and no a now means fill with upscaling (workflow.go#L574-L587). The rewritten acceptance test expects 1x1024 → 16x1024 and 1024x1024 → 1024x1024 from a 640x480 text render, where main returned 16x16 and 640x480. Web always sends a processor, so it is unaffected, but any client that sends bare x/y (OC10-style integrations, scripts) now gets a blurry upscaled crop. Either emit no_upscale() for the default fill too, or write it into the changelog as a behaviour change. The Oct 3 "is that intended?" in this thread never got an answer.

Should fix, small

  • S3Cache.Get (s3cache.go#L92-L98): minio's GetObject is lazy, so the NoSuchKey branch on its return value can never fire with the real client; the miss surfaces from ReadAll as a plain error. It works only because the caller treats every error as a miss. Secure: true is also hard-wired (no http:// MinIO for dev), and context.Background() ignores the request deadline.
  • Crop position differs per build: the vips fill uses InterestingAttention (smart crop, push_vips.go#L89) while the pure-Go build, the GIF path and imagor's default are center crop. The parity suite compares dimensions and format, not crop position, so it cannot see this. Pick one.
  • The push endpoint has no body cap: ParseMultipartForm(32<<20) then io.ReadAll(file) (push.go#L85, #L104). The size limit lives only in webdav's stat check. Fine on loopback, but an http.MaxBytesReader matching WEBDAV_THUMBNAIL_MAX_INPUT_IMAGE_FILE_SIZE costs two lines.
  • No ETag / Cache-Control on the thumbnail response, which was lamented in this thread already. The cache key is a content-addressed ETag (checksum + box + op + format); emitting it is a three-line win, and the README's c row still describes caching semantics the code does not have.
  • FontLoader is rebuilt (fonts re-parsed) on every text preview request via preprocessor.ForType.
  • One http.Client{Timeout: 30s} is shared by the generator POST and the source download, so the generator timeout silently doubles as the storage download timeout for a 50 MB source.
  • frontend and graph now import services/webdav/pkg/thumbnail. Same cross-service pattern as before (they imported services/thumbnails/pkg/thumbnail), but the top-level pkg/ suggested earlier in this thread is the cleaner home for a list three services read.
  • Codacy's 12 "critical" findings are untriaged. Probably gosec on the file-cache I/O, but the check is red and the thread does not say.

Process

Commits titled "answer the third review round" with zero review comments on GitHub mean the review happened somewhere else. For a change that deletes a public gRPC API and changes client-visible sizing, it would help outside readers a lot if that trail were on the PR.

Both builds and imagor now crop the same region, a gif is bounded by its
declared frames, the posted body by THUMBNAILS_MAX_INPUT_IMAGE_FILE_SIZE and an
image extracted from a container by the configured input size. The s3 cache
reads a miss where minio reports it.

The push endpoint's HTTP behaviour is asserted in the parity suite now, where
imagor answers the same request, so 41 duplicated service specs are gone and
the 32 that remain are what a non-default config or a helper decides.
@dschmidt
dschmidt force-pushed the imagor branch 2 times, most recently from 3c456b7 to dd91deb Compare October 18, 2026 20:39
A cropping resize now carries imagor's smart segment, which is how the libvips
build and a real imagor keep the region that carries the image, the way main
cropped before the generator moved out. A build without libvips answers the
same request with a centre crop, and the parity suite records that.
probe: cropProbe,
generatorOverrides: map[string]override{
"imaging": {answers: "200 100x100 png drops-marker", because: "an attention crop is libvips work; this build takes the segment and crops the centre, which is what the thumbnails service has always done without libvips"},
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means that text in .txt can be cut off in pure go builds while it works in imagor and vips builds.

It's not a regression it's a preexisting bug on main.
Claude says it can be fixed in about 80 LoC, but I'm not convinced it's worth it. Noone should be using it in production...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Type:Enhancement Type:Maintenance E.g. technical debt, packaging, etc.

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

Thumbnailer should use a push based mechanism

4 participants