Context
We run Thumbor behind the same origin as Plone, under a subpath (/thumbor/), and we have multiple Plone subsites on different hostnames. In this setup, PGTHUMBOR_SERVER_URL should be a path only — e.g. /thumbor — so the browser resolves each image URL against the current subsite's host, not a fixed Thumbor hostname baked in at index time.
Direct access via @@images/<uid> (no brain metadata involved) already works with PGTHUMBOR_SERVER_URL=/thumbor, because ThumborImageScale._scale_url returns "/thumbor/<hmac>/.../<zoid>/<tid>" and the browser resolves that against the current host.
The breakage is on the brain image_scales metadata path (listings, summary views, search results — anything that reads pre-rendered scale URLs out of the catalog column instead of loading the object).
Analysis (plone.namedfile 8)
The write path goes:
-
Products.CMFPlone.image_scales.indexer.image_scales(obj) → IImageScalesAdapter.
-
Products.CMFPlone.image_scales.adapters.ImageScales iterates the Dexterity schemata and asks IImageScalesFieldAdapter per image field.
-
plone.namedfile.adapters.ImageFieldScales calls @@images (→ our ThumborImageScaling), then stores the URL via:
# plone/namedfile/adapters.py (8.0.0a4, unchanged from 7.x)
def _scale_view_from_url(self, url):
# "common case is a local path to @@images/foo-scale"
return url.replace(self.context.absolute_url(), "").lstrip("/")
With PGTHUMBOR_SERVER_URL=/thumbor, url is "/thumbor/<hmac>/.../<zoid>/<tid>". context.absolute_url() is https://…/folder/doc, so .replace(...) is a no-op. Then .lstrip("/") eats the leading slash, and the catalog metadata ends up storing:
"thumbor/<hmac>/.../<zoid>/<tid>"
The read path is NavigationRootScaling._tag_from_brain_image_scales:
# plone/namedfile/scaling.py (8.0.0a4)
src = (
data["download"]
if data["download"].startswith("http")
else f"{brain.getURL()}/{data['download']}"
)
Since the stored value no longer starts with http (and no longer starts with / either), it hits the else branch and the final src becomes:
https://site-a.example/folder/doc/thumbor/<hmac>/.../<zoid>/<tid>
The Thumbor subpath is concatenated after the document path instead of living at the host root. The browser then requests a non-existent URL → 404 / broken image, but only when rendering happens from the brain metadata — direct object rendering still works, which makes this easy to miss in isolated tests.
With an absolute PGTHUMBOR_SERVER_URL (https://thumbor.example) — for comparison
_scale_view_from_url's .replace() and .lstrip("/") are both no-ops (no overlap with context.absolute_url(), no leading slash), so the full absolute Thumbor URL lands in the metadata verbatim. The read path then hits the startswith("http") branch and passes it through unchanged. This is the only case that currently works for brain-metadata rendering, and it hard-codes the Thumbor hostname into every catalog row — which is what breaks multi-host / subsite deployments.
Impact
- Subpath deployments (
/thumbor) are broken on any template that renders from image_scales brain metadata (standard listings, summary / tile views, REST API serializations, anything going through NavigationRootScaling.tag(brain, ...)).
- Absolute deployments work, but bake the Thumbor hostname into the catalog. Host rename, key rotation, or moving Thumbor to a different domain require a full
@@thumbor-purge-scales reindex.
- Multi-host / subsite deployments can't express "relative to whichever host served the page" at all today.
Proposed fix (discussion)
Two small, targeted overrides in plone.pgthumbor, both bound to IPlonePgthumborLayer. No new config flag — PGTHUMBOR_SERVER_URL=/thumbor becomes the single knob.
1. Write side — custom IImageScalesFieldAdapter
Register a subclass of plone.namedfile.adapters.ImageFieldScales for (INamedImageField, IDexterityContent, IPlonePgthumborLayer) that overrides _scale_view_from_url to preserve absolute-origin and root-relative URLs verbatim:
def _scale_view_from_url(self, url):
if url.startswith(("http://", "https://", "/")):
return url # Thumbor URL — leave intact
return super()._scale_view_from_url(url)
Result: the catalog stores "/thumbor/<hmac>/.../<zoid>/<tid>" verbatim (leading slash preserved), or the full https://thumbor.example/... form when configured that way.
2. Read side — ThumborNavigationRootScaling
Subclass NavigationRootScaling and register it as the @@image_scale view on INavigationRoot in our layer. Override _tag_from_brain_image_scales so the pass-through branch also accepts root-relative paths:
download = data["download"]
if download.startswith(("http://", "https://", "/")):
src = download
else:
src = f"{brain.getURL()}/{download}"
Browser resolution then does the right thing automatically:
- Absolute URL (
https://thumbor.example/...) → used as-is.
- Root-relative URL (
/thumbor/...) → resolved against the current request host, so subsite-A gets https://site-a.example/thumbor/... and subsite-B gets https://site-b.example/thumbor/..., from the same catalog row.
- Legacy relative path (
@@images/...) → resolved against brain.getURL(), unchanged behavior.
Trade-off
Toggling between absolute-host and subpath forms on an existing site requires a @@thumbor-purge-scales reindex, because the stored form is different. This is consistent with how security_key rotation, Thumbor host changes, and paranoid-mode toggles already work today — no new class of "requires reindex after config change" is introduced.
Out of scope
- No change to
url.py or config.py.
- No new environment variable.
- No change to the direct-access
@@images/<uid> path (already works).
plone.namedfile < 8 support — we've standardized on 8.
Test plan
- Unit tests for the new
_scale_view_from_url override (absolute, root-relative, legacy relative).
- Unit tests for
_tag_from_brain_image_scales override (all three forms).
- Functional test that simulates an indexed brain with a
/thumbor/... download and verifies the rendered <img src="..."> is the root-relative path (so the browser resolves it against the current host).
Context
We run Thumbor behind the same origin as Plone, under a subpath (
/thumbor/), and we have multiple Plone subsites on different hostnames. In this setup,PGTHUMBOR_SERVER_URLshould be a path only — e.g./thumbor— so the browser resolves each image URL against the current subsite's host, not a fixed Thumbor hostname baked in at index time.Direct access via
@@images/<uid>(no brain metadata involved) already works withPGTHUMBOR_SERVER_URL=/thumbor, becauseThumborImageScale._scale_urlreturns"/thumbor/<hmac>/.../<zoid>/<tid>"and the browser resolves that against the current host.The breakage is on the brain
image_scalesmetadata path (listings, summary views, search results — anything that reads pre-rendered scale URLs out of the catalog column instead of loading the object).Analysis (plone.namedfile 8)
The write path goes:
Products.CMFPlone.image_scales.indexer.image_scales(obj)→IImageScalesAdapter.Products.CMFPlone.image_scales.adapters.ImageScalesiterates the Dexterity schemata and asksIImageScalesFieldAdapterper image field.plone.namedfile.adapters.ImageFieldScalescalls@@images(→ ourThumborImageScaling), then stores the URL via:With
PGTHUMBOR_SERVER_URL=/thumbor,urlis"/thumbor/<hmac>/.../<zoid>/<tid>".context.absolute_url()ishttps://…/folder/doc, so.replace(...)is a no-op. Then.lstrip("/")eats the leading slash, and the catalog metadata ends up storing:The read path is
NavigationRootScaling._tag_from_brain_image_scales:Since the stored value no longer starts with
http(and no longer starts with/either), it hits the else branch and the finalsrcbecomes:The Thumbor subpath is concatenated after the document path instead of living at the host root. The browser then requests a non-existent URL → 404 / broken image, but only when rendering happens from the brain metadata — direct object rendering still works, which makes this easy to miss in isolated tests.
With an absolute
PGTHUMBOR_SERVER_URL(https://thumbor.example) — for comparison_scale_view_from_url's.replace()and.lstrip("/")are both no-ops (no overlap withcontext.absolute_url(), no leading slash), so the full absolute Thumbor URL lands in the metadata verbatim. The read path then hits thestartswith("http")branch and passes it through unchanged. This is the only case that currently works for brain-metadata rendering, and it hard-codes the Thumbor hostname into every catalog row — which is what breaks multi-host / subsite deployments.Impact
/thumbor) are broken on any template that renders fromimage_scalesbrain metadata (standard listings, summary / tile views, REST API serializations, anything going throughNavigationRootScaling.tag(brain, ...)).@@thumbor-purge-scalesreindex.Proposed fix (discussion)
Two small, targeted overrides in
plone.pgthumbor, both bound toIPlonePgthumborLayer. No new config flag —PGTHUMBOR_SERVER_URL=/thumborbecomes the single knob.1. Write side — custom
IImageScalesFieldAdapterRegister a subclass of
plone.namedfile.adapters.ImageFieldScalesfor(INamedImageField, IDexterityContent, IPlonePgthumborLayer)that overrides_scale_view_from_urlto preserve absolute-origin and root-relative URLs verbatim:Result: the catalog stores
"/thumbor/<hmac>/.../<zoid>/<tid>"verbatim (leading slash preserved), or the fullhttps://thumbor.example/...form when configured that way.2. Read side —
ThumborNavigationRootScalingSubclass
NavigationRootScalingand register it as the@@image_scaleview onINavigationRootin our layer. Override_tag_from_brain_image_scalesso the pass-through branch also accepts root-relative paths:Browser resolution then does the right thing automatically:
https://thumbor.example/...) → used as-is./thumbor/...) → resolved against the current request host, so subsite-A getshttps://site-a.example/thumbor/...and subsite-B getshttps://site-b.example/thumbor/..., from the same catalog row.@@images/...) → resolved againstbrain.getURL(), unchanged behavior.Trade-off
Toggling between absolute-host and subpath forms on an existing site requires a
@@thumbor-purge-scalesreindex, because the stored form is different. This is consistent with howsecurity_keyrotation, Thumbor host changes, and paranoid-mode toggles already work today — no new class of "requires reindex after config change" is introduced.Out of scope
url.pyorconfig.py.@@images/<uid>path (already works).plone.namedfile < 8support — we've standardized on 8.Test plan
_scale_view_from_urloverride (absolute, root-relative, legacy relative)._tag_from_brain_image_scalesoverride (all three forms)./thumbor/...download and verifies the rendered<img src="...">is the root-relative path (so the browser resolves it against the current host).