Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
50c039e
docs(adr): scope the resizer cache key by the source's upload path
parisek Aug 26, 2026
de0cca3
feat(resizer): derive a source's upload directory from its URL
parisek Aug 26, 2026
0e1fd27
feat(resizer): scope the cache key by the source's upload directory
parisek Aug 26, 2026
16e9315
feat(starterbase): expose the resizer source-path cache key flag
parisek Aug 26, 2026
17702d6
feat(cli): add migrate-image-cache for the source-path cache key
parisek Aug 26, 2026
1925a6a
fix(cli): re-check the migrate-image-cache target before rename and g…
parisek Aug 26, 2026
0b246e9
fix(media): address cached derivatives instead of scanning for them
parisek Aug 26, 2026
cd4f7c3
fix(media): address cached derivatives instead of scanning for them
parisek Aug 26, 2026
6fd148d
docs(changelog): record the resizer source-path cache key
parisek Aug 26, 2026
fa2c052
fix(image-cache-migrator): stop dropping guard-rejected directories a…
parisek Aug 26, 2026
d8db562
docs(image-cache-migrator): note the backslash input-domain and drop …
parisek Aug 26, 2026
dc1bf05
docs(adr): key the derivative by the source's whole identity, extensi…
parisek Aug 27, 2026
d938135
fix(resizer): key cache derivative on source filename including exten…
parisek Aug 27, 2026
60167ac
fix(resizer): decode the URL filename before keying the cache
parisek Aug 27, 2026
d6ddbc7
fix(migrator): classify an already-migrated root upload before the le…
parisek Aug 27, 2026
c3775b8
docs(adr): correct 0007's description of the old key and the new one
parisek Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,40 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

## [Unreleased]

### Added

- `StarterBase::$resizer_source_path_in_cache_key` (default `false`) keys the
resizer cache on the source's whole identity — its upload directory *and* its
own filename, extension included — so two uploads that share a name stop
sharing one cached derivative. Without it the derivative is named for the
source's directory-less, extension-less stem alone, so `2022/03/11.png` and
`2022/10/11.png` collide on directory, and `hero.jpg` and `hero.png` in one
directory collide on extension. Measured on a five-language production site:
496 names map to more than one source file, and 152 of those collide inside a
single directory (same name, different extension).

Enabling it relocates every derivative whose source is below a year/month
directory, or whose source shares a directory and stem with another upload of
a different extension; `wp timber-kit migrate-image-cache` (dry-run by
default) moves the existing cache into the new shape rather than re-encoding
it. What it resolves: directory collisions and same-directory
different-extension collisions, both under the flag. What it does not
resolve: a flat legacy derivative whose old (extension-less) name maps to more
than one distinct source path is reported as ambiguous and left unmigrated —
recovering which source it came from is exactly what the flat layout
destroyed — and is re-encoded correctly on first view instead. A root upload
(no year/month directory) still needs migrating, because its target name now
carries the source's own extension too (`hero.avif` becomes `hero.png.avif`);
only a root upload whose source filename has no extension of its own
produces a byte-identical path, and needs no migration.

With the flag on, `cleanup_cached_images()` addresses derivatives by path
instead of matching basenames across the tree, so deleting an attachment can
no longer reach another upload's images — including one differing only by
extension in the same directory.

Decision and alternatives: `docs/adr/0007-resizer-source-path-cache-key.md`.

### Fixed

- `StarterBase::cleanup_cached_images()` deleted resizer derivatives that other
Expand All @@ -31,10 +65,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
the same zero a genuine "no siblings" answer gives, so the null and
`last_error` are both checked rather than read as a count.

Not fixed here, and tracked separately: two different files that share a
basename across upload-year folders still collide in the flat cache
namespace, so deleting one can remove the other's derivative. That needs a
cache-naming change, not a guard.
Not fixed here: two different files that share a basename across upload-year
folders, or within one directory but differing only by extension, still
collide in the flat cache namespace, so deleting one can remove the other's
derivative. That needed a cache-naming change, not a guard — see
`$resizer_source_path_in_cache_key` above, opt-in for exactly that reason.

- `wp timber-kit migrate-image-cache --apply` could silently overwrite a
derivative: it checked the target didn't exist, then `rename()`d into it, and
a target created in the gap between those two steps — a second invocation, a
file dropped by hand — was replaced without warning by POSIX `rename()`. It
now moves via `link()` then `unlink()`: `link()` fails atomically when the
target exists, so a race there is reported as a failed move with both files
left untouched, never a silent overwrite.

- `wp timber-kit migrate-image-cache --apply` reported success even when some
moves failed — `WP_CLI::success()` ran unconditionally, before the loop that
warns about failures, so a script harness reading only the exit code saw 0 on
a run that left files unmoved. It now warns about every failure first, then
exits non-zero via `WP_CLI::error()` when any move failed, and calls
`WP_CLI::success()` only when none did.

