Skip to content

Subpath-relative Thumbor URLs (PGTHUMBOR_SERVER_URL=/thumbor) break image_scales metadata rendering #7

Description

@jensens

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:

  1. Products.CMFPlone.image_scales.indexer.image_scales(obj)IImageScalesAdapter.

  2. Products.CMFPlone.image_scales.adapters.ImageScales iterates the Dexterity schemata and asks IImageScalesFieldAdapter per image field.

  3. 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).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions