[6.x] Hybrid Glide image caching - #14570
Conversation
|
🔥 |
jasonvarga
left a comment
There was a problem hiding this comment.
Hybrid approach looks great overall — one bug to address before merging (inline). A few smaller notes I'll leave as a follow-up comment so they don't block.
When `itemType()` returns 'id', the item is already a valid asset ID
like `container::folder/file.jpg`. The str_replace was incorrectly
converting `/` to `::`, producing invalid IDs like
`container::folder::file.jpg`.
This caused `Assets::find()` to return null, leading to fatal errors
when building hybrid URLs via `Image::manipulate('container::path')`.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
Did a bit of a more thorough review and this introduces a security issue in its current state. I've marked it as draft so we don't merge prematurely. |
Changes were made but there's more to do.
|
Ooo really looking forward to this one! 🔥 |
…hing # Conflicts: # src/Imaging/ImageGenerator.php
jasonvarga
left a comment
There was a problem hiding this comment.
The hybrid design itself is solid — the clean URL is only generatable from a mapping the server wrote during rendering, so the mass-resize vector is closed for hybrid's own URLs. I checked this out and exercised it; the inline comments are the things that need fixing before this can merge. One is a page-breaking crash, the rest are correctness/security gaps in the surrounding routes.
Non-blocking, but worth doing while you're in here:
generateOnDemand()passes the raw request path toGlide::cacheDisk()->exists(). Paths like/img/../../.envthrowLeague\Flysystem\PathTraversalDetected(correctly blocked, no disclosure) but surface as a 500 rather than a 404. Catch it and 404.- Direct serving only works when
routematches the public-relativecache_path(img/public_path('img')), and nothing checks that. If someone changes one, every image request silently hits PHP forever. Worth a docs note or a boot-time warning. - A published hybrid URL is only resolvable while its mapping survives in the Glide cache store. On a statically cached site, losing
storage/framework/cache/glide(e.g. a container redeploy) while the HTML is retained means those images 404 permanently. Docs note at minimum. resolveForUrl()and the'url'mapping branch have no tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt
jasonvarga
left a comment
There was a problem hiding this comment.
These nine commits address everything from the last review — I re-checked each one against the code and tests and all five are properly fixed, not papered over:
b0b8138resolvesiditems once and throwsAssetNotFoundException, whichTags\Glidecatches, so the missing-asset page crash is gone.56f1f5bdrops the/assetand/httproutes in hybrid mode entirely, which closes the mass-resize vector independently ofsecure.a1de853shares themarkencoding and hashesImageGenerator::watermarkParam()on both sides. I traced all three watermark shapes (Asset, remote URL, plain path) and they agree now.8fe77e4splits the validator correctly. I checked everyRemoteUrlValidatorcall site —ImageGenerator::parseUrl()andGuzzleAdapterboth still resolve before fetching, so there's no SSRF regression against #14761.aa44e90dropssecure => false, so the tests now cover the shipped default.
The non-blocking notes are handled too (773e2eb, 0088bae, ff8fb4b).
I did find three more things, and they're all the same bug wearing different hats: the predicted path and the path the generator actually writes to diverge. Each one fails silently — the response is correct, so nothing looks broken, but the file never lands where the browser asked for it, the web server can never serve it, and every request boots PHP forever. That's exactly the watermark bug again.
Which is the real point here: a1de853 fixed watermarks specifically rather than fixing the class. I think this wants a test helper that asserts resolveFor*() equals what ImageGenerator writes, driven over a table of inputs — asset, path, remote URL, watermark, video, prefixed site. That would have caught all three of these and would stop the next one. Right now nothing in the suite checks the invariant the whole design rests on.
Non-blocking:
- Changing
image_manipulation.defaultsor a preset between render and request re-points the hash, so the requested file never appears and the image is re-manipulated on every subsequent request. It self-heals on the next render, so it mostly doesn't matter — except on a statically cached site, where the stale HTML keeps the old URL indefinitely. Worth a docs line about clearing static caches after changing image defaults. - The boot warning added in
0088baefires on every request and every artisan command while misconfigured, not once. Same log-noise shape as the existing "rewrite rules" debug line. ImageGenerator::watermarkParam()is typed: string, butdecodeWatermark()returnsAssets::find(...), which isnullif the watermark asset was deleted between render and generation. That's aTypeErrornow where it used to degrade to an unmarked image. Edge case, image route only, and the old behaviour was also wrong.- Unrelated to the above: video assets 500 on the glide route when ffmpeg isn't available, because
generateByAsset()returns''andcreateResponse('')resolves to the cache root. I measured the same 500 oncache: false, so it's pre-existing and not yours to fix — just noting it while we're in here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a site's path prefix no longer ends up in hybrid urls, since the cache path is shared by all sites. the hybrid route is registered once without a site prefix, so it's still reachable when every site has one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks Jason! Agreed on the root cause, so I've added the test you suggested alongside the fixes. Blocking
Non-blocking
|
The log line was copied from FileCacher, where rewrite rules genuinely are needed because the static cache URL and its file path differ. Hybrid's URL is the file path, so every stock Laravel front controller already serves it without PHP and there is nothing to configure. Reworded to describe the symptom instead of blaming a setup step that does not exist, and raised from debug to warning so it is visible in production, where LOG_LEVEL is usually above debug. Throttled with the same cache store flag cf0ac87 uses for the boot-time warning, since this fires per request per image and would otherwise flood the log on exactly the misconfiguration it is reporting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hing Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This pull request implements a new "hybrid" caching strategy for Glide image manipulation.
The Problem
Statamic currently offers two Glide caching modes:
cache: false) — Fast template rendering, but every image request hits PHP, even after the image has already been generated.cache: true) — Images are served directly by the web server, but they're all eagerly generated during template rendering. A page with 10 images × 11 variants = 110 images blocking the page from even loading.Hybrid Caching
Setting
cacheto'hybrid'enables a new mode that combines the best of both:The Glide tag outputs a URL pointing to where the cached image will live, but doesn't generate the image during template rendering. When the browser requests the image:
cache_path, and serves it.After the first request, the web server serves the static file directly on all subsequent requests. The default Nginx/Apache/IIS configurations handle this automatically — no additional rewrite rules are needed.
The
cache_pathmust be theroutedirectory insidepublic/(e.g.public/imgfor theimgroute), otherwise the generated files are never reachable by the web server. A warning is logged once if they don't line up.How It Works
GlideCachePathResolverpredicts the deterministic cache path for an image without generating it. This is the same path the generator writes to, so hybrid URLs are identical to the onescache: trueproduces for the same image.HybridUrlBuilderbuilds URLs from that cache path, and stores a mapping (hybrid::<cache path>→ source + params) in the Glide cache store so the controller knows how to generate the image later. Mappings for assets are added to the asset's existing manifest.{route}/{path}route. TheGlideControllerhandles the PHP fallback: if the file already exists it serves it (logging a one-time warning, since the web server should have served it), otherwise it looks up the mapping and generates the image on demand. Unknown paths 404.php please glide:cleardeletes the files and flushes the mappings.RemoteUrlValidator::parse()no longer resolves DNS, so remote URL cache paths can be computed during rendering without a lookup. The public IP check now lives inresolve()/validate(), and everything that actually fetches a remote image still calls it first.Notes
{{ glide:generate }}still generates eagerly, since it needs the image's dimensions.cache: falseto'hybrid'changes image URLs (as switching totruedoes), so old/img/asset/…and/img/http/…URLs will 404. Switching fromtruekeeps the same URLs.glide:clear.Closes statamic/ideas#1420
Docs PR: statamic/docs#1899