## [1.42.0] - 2026-08-26

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ of these went undocumented for several releases.
| `wp timber-kit acfml-sync-preferences` | Reconciles WPML translation preferences for programmatically written ACF meta. See § ACFML preference sync. |
| `wp timber-kit wpml-cleanup-theme-domain` | Purges WPML String Translation rows and compiled files left behind for a text domain that is no longer registered with ST. See § WPML theme-domain cleanup. |
| `wp timber-kit outage-screen` | Installs the drop-ins that serve the theme's prerendered outage screen. See § Outage screen. |
| `wp timber-kit migrate-image-cache` | Moves existing resizer cache derivatives into the source-path layout, ahead of enabling `$resizer_source_path_in_cache_key`. Dry-run by default, `--apply` to write. |

---

Expand Down Expand Up @@ -1096,6 +1097,7 @@ Available hooks:
- `timber_kit_resizer_remote_variant_probe_timeout` — HTTP timeout for variant probes, default `2.0`
- `timber_kit_resizer_remote_variant_probe_limit` — max remote variant probes per request, default `50`
- `timber_kit_resizer_quality_in_cache_key` — put a variant's quality in its cache key, default `false` (also settable as `StarterBase::$resizer_quality_in_cache_key`). Without it, re-cutting the same dimensions at a different quality serves the previously generated file. Opt-in because switching it on relocates every non-default-quality variant: old cache files orphan and public URLs change.
- `timber_kit_resizer_source_path_in_cache_key` — key the resizer cache on the source's whole identity, default `false` (also settable as `StarterBase::$resizer_source_path_in_cache_key`): its upload directory *and* its own filename, extension included. Without it, two uploads that share a directory-less, extension-less stem occupy one cache path and whichever renders first decides what the other one shows — that covers both `2022/03/11.png` vs. `2022/10/11.png` (directory) and `hero.jpg` vs. `hero.png` in one directory (extension). Opt-in because switching it on relocates every derivative whose source is not at the uploads root, or whose source shares a directory and stem with a different-extension upload: old cache files orphan and public URLs change. Run `wp timber-kit migrate-image-cache` to move them instead of re-encoding them — a flat legacy derivative whose old name maps to more than one distinct source is reported as ambiguous and left in place, since recovering which source it came from is exactly what the flat layout destroyed.
- `timber_kit_resizer_aspect_tolerance` — tolerance band around 1:1 used by `Resizer::classifyAspect()` to decide whether a source qualifies as `square`, default `0.1`. Returning a smaller value (e.g. `0.05`) tightens the square band; returning a larger value (e.g. `0.2`) loosens it.

### Google Tag Manager
Expand Down
139 changes: 139 additions & 0 deletions docs/adr/0007-resizer-source-path-cache-key.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# 0007. Scope the resizer cache key by the source's upload path

## Context

`Resizer` writes every derivative to `wp-content/cache/image/<W>x<H>-<style>/<name>.<fmt>`,
where `<name>` is `pathinfo( basename( $src ), PATHINFO_FILENAME )` run through
`sanitize_file_name()` — the source file's name with its directory *and* its
own extension thrown away. Uploads keep their directory: WordPress files them
under `uploads/<year>/<month>/`.

So the cache namespace is flatter than the namespace it caches — flatter twice
over, because the derivative is named for the source *without its extension*, so
`11.png` and `11.jpg` land on one path as surely as two `11.png` in different
months do. Measured on a five-language production site: **496 names map to more
than one source file, and 152 of those collide inside a single directory** —
same name, different extension. The two axes are independent, and a fix that
addresses only one leaves the other exactly as it was.

```
900x0-center/11.avif <- 2022/03/11.png
<- 2022/04/11.png
<- 2022/08/11.png
<- 2022/10/11.png
```

Whichever renders first writes the file; the rest read it and get a picture of
something else. Nothing errors, and the derivative is a plausible image, so the
failure is invisible until somebody recognises the wrong photograph on a page.

The same flatness reaches the delete path. `StarterBase::cleanup_cached_images()`
cannot address a derivative, so it scans the tree and matches basenames — which
is why deleting one attachment could remove another's images.

Three shapes were considered. Putting the segment **above** the size directory
(`<year>/<month>/<size>/<name>`) groups a month together and makes "purge March
2022" one delete, but turns "drop a discontinued breakpoint" into a tree scan;
today the trade runs the other way, and nothing here purges by month. Folding
the path **into the filename** (`2026-08-hero.avif`) adds no directories but lets
a derivative's name collide with a genuinely uploaded `2026-08-hero.png` —
trading one silent collision for a subtler one.

## Decision

Key the derivative by the source's **whole identity** — its directory below the
uploads root, and its full filename including the source's own extension:

```
<W>x<H>-<style>[-q<N>]/<source-relative-dir>/<source-filename>.<fmt>
```

So `uploads/2026/08/hero.webp` at 900px wide becomes
`900x0-center/2026/08/hero.webp.avif`.

