Skip to content

Push based thumbnailer architecture - #3397

Open
butonic wants to merge 5 commits into
mainfrom
imagor
Open

Push based thumbnailer architecture#3397
butonic wants to merge 5 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.

Webdav gains what it needs to own that pipeline: the preprocessors (PDF→image, 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, optional auth header (for an external imagor behind a proxy), 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; thumbnail requests no longer need the second round-trip, and the resizer is trivially replaceable.

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.

@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 889 complexity

Metric Results
Complexity 889

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.

@butonic

butonic commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

@JammingBen This PR is backwards compatible in that it tries to produce the same thumbnails as before. But because I do not want to examine any bytes in process for security reasons I can no longer use the size of the source image when deciding on the dimensions of the preview image. Furthermore, the list of allowed resolutions is not really matching anything web requests (save 64x64 thumbnails), AFAICT. All the fit requests also request a square. But the old code works more by accident, than design. fit + square simensions conceptually tells the "server fit the image inside this box, keep aspect ratio, do not crop, it is ok if I get a landscape or portrait preview back". "thumbnail + square" tells the server give me an image with exatly these dimensions, crop if necessary. That is how I interpret the requests by web. Correct me, if I am interpreting that wrong. But that also means the a and scaleup params are unneeded and do no tneed to be sent by web. In fact scaleup and a are ignored in main. In this PR I added a response header to let clients know if they send contradictory params.

Anyway, this PR allows using the the thumbnail and fit processors as well as the corresponding imagor variants (fill and fit-in respectively, see the imagor image endpoint docs).

The resulting preview images should now be what the client requested, because on main there are corner cases where it is in fact possible that the generated image was taller or wider than the requested box.

What did you do to improve performance with regards to thumbnails? respect cache headers? rely more on the c param? The latter is also ignored by opencloud... It would allow cache busting only if a caching proxy sat infront of opencloud. Hell, we are not even setting any cache related headers on main (neither on this branch). 😞

So yeah, I would like to implement preview generation properly on the server side under the mentioned assumbtion about the requests web makes. I also know that domme wants to requests landscape and portrait images. And I think we should be able to finally add proper cache headers.

@JammingBen

Copy link
Copy Markdown
Member

fit + square simensions conceptually tells the "server fit the image inside this box, keep aspect ratio, do not crop, it is ok if I get a landscape or portrait preview back". "thumbnail + square" tells the server give me an image with exatly these dimensions, crop if necessary. That is how I interpret the requests by web.

Yes this is also my understanding of it.

But that also means the a and scaleup params are unneeded and do no tneed to be sent by web. In fact scaleup and a are ignored in main. In this PR I added a response header to let clients know if they send contradictory params.

This is good to know, so Web can safely remove them from the request body then?

What did you do to improve performance with regards to thumbnails

We introduced more values to THUMBNAILS_RESOLUTIONS and optimized the requested resolutions in Web for that. So I got alarmed when I saw this config getting touched 😄 I just tested your branch, it looks good! The returned previews have the same sizes are before AFAICT.

@butonic

butonic commented Sep 2, 2026

Copy link
Copy Markdown
Member Author

so Web can safely remove them from the request body then?

yes

We introduced more values to THUMBNAILS_RESOLUTIONS and optimized the requested resolutions in Web for that. So I got alarmed when I saw this config getting touched 😄 I just tested your branch, it looks good! The returned previews have the same sizes are before AFAICT.

ok, to verify:
upload a wide (1500x669) landscape image like this:

greg-rutkowski-beach-girl-study-1500

web makes requests like this:

https://demo.opencloud.eu/dav/spaces/d21381aa-01dc-45cb-a5b8-e6880c47c954%24baf1910c-04be-41bd-9c24-685871975b18/Gregorz%20Rutkowski/greg-rutkowski-beach-girl-study-1500.jpg?scalingup=0&preview=1&a=1&processor=fit&c=ea44f9d48500a7e51187df7a0e739eb9&x=320&y=320

it specifically tells the server to fit the image inta a 320x320 box ...but the server returns a 500x223 image:

greg-rutkowski-beach-girl-study-500x223

but 500 is wider than 320x320 ... which is plainly wrong. This PR will return a 320x142 image:

greg-rutkowski-beach-girl-study-320x142

but ... all of that is just an accidental fix. And web also makes the image fit inside the tiles, so this should be fine.

@dschmidt

dschmidt commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

but 500 is wider than 320x320 ... which is plainly wrong. This PR will return a 320x142 image:

sounds correct! But that makes it more important to think about ultrawide pictures.
Only 320 as config value, wouldn't allow for 320 height x 1200px width (making that up, but you get the idea)
And allowing 1200px quadratic pictures sounds odd for this use case as well

@butonic

butonic commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

I think it the use case for web is to say:

  1. fit: "give me something I can fit inside this square. I will handle any landscape or portrait preview myself, just don't make any border bigger than my edges."
  2. thumbnail: "give me something that is exactly this big. It can be cropped to keep the aspect ratio, but I need the correct dimensions."

For the latter case there is bad news, because the server will only produce thumbnails in the configured resolutons. Even for ultrawide images, the current widest resolution is "7680x4320". That is pretty wide. But "give me exactly these dimensions" for thumbnail will only give you a portrait / landscape / square preview in one of the configured resolutions. The imagor docs describe the fill operation as:

Resizes the image to the given width W and height H, auto-cropping the excess to fill the box. The crop is centered by default.

Together with the no_upscale() filter (=scalingup=0in the preview url) the resulting image should keep the aspect ratio and not be scaled weirdly.

If the web ui knows the exact dimensions we can extend the preview url parameters to allow more filters. For now I just implemented the minimal set that is needed by the current web ui. The implementation does already pass the processor param as the imagor operation, but that obviosly will only work with the real imagor instance. Again, we could extend our built in service or maybe embed imagor? ... later ... someday, maybe ...

@butonic

butonic commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

The configured resolutions will be sorted into square, landscape and portrait buckets. And depending of the orientation in the preview request url the corresponding bucket will be used to pick the actual preview size that will be sent to imagor. whatever that returns (which might differ from the requested dimensions) will be returned. I think this is the best tradeoff to flood the cach with every possible dimension to eat the storage and the usability in the web. With this PR we could allow more, but ... that I want to defer that to subsequent PRs.

@butonic

butonic commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

ok, I think I have the three commits in good shape.

@dschmidt

dschmidt commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

In the beginning I wasn't sure where this was going, I thought you wanted to embed imagor straight away, but this is a nice iteration.

Claude says:

Blockers

  1. Max-resolution check is inverted and runs too late (push.go:107). The condition is (w > maxW) && (h > maxH), so a 20000x100 image passes. Needs ||. Worse, it runs after processImage, so the image is fully decoded before the limit applies. In the imaging build that is imaging.Decode at push_imaging.go:36, which reintroduces the dimension-bomb OOM that fix(thumbnails): bound declared image dimensions before decoding #3457 fixes on main (a 375 byte JPEG declaring 20000x20hi000 allocates ~381 MB). The 422 test at push_test.go:571 does not catch the && because it exceeds both axes. The guard needs to sit in front of the decode in push_imaging.go, push_vips.go and the moved preprocessor in webdav.
  2. Empty checksum shares one cache key across all files (workflow.go:96, :349). main rejects resources without a checksum with 404 (grpc/v0/service.go:323). The branch drops the check and builds /32x32-fill.jpg from "", so every file without a checksum returns the same cached thumbnail once a cache backend other than none is on. No test covers the empty case.
  3. resolveUser type-asserts w.stater.(*gatewayStater) (workflow.go:557). Any other Stater injected via WithStater panics on a path-based request (/dav/files/{user}/..., /webdav/...). Should be its own injected dependency.

Regressions against main

  • MaxInputFileSize has no default anymore. main had 50MB, webdav's defaults leave it at 0 = unlimited, and webdav reads the whole file into memory with io.ReadAll (workflow.go:645) before posting it.
  • MaxConcurrency is gone. main capped parallel generation with a semaphore, the branch has no limit in webdav or the push handler. Together with the previous point, memory is bounded by concurrent requests times file size.
  • Every error becomes 404. handleWorkflowError maps everything but the four named errors to "File could not be located", including generator down, download failures and timeouts. main returned 500 for those. Clients that cache 404 as "no preview" go blank for the lifetime of the cache during a generator outage.
  • /dav/files/{user}/... ignores the user from the URL. main resolved {user} via GetUserByClaim, the branch uses WhoAmI on the token (absolutizeUserPath).
  • handlePublicLinkAuthError string-matches PERMISSION_DENIED and expired in the error text. gRPC status codes are available.

imagor compatibility

  • no_upscale() was dropped from url.go entirely. The built-in resizer uses SizeDown for fit-in, real imagor upscales on fit-in unless no_upscale() is set. Worth a test against the real thing: POST /unsafe/fit-in/320x320/filters:format(jpeg)/ with a 100x100 image. If 320x320 comes back, the filter needs to go back in, and the cache key does not distinguish which generator produced an entry.
  • The optional auth header goes out as x-access-token (revactx.TokenHeader), hard-coded.
  • The thumbnails service has no auth and no body limit beyond the 32 MB memory threshold of ParseMultipartForm, which is not a limit. Fine for the default 127.0.0.1 bind, but the README should say the port must not be exposed.

Minor

  • GuessExtension (extensions.go) is a strings.Contains heuristic next to ExtForMime, which does the same properly. One can go.
  • inputFormatViaImaging decodes the full image just to detect the format, image.DecodeConfig is enough.
  • S3Cache.Get and FileCache.Get copy the buffer once more after ReadAll.
  • The default resolution list changes (adds 320x320, 1024x1024, drops 512x2048) and THUMBNAILS_RESOLUTIONS now configures a webdav value. Should be in the changelog.

@dschmidt

dschmidt commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

One more on the preprocessor, since it moved as-is: in this branch GifDecoder no longer does anything useful.

For a gif request webdav runs gif.DecodeAll on every frame (preprocessor.go:37), encodeForUpload re-encodes all frames with gif.EncodeAll (encode_imaging.go:36), posts the result, and the thumbnails service then runs gif.DecodeAll on the same bytes again before resizing (push_imaging.go:28, push_vips.go:27). The decode/encode roundtrip in webdav only validates the file, and a broken gif ends up as a 404 either way.

What it costs: a large animated gif is fully decoded twice, once inside the webdav process, and gif.DecodeAll allocates the header-declared frame size, so this is the same dimension-bomb vector as #3457, just in webdav and without the guard. It also breaks the "webdav does not inspect image bytes" property for gif.

Suggestion: add image/gif to directImageMimes (workflow.go:535). The thumbnails service already detects gifs itself via isGifReader and handles animation, so the raw bytes can pass through like png and jpeg. GifDecoder and the *gif.GIF branch in encodeForUpload become dead code and can go. matchOperation is unaffected, the stretch default keys off the mime type.

@butonic
butonic force-pushed the imagor branch 3 times, most recently from 8a31e31 to 954eb9b Compare September 4, 2026 15:50
Add a stateless POST endpoint that accepts an original image as a multipart
upload and returns the resized thumbnail, mimicking imagor's /unsafe/ API:

  POST /unsafe(/fit-in|/stretch)/{width}x{height}(/filters:{filters})

The three forms are fill (center-crop to the exact box, may upscale), fit-in
(preserve aspect ratio, fit within the box, never upscale) and stretch (resize
to the exact box without preserving aspect). Supported output formats are jpg,
png and gif. Image processing is split by build tag: stdlib imaging by default,
libvips when built with -tags enable_vips. The endpoint is stateless: no auth,
no storage, no source fetching. This commit only adds the /unsafe routes; the
legacy /data endpoint is left in place and removed in a later commit.
…erator

webdav now owns the complete thumbnail pipeline: stat -> validate -> cache
check -> download -> preprocess -> generate -> cache -> respond. The
thumbnails service is used only as a stateless imagor-compatible image
resizer via its /unsafe push endpoint.

- New ThumbnailWorkflow drives the pipeline; space-scoped refs are anchored
  at the space ResourceId and downloads authenticate with the user's
  x-access-token.
- Preprocessing (audio/geogebra/text/gif) moves from the thumbnails service
  into webdav (services/webdav/pkg/preprocessor).
- Thumbnail caching moves to webdav (memory/file/s3 backends, file cache off
  by default); resolutions are snapped orientation-aware in webdav.
- Generator URL defaults to the local thumbnails service; new WEBDAV_* config
  for generator, cache and resolutions.
- Acceptance tests updated for the new sizing/processor behavior.
The thumbnails service is now a stateless image resizer; remove everything
the old gRPC-based pipeline needed:

- proto definitions and generated code (service + messages)
- gRPC server, handler and decorators
- JWT transfer token code and the /data HTTP download endpoint
- filesystem storage layer and source fetchers (webdav + CS3 imgsource)
- duplicated encoding/generator/processor/resolution utilities
- the preprocessor packages moved to webdav in the previous commit
- dead config fields (Thumbnail struct, GRPC config, unused go-micro client),
  no-op metrics/instrumentation wrappers, errors package, stale testdata and
  the Makefile protobuf target

Also remove the now-meaningless thumbnails transfer secret generation from
opencloud init and point the graph service at the new webdav thumbnail
package. The README is rewritten to document the imagor-like push endpoint.
Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>
Signed-off-by: Jörn Friedrich Dreyer <jfd@butonic.de>
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

3 participants