Changelog#
The authoritative changelog lives in CHANGES.md at the repository root. It is included below so the rendered docs always match the release history.
0.7.1 (2026-08-27)#
Fix: an environment variable can switch a boolean setting off again. #38.
PGTHUMBOR_SMART_CROPPING=falseagainst a registry value ofTrueproducedTrue. Reading these by membership made"false","0","no"and unset the same value, so an explicit “off” could not be told apart from “no answer”; the registry was consulted for both and its “on” won. The reference documentation has always promised the opposite, and these are the two settings an operator reaches for under pressure —PGTHUMBOR_PARANOID_MODEdecides whether every image request is access-checked against Plone.Both are now read through a
Nonesentinel, the same wayPGTHUMBOR_SOURCE_MAX_EDGEalready was. An empty value counts as set:VAR=in a compose file or a ConfigMap means off, not “ask the registry”. The set of spellings accepted as “on” is unchanged.Fix: the
image_scalesmetadata’s top-leveldownloadpoints at the original again, instead of at a Thumbor render. Half of #15.ImageFieldScales.get_original_image_urlbuilds that value by asking for a scale at the original’s own dimensions, which under Thumbor came back as a Thumbor URL. That was wrong twice. It is not the original — a 1:1 request through an image processor is at best a re-encode, and since 0.7.0 a Thumbor URL names the source derivative, so a “download original” link handed over a capped, colour-converted rendition rather than the uploaded bytes. And it is not context-relative, which the metadata contract requires: withPGTHUMBOR_SERVER_URL=/thumborthere is no context prefix to strip, so the stored value wasthumbor/<signed>and the renderer emitted{image_url}/thumbor/<signed>— broken for every consumer of the column, includingplone.namedfile’s owntag().It now returns the field’s own
@@images/{fieldname}URL, which Plone serves from the original blob. The new adapter is registered forIPlonePgthumborLayer, which is more specific thanplone.namedfile’s own registration, so it wins the lookup without an override and sites without the add-on keep the stock behaviour.The per-scale
downloadentries are unchanged: those should be Thumbor URLs, and their own version of the host-root problem is the rest of #15, which is tangled with #7.Fix:
purge_scalesreindexes only the objects it actually changed, and can be walked in bounded slices. Both halves of #16 that 0.7.0 left open._has_image_scales_metadata()asks the catalog schema, which is constant for a whole run, so deciding on it alone reindexed every catalogued object rather than the handful whose annotation was deleted — O(site) catalog writes for O(objects-with-legacy-scales) of work. It is now asked once per run instead of once per object, and the reindex happens only where an annotation was actually removed.purge_scales()takeslimitandstartand returnsnext_startanddone, so a site too large to finish in one request or one process can be walked in slices.@@thumbor-purge-scales?limit=1000&start=0reports where to resume; thezconsoleentry point readsPURGE_LIMITandPURGE_START. A bounded walk sorts onpath— the default order is whatever PostgreSQL returns and is not stable across queries, and an offset into an unstable order silently skips objects on resume. Paths do not change during a purge, which is what makes ordering on them safe here.purge_scales()now returns a dict rather than a 4-tuple.Lower ruff’s C901 max-complexity threshold from 15 to 13 as part of the ecosystem-wide complexity ratchet. The code base passes as-is after the
srcsetrefactor (#33).Refactor
ThumborImageScaling.srcset(complexity 21 → 8) below the C901 threshold and drop its# noqa: C901marker (#33). Primary-field lookup, srcset candidate collection, tag-attribute assembly, and thescale_in_srcfallback pick are now dedicated helper methods; behavior is unchanged.Enable ruff’s cyclomatic-complexity check (
C901, mccabe) withmax-complexity = 15. One existing hotspot is marked with a targeted# noqa: C901as a visible refactor candidate (ThumborImageScaling.srcset, complexity 21);scripts/is exempted via per-file-ignores (CLI code).Tests: CI now runs the suite against
plone.namedfile7.x and 8.x. The two put a differentImageScalemethod on the live path —scaling._HAS_SCALE_URLforks on it — so a run that only ever saw one of them exercised half the package. There is no lockfile here, so an unconstrained resolve gets 8.x while production runs 7.x, which was exactly the half CI never covered. The test step usesuv run --no-sync, because a plainuv runre-resolves frompyproject.tomland would silently undo the pin, leaving four jobs that all claim to cover both.
0.7.0 (2026-08-24)#
Add Thumbor source derivatives. Thumbor refuses images above its
MAX_PIXELSlimit (75 MP by default) and answers HTTP 400 after several seconds of work, so print-resolution originals never rendered at all; it also fetched the whole original on every cache miss, a 40 MB blob crossing the network and the decoder to produce a 3 KB listing thumbnail. Plone now stores a capped, sRGB-normalised secondNamedBlobImageon the original field value, as_pgthumbor_source, and every Thumbor URL addresses that blob instead. The original is never modified and@@downloadstill serves it byte for byte. Pillow becomes a direct dependency: it runs once per image on write, never on the request path, whereThumborScaleStoragestill looks up noIImageScaleFactoryandtests/test_storage.py::test_no_pillow_invokedstill holds. Closes #25.New setting
PGTHUMBOR_SOURCE_MAX_EDGE, registry fieldsource_max_edge, default 4000 pixels.0disables generation entirely, and values above8000are clamped on read rather than trusted, because a registry record written before the bound existed never revalidates and an env var bypasses validation outright. The ceiling is arithmetic rather than taste: a longest edge of E bounds the derivative at E² pixels, so abovesqrt(75e6), roughly 8660, a derivative could reproduce the very HTTP 400 this removes, and it would do so silently. The env lookup uses aNonesentinel instead of the existing falsiness-as-unset idiom, or the documented0kill switch would read as unset and get overwritten by the registry default. The cap in force is recorded with each derivative, so changing it later is an ordinary backfill run rather than a migration. Profile version 4, withupgrade_to_4registering the record on sites that already have the add-on.A subscriber on
IObjectAddedEventandIObjectModifiedEvent, registered forIDexterityContentand never for*, walks every schema and behaviour and gives eachNamedBlobImagefield a derivative. Generation triggers on size or on colour space (CMYK,LAB, the 16-bit integer modes, palette with transparency), independently, because tying normalisation to size alone would let a 3 MP CMYK press image through unconverted. SVG and animated GIFs are skipped. One decode at a time per process, behind a bounded semaphore with a short timeout: a print-resolution decode costs 79 to 105 MB of pixel buffer andIObjectModifiedEventcan fan out across every worker thread. Every outcome is recorded, including the ones that produced nothing, so failures stay enumerable; a semaphore timeout and a failed decode are explicitly non-terminal and get picked up again by an ordinary backfill run, with noforceflag for anyone to forget.Source selection, crop translation and dimension clamping all land in
_build_thumbor_url, the package’s single URL funnel, so all four call sites get them at once. Crop boxes fromplone.app.imagecroppingare stored in the original’s pixels and are now rescaled onto the derivative with a factor per axis and direction-aware rounding, and dropped when they degenerate. Requested dimensions are clamped to the selected source, andsrcsetno longer offers a candidate the source cannot satisfy: its original-width back-fill entry used to fail loudly with a Thumbor 400, and against a 4000 px derivative it would have started succeeding by scaling an 11811 px image up instead, which is a worse outcome than the failure.Keep source derivatives out of
Products.CMFEditionsversion snapshots.CloneNamedFileBlobscollects top-level field blobs only, so a nested derivative went through the pickle andZODB.blob.Blob.__getstate__returnedNone: the snapshot held aNamedBlobImagethat looked entirely valid and read back zero bytes, and a revert produced a field value whose(zoid, tid)resolved to an empty blob, which Thumbor answers with 400. A newICloneModifierdrops both attributes on clone, not only the derivative, since a terminal outcome record with no derivative would never regenerate. It is registered into the persistentportal_modifiertool by a GenericSetup step rather than by ZCML, so the profile goes to version 5 withupgrade_to_5: an install-only handler would leave every existing site without it, and an existing site is exactly the one this repairs. BothProducts.CMFEditionsand itsportal_modifiertool may be absent; both absences are logged and ignored, because with no version repository there is nothing to protect.New script
scripts/backfill_thumbor_sources.pygives existing content its derivatives. A keyset walk overobject_staterather than a catalog walk (a brain walk over the same population OOM-killed a production container during the original scan), chunked, resumable, with a dry run that reports the numbers a cap is chosen from: candidate count, median encoded derivative size, how many field values will have their scale uids move, and which scale names actually carry crops. Phase 2 re-indexesimage_scalesonce the new blobs have transaction ids, and it is not optional: the affected catalog rows hold direct, signed Thumbor URLs that a browser fetches without Plone in the path, so uid healing can never reach them and nothing improves until phase 2 has run.Fix:
plone.pgthumbor.purge_scalesno longer blanksimage_scalessite-wide. It calledmakerequest, which setsapp.REQUESTbut leaveszope.globalrequest.getRequest()atNone, and then re-indexedimage_scalesfor every object in the catalog. With no request theimage_scalesindexer raisesAttributeError, which is plone.indexer’s deliberate “do not index” signal;plone.pgcatalog’sextract_idxreads every metadata column asgetattr(wrapper, name, None)and the default swallows that signal; the value becomes a plainNoneand is merged into the JSONB column as an explicitnull. The column was overwritten, not skipped. The newplone.pgthumbor.zconsolemodule establishes a request carrying the browser layer and refuses to let a script write unless a request exists, provides the layer, and resolves@@imagestoThumborImageScaling; the backfill uses the same gate before its reindex phase. Closes #16.Two limitations are accepted rather than fixed. A truncated source blob yields a truncated derivative, grey where the scan data ran out, rather than no derivative:
plone.scalesetsPIL.ImageFile.LOAD_TRUNCATED_IMAGES = Trueprocess-wide at import, so Plone already renders those bytes that way, and a package that replaces Plone’s scaling should not judge the same bytes more harshly than the scaling it replaces. And images above roughly 179 MP get no derivative at all, because Pillow raisesDecompressionBombErrorinsideImage.openbefore this package’s own 175 MP ceiling can be consulted, and raisingImage.MAX_IMAGE_PIXELSfrom a worker thread would disable bomb protection process-wide for every other decode in the process. Those images are recorded as failures, so they stay enumerable, and they keep returning Thumbor’s 400.Chore:
uv.lockis now in.gitignore. The absent lockfile is deliberate, butuv runwrites one on every invocation andcheck-added-large-filescaught it at 527 KB. Note that a plainuv runalso re-syncs the environment againstpyproject.toml, silently undoing a localplone.namedfile < 8pin; useUV_NO_SYNC=1 uv run pytestwhen the pin matters.Docs: new how-to guides for choosing the cap and for running the backfill, a source derivatives section in the architecture explanation, and the “Pillow is never imported, never invoked” claim scoped to the request path, where it stays true.
Bump
hynek/build-and-inspect-python-packagefrom v2 to v3.0.1. Hatchling now emitsMetadata-Version: 2.5, which the Twine bundled in v2 rejects withInvalidDistribution: '2.5' is not a valid metadata version— the release build failed before uploading anything. v3 ships Twine 7, which supports it.Add the
LICENSEfile with the full GNU General Public License v2.0 text. The packaging metadata already declaredGPL-2.0-only, but the license text itself was missing from the repository, so GitHub reported “No license” and the terms could not be verified from the source tree alone. Fixes #24.Support the
scalemode semantics bug from plone.scale < version 6. The scale mode names are opposite to the corresponding CSSobject-fitproperty names. We expect that to be fixed in version 6. [thet]Add
cloud-vinylandplone.observabilityto the ecosystem navigation dropdown in the docs.Fix:
_heal_legacy_uidnow recovers the scale a uid was minted for instead of guessing one from its width. The uid’s md5 covers the whole parameter set plus the field’s modification time, so the candidates are enumerated and re-hashed withplone.scale’s ownhash_keyuntil one matches. That identifies the mode rather than assuming"scale", tells two registered scales sharing a width apart (Haeuser 400:200used to heal aspreview 400:0), and resolves height-driven0:Hscales, which used to heal into a request at the original’s dimensions and could push Thumbor pastMAX_PIXELS.Matching only works once the modification time is reconstructed:
publishTraverseadapts(context, None), so the storage’smodified_timeisNoneat healing time while the uid was hashed against the field’s modification time. A uid older than the image’s last modification cannot be identified at all and falls back to the first registered scale of that width. The original’s dimensions are requested only for the one case where that fallback has no other reading: a uid with no width at all, and no0:Hscale registered.Closes #21.
Fix: a healed uid for a scale with both dimensions set now recovers the scale’s name instead of
scale=None, so_get_cropstill finds the configured crop.hash_keydrops thescalekey whenever width and height are both truthy, so a named call (tag(scale="Haeuser")) and theimage_scalesindexer’sscale=Nonecall mint the identical uid; healing could not tell them apart and previously assumed the uncropped one.Fix: healing no longer reads the field’s image size before any candidate is hashed.
NamedBlobImage.getImageSize()lazily assigns_width/_heighton first call, which registers a ZODB write on aPersistentobject — reachable by an unauthenticated GET with an attacker-chosen uid._original_sizeis now consulted only once every registry-derived candidate has already failed to match, which is also the common case, so the successful healing path no longer computes the image size twice either.Fix: the scale mode now reaches the generated Thumbor URL.
plone.scalekeepsmodeininfo["key"]and never copies it into the info dict, soinfo.get("mode", "scale")always read"scale"and every URL was built withfit_in— acontainscale got an<img>tag claiming the cropped box and an image fitted inside it instead. Bothplone.namedfilecode paths and the HiDPIsrcsetattribute are fixed. Forward-compatible with plone/plone.scale#156, which addsmodeto the info dict upstream.Tests: pin
scale_mode_to_thumboragainst realscalePILImageoutput. The mapping compensates forplone.scale’s inverted mode names behind aplone.scale < 6gate, and until the fix above it was only ever reached with"scale", so the other two branches were unobservable.Tests: cover the mode-threading fix on the two call sites that had none:
srcset_attribute, whose argument is acalculate_srcsetentry rather than a full info dict, andThumborImageScaling._scale_url.Docs: fix the “Scale modes” tables in
README.md,docs/sources/explanation/why-thumbor.md, anddocs/sources/reference/url-format.md, which described the inverse of live behaviour.plone.scale’s own mode names are the reverse of what they describe;scale_mode_to_thumborcompensates for that, and oncemodestarted reaching the Thumbor URL the tables’ error stopped being harmless.
0.6.5 (2026-08-04)#
Fix: SVG (skip-Thumbor) images no longer emit uid-based scale URLs that permanently 404. Root cause was not
purge_scalesbut the volatileThumborScaleStorageintroduced in 0.6.x:get_or_generatereads a fresh empty per-instance dict on every traversal, so no uid scale URL could ever resolve — theplone.scaleannotation is never consulted. Fixed on both ends: skip-types now emit the original field URL with a modification-time cache buster (both plone.namedfile code paths, the legacy__init__for 7.x and_scale_urlfor >= 8.0.0a2), the HiDPIsrcsetattribute and thesrcset()method emit Thumbor URLs, andget_or_generateheals legacy uid URLs (cached HTML, staleimage_scalescatalog metadata) by parsing the deterministic{fieldname}-{width}-{md5}uid and regenerating the info on the fly — restricted to widths registered inplone.allowed_sizes. Review hardening on top: srcset() mirrors the parent’s edge-case guards (zero-size original, original-size back-fill, unresolvable src scale), and the HiDPI srcset path threads crop info through for scale infos that carry a scale name. Closes #17.Add
cdk8s-ploneto the ecosystem navigation dropdown in the docs.Chore: apply ruff 0.16 markdown code-fence formatting to four docs files (pre-existing drift; the QA workflow runs the latest ruff via uvx over the whole repo). Mark up
zope2.Publicas inline code in a security-doc heading so vale’s Microsoft.Spacing rule no longer trips on it.
0.6.4 (2026-04-20)#
Fix:
_needs_auth_url()no longer issues a PostgreSQL query per image. The old implementation looked upallowed_rolesinobject_statevia a request-scoped pool connection, which saturated the per-pod psycopg pool under cold-cache production load (30 thumbnails per listing page × concurrent anonymous requests = 30 sPoolTimeoutstacks). Replaced with an in-memoryrolesForPermissionOn("View", context)lookup — the plone-pgcatalogallowed_rolescolumn is a cache of exactly this computation, so the SQL was re-asking a question Zope already knew the answer to. Zero DB round-trips, zero pool pressure, no catalog-lag skew vs. live workflow state. Closes #8.Fix:
@thumbor-authREST service now prefers the ZODB storage connection (already held for the request) over the psycopg pool, so per-image auth verification doesn’t contend onpool.getconn(). The SQL query is unchanged — this is strictly a connection-acquisition change, matching the pattern plone-pgcatalog uses in_get_pg_read_connection. Falls back to the pool when no ZODB storage is in scope (tests, scripts). Related to #8.
0.6.3 (2026-04-13)#
Move
@@imagesout of overrides, it is on a layer.
0.6.2 (2026-04-10)#
Fix: access-check queries now use the dedicated
allowed_rolesTEXT[] column instead ofidx->'allowedRolesAndUsers'.plone-pgcatalogextractsallowedRolesAndUsersinto its own column, so the old JSONB lookup returnedNULLfor every migrated object — making_needs_auth_url()always returnTrue(broken anonymous images) and@thumbor-authalways return401for 3-segment URLs. Affects both_needs_auth_urlinscaling.pyandThumborAuthServiceinrestapi.py. Closes #5.Docs: the Sphinx reference changelog is now a MyST include of the root
CHANGES.md, removing the stale hand-maintained copy.
0.6.1 (2026-04-03)#
Fix:
IImageScaleStorageadapter registration now uses*as second discriminator instead ofIPlonePgthumborLayer. The adapter lookup inplone.namedfilepasses amodifiedcallable (not a request), so the layer-based registration never matched — all scales still used the defaultAnnotationStorage. Closes #4.
0.6.0 (2026-04-03)#
Fix:
ThumborScaleStorageno longer writesScalesDictto ZODB. Thestorageproperty now returns a volatile (non-persistent) dict, eliminating constant write transactions frompre_scale(). Closes #3.
0.5.0 (2026-04-02)#
Remove
server_url,security_key, andunsafefrom controlpanel and registry. These settings are configured exclusively via environment variables (PGTHUMBOR_SERVER_URL,PGTHUMBOR_SECURITY_KEY,PGTHUMBOR_UNSAFE).Controlpanel now shows env-var configuration hint in the description.
Upgrade step (v2 -> v3) deletes orphaned registry records from existing sites.
Purge button uses alert styling.
Closes #2.
0.4.0 (2026-04-02)#
Add browser layer
IPlonePgthumborLayerand bind all views, services, and adapter overrides to it. This enables clean uninstall via GenericSetup: removing the layer deactivates all registrations.Add uninstall profile (removes browser layer and control panel configlet).
0.3.0 (2026-03-10)#
Wire
smart_croppingandparanoid_modefrom env vars / Plone registry into Thumbor URL generation.Add
_scale_urloverride for upcoming plone.namedfilescale_infosupport, with backward compatibility for current releases.Simplify dev setup: run Plone locally, Docker only for postgres/thumbor/nginx.
0.2.0 (2026-03-07)#
Add
@@thumbor-purge-scalesview andzconsole run -mscript to remove legacy ZODB image scales and reindeximage_scalesmetadata after installation.
0.1.0#
Initial implementation: Thumbor URL generation for Plone image scales.