Both halves are load-bearing and neither substitutes for the other. The
directory separates `2022/03/11.png` from `2022/10/11.png`; the retained source
extension separates `hero.jpg` from `hero.png` in one directory. Dropping either
half leaves one of the two collision axes untouched — and the 152 same-directory
collisions measured above are the half that is easy to forget, because adding a
directory *feels* like it has separated everything.

It is derived from the source's own URL, not `$source_path`: the directory half
must be resolvable before `resize()` knows whether the local file exists (a
missing source is served by a filter that addresses the same cache path on
another host), and the filename half must agree with what
`StarterBase::cached_derivative_paths_by_source_path()` and the migration
command compute from `_wp_attached_file`. Both halves parse the URL the same
way: strip the query string, decode each `%`-encoded component, then run it
through `sanitize_file_name()` — a URL is encoded where the database value is
not, so skipping the decode step would make the writer and the deleter/migrator
name the same source differently. `Resizer` maps a URL to a path only through
the uploads base pair, so every source it caches lies below the uploads root
and the directory segment is always well defined — possibly empty, never
absent. A theme-directory image does not resolve and never reaches the cache.

The behaviour ships behind `StarterBase::$resizer_source_path_in_cache_key`
(filter `timber_kit_resizer_source_path_in_cache_key`), default `false`,
alongside the `$resizer_quality_in_cache_key` flag it mirrors.

The cache root stays `wp-content/cache/image`. A new
`wp timber-kit migrate-image-cache` moves existing derivatives into the new
shape in place, dry-run by default.

## Consequences

**Enabling the flag invalidates every cached derivative**, which is why it
defaults off and why the change is not simply applied. On a site with rewritten
uploads this is thousands of files; regenerating them costs encoder time, and on
a host whose encoder is defective it costs correctness. The decision is the
project's to take on its own schedule, not a consequence of `composer update`.

Where a site has year/month folders switched off (a root upload), the relative
directory is empty, but the path is *not* generally byte-identical to the
current one: the target name still gains the source's own extension
(`hero.avif` becomes `hero.png.avif`), so a root upload needs migrating too.
The one exception is a root upload whose own filename already carries no
extension — there the target and the current flat name coincide, and the test
suite pins exactly that case, not the broader one.

`<source-relative-dir>/<source-filename>` is `_wp_attached_file` in full,
extension included — not the extension-stripped form the flat layout used.
A derivative's path therefore becomes **derivable from the database** rather than
discoverable by scanning, which lets `cleanup_cached_images()` address the files
it means to delete instead of matching names. The sibling-attachment guard stays:
one file can carry several attachment rows under WPML, and that is independent
of how the path is spelled.

The key is built from `sanitize_file_name()`'s output, which is the *sanitized*
identity of a source, not a guarantee of a unique one: two names differing only
in characters that function strips (`hero[1].jpg` and `hero1.jpg`, in
principle) would collide on one cache key. This is not engineered around — no
hash, no second key component — because it does not occur in measured data:
zero occurrences of `[` or `]` in `_wp_attached_file` on the production site
surveyed for this ADR, and WordPress itself runs uploads through
`sanitize_file_name()` at the point of upload, so a stored value is normally
already sanitized before this code ever sees it.

A note on how this decision was reached, because it is the point of the
practice rather than a detail of it: the first version of this ADR added only
the directory, and seven review rounds passed it. The same-directory half was
found by an independent reviewer on a different model, reading the branch
without sight of those rounds — the one thing the repo's own two-reviewer rule
asks for and the one thing that run had skipped.

The migration cannot place a derivative whose name maps to more than one
source — recovering that association is exactly what the old layout destroyed.
Those files are reported and left alone; the new path is empty for them, so they
are re-encoded on first view. The command moves rather than copies (no transient
doubling of the cache), and is idempotent, so an interrupted run is resumed by
running it again — including for a root upload, whose migrated derivative
lands at the same depth in the size directory as an unmigrated legacy one, and
so is told apart from it by matching against the source's own full name rather
than by where it sits in the tree.

The added segment is derived from a URL, so it inherits the path-traversal
duty that `sanitize_file_name()` already discharges for the filename. That
protection has to widen to cover it.

`Helpers::resizeImage()` — the legacy path predating `Resizer` — keeps the flat
layout. It is not migrated and not flagged.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,4 @@ guard (test, CI check, convention) that keeps it from drifting, if any.
- [0004](0004-image-downscaling-via-core-threshold.md) — Drive downscaling through core's threshold; never delete originals on upload
- [0005](0005-first-party-gtm-container.md) — Load the GTM container from the kit, configured in code
- [0006](0006-warmup-priority-precomputed-at-refresh.md) — Precompute warmup priority at refresh, never at purge
- [0007](0007-resizer-source-path-cache-key.md) — Scope the resizer cache key by the source's upload path
Loading
Loading