From 2e3d8faaab3caec120b7ee9ab9966adf9ffe3250 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 29 Apr 2026 12:55:31 +0100 Subject: [PATCH 01/24] wip --- config/assets.php | 13 +- routes/routes.php | 2 +- src/Facades/Glide.php | 1 + src/Http/Controllers/GlideController.php | 67 ++++++- src/Imaging/GlideCachePathResolver.php | 91 ++++++++++ src/Imaging/GlideManager.php | 17 +- src/Imaging/HalfMeasureUrlBuilder.php | 61 +++++++ src/Imaging/ImageGenerator.php | 40 +++-- src/Providers/GlideServiceProvider.php | 12 ++ tests/Imaging/GlideTest.php | 217 +++++++++++++++++++++++ 10 files changed, 500 insertions(+), 21 deletions(-) create mode 100644 src/Imaging/GlideCachePathResolver.php create mode 100644 src/Imaging/HalfMeasureUrlBuilder.php diff --git a/config/assets.php b/config/assets.php index ddeab7d3b36..811cebc4396 100644 --- a/config/assets.php +++ b/config/assets.php @@ -46,9 +46,16 @@ | Save Cached Images |-------------------------------------------------------------------------- | - | Enabling this will make Glide save publicly accessible images. It will - | increase performance at the cost of the dynamic nature of HTTP based - | image manipulation. You will need to invalidate images manually. + | This controls how manipulated images are cached and served. + | + | false - Images are generated on each HTTP request via Glide routes. + | true - Images are eagerly generated during template rendering and + | saved to a publicly accessible location. + | 'half' - Images are generated on-demand on the first HTTP request, + | then saved to a publicly accessible location so the web + | server can serve them directly on subsequent requests. + | + | When using true or 'half', you should configure the cache_path below. | */ diff --git a/routes/routes.php b/routes/routes.php index 31a12883ff2..97bee65deb5 100644 --- a/routes/routes.php +++ b/routes/routes.php @@ -29,7 +29,7 @@ }); } -if (Glide::shouldServeByHttp()) { +if (Glide::shouldServeByHttp() || Glide::isUsingHalfMeasureCaching()) { require __DIR__.'/glide.php'; } diff --git a/src/Facades/Glide.php b/src/Facades/Glide.php index 70f97521a1e..e044f7b2aa7 100644 --- a/src/Facades/Glide.php +++ b/src/Facades/Glide.php @@ -10,6 +10,7 @@ * @method static \Illuminate\Contracts\Filesystem\Filesystem cacheDisk() * @method static bool shouldServeDirectly() * @method static bool shouldServeByHttp() + * @method static bool isUsingHalfMeasureCaching() * @method static string route() * @method static string url() * @method static \Illuminate\Contracts\Cache\Repository cacheStore() diff --git a/src/Http/Controllers/GlideController.php b/src/Http/Controllers/GlideController.php index 62603b32247..cafc1eefdf2 100644 --- a/src/Http/Controllers/GlideController.php +++ b/src/Http/Controllers/GlideController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; use League\Flysystem\UnableToReadFile; use League\Glide\Server; use League\Glide\Signatures\SignatureException; @@ -12,6 +13,7 @@ use Statamic\Facades\Asset; use Statamic\Facades\AssetContainer; use Statamic\Facades\Config; +use Statamic\Facades\Glide; use Statamic\Facades\Site; use Statamic\Imaging\ImageGenerator; use Statamic\Support\Str; @@ -51,6 +53,10 @@ public function __construct(Server $server, Request $request, ImageGenerator $ge */ public function generateByPath($path) { + if (Glide::isUsingHalfMeasureCaching()) { + return $this->generateOnDemand($path); + } + $this->validateSignature(); // If the auto crop setting is enabled, we will attempt to resolve an asset from the @@ -79,6 +85,63 @@ public function generateByUrl($url) return $this->createResponse($this->generateBy('url', $url)); } + /** + * Generate an on-demand image for the half-measure caching strategy. + * + * The URL path is the predicted cache path. Query parameters contain + * the source identifier and manipulation parameters needed to generate. + */ + private function generateOnDemand(string $path) + { + $this->validateSignature(); + + if (Glide::cacheDisk()->exists($path)) { + Log::debug('Glide half-measure cache loaded ['.$path.'] If you are seeing this, your server rewrite rules have not been set up correctly.'); + + return $this->createResponse($path); + } + + $params = collect($this->request->all()) + ->except(['asset', 'url', 'src', 's']) + ->all(); + + if ($encoded = $this->request->query->get('asset')) { + $decoded = Str::fromBase64Url($encoded); + + [$container, $assetPath] = explode('/', $decoded, 2); + + throw_unless($container = AssetContainer::find($container), new NotFoundHttpException); + + throw_unless($asset = $container->asset($assetPath), new NotFoundHttpException); + + return $this->createResponse($this->ensureGenerated('asset', $asset, $params)); + } + + if ($url = $this->request->query->get('url')) { + return $this->createResponse($this->ensureGenerated('url', Str::fromBase64Url($url), $params)); + } + + if ($src = $this->request->query->get('src')) { + return $this->createResponse($this->ensureGenerated('path', $src, $params)); + } + + throw new NotFoundHttpException; + } + + /** + * Forget any stale cache store entry, then generate the image. + * + * In half-measure mode, the file on disk is the source of truth. + * If we're here, the file doesn't exist, so the cache store + * entry (if any) is stale and should be cleared first. + */ + private function ensureGenerated(string $type, $item, array $params) + { + Glide::cacheStore()->forget(ImageGenerator::manipulationCacheKey($type, $item, $params)); + + return $this->generateBy($type, $item, $params); + } + /** * Generate a manipulated image by an asset reference. * @@ -108,12 +171,12 @@ public function generateByAsset($encoded) * * @return mixed */ - private function generateBy($type, $item) + private function generateBy($type, $item, ?array $params = null) { $method = 'generateBy'.ucfirst($type); try { - return $this->generator->$method($item, $this->request->all()); + return $this->generator->$method($item, $params ?? $this->request->all()); } catch (InvalidRemoteUrlException $e) { abort(400, $e->getMessage()); } catch (UnableToReadFile $e) { diff --git a/src/Imaging/GlideCachePathResolver.php b/src/Imaging/GlideCachePathResolver.php new file mode 100644 index 00000000000..62d897233b1 --- /dev/null +++ b/src/Imaging/GlideCachePathResolver.php @@ -0,0 +1,91 @@ +resolve( + $asset->basename(), + $params, + sourcePathPrefix: $asset->folder(), + cachePathPrefix: ImageGenerator::assetCachePathPrefix($asset).'/'.$asset->folder(), + asset: $asset, + ); + } + + public function resolveForPath(string $path, array $params): string + { + return $this->resolve( + $path, + $params, + sourcePathPrefix: '/', + cachePathPrefix: 'paths', + ); + } + + public function resolveForUrl(string $url, array $params): string + { + $parsed = app(RemoteUrlValidator::class)->parse($url); + $qs = $parsed['query']; + $path = $parsed['path'].($qs ? '?'.$qs : ''); + + return $this->resolve( + $path, + $params, + sourcePathPrefix: '/', + cachePathPrefix: 'http', + ); + } + + public function resolveForItem($item, array $params): string + { + if ($item instanceof Asset) { + return $this->resolveForAsset($item, $params); + } + + if (is_string($item) && Str::contains($item, '::')) { + $asset = Assets::find($item); + + if ($asset) { + return $this->resolveForAsset($asset, $params); + } + } + + if (is_string($item) && URL::isAbsolute($item)) { + return $this->resolveForUrl($item, $params); + } + + return $this->resolveForPath($item, $params); + } + + private function resolve(string $image, array $params, string $sourcePathPrefix, string $cachePathPrefix, ?Asset $asset = null): string + { + $origSourcePrefix = $this->server->getSourcePathPrefix(); + $origCachePrefix = $this->server->getCachePathPrefix(); + $origDefaults = $this->server->getDefaults(); + + $this->server->setSourcePathPrefix($sourcePathPrefix); + $this->server->setCachePathPrefix($cachePathPrefix); + $this->server->setDefaults(ImageGenerator::getDefaultManipulations($asset)); + + try { + return $this->server->getCachePath($image, $params); + } finally { + $this->server->setSourcePathPrefix($origSourcePrefix); + $this->server->setCachePathPrefix($origCachePrefix); + $this->server->setDefaults($origDefaults); + } + } +} diff --git a/src/Imaging/GlideManager.php b/src/Imaging/GlideManager.php index 5909be7a079..9e6e6d113ea 100644 --- a/src/Imaging/GlideManager.php +++ b/src/Imaging/GlideManager.php @@ -49,7 +49,9 @@ public function cacheDisk() private function wantsCustomFilesystem() { - return is_string(Config::get('statamic.assets.image_manipulation.cache')); + $cache = Config::get('statamic.assets.image_manipulation.cache'); + + return is_string($cache) && $cache !== 'half'; } private function localCacheFilesystem() @@ -77,19 +79,26 @@ private function customCacheFilesystem() */ private function cachePath() { - return $this->shouldServeDirectly() + return ($this->shouldServeDirectly() || $this->isUsingHalfMeasureCaching()) ? Config::get('statamic.assets.image_manipulation.cache_path') : storage_path('statamic/glide'); } public function shouldServeDirectly() { - return (bool) Config::get('statamic.assets.image_manipulation.cache'); + $cache = Config::get('statamic.assets.image_manipulation.cache'); + + return $cache === true || $this->wantsCustomFilesystem(); } public function shouldServeByHttp() { - return ! $this->shouldServeDirectly(); + return ! $this->shouldServeDirectly() && ! $this->isUsingHalfMeasureCaching(); + } + + public function isUsingHalfMeasureCaching() + { + return Config::get('statamic.assets.image_manipulation.cache') === 'half'; } public function route() diff --git a/src/Imaging/HalfMeasureUrlBuilder.php b/src/Imaging/HalfMeasureUrlBuilder.php new file mode 100644 index 00000000000..47b026c8871 --- /dev/null +++ b/src/Imaging/HalfMeasureUrlBuilder.php @@ -0,0 +1,61 @@ +resolver = $resolver; + $this->options = $options; + } + + /** + * Build the URL. + * + * @param \Statamic\Contracts\Assets\Asset|string $item + * @param array $params + * @return string + * + * @throws \Exception + */ + public function build($item, $params) + { + $this->item = $item; + + $cachePath = $this->resolver->resolveForItem($item, $params); + + $sourceParams = match ($this->itemType()) { + 'asset' => ['asset' => Str::toBase64Url($this->item->containerId().'/'.$this->item->path())], + 'url' => ['url' => Str::toBase64Url($this->item)], + 'id' => ['asset' => Str::toBase64Url(str_replace('::', '/', $this->item))], + 'path' => ['src' => $this->item], + default => throw new Exception('Cannot build a half-measure Glide URL without a URL, path, or asset.'), + }; + + if (isset($params['mark']) && $params['mark'] instanceof Asset) { + $asset = $params['mark']; + $params['mark'] = 'asset::'.Str::toBase64Url($asset->containerId().'/'.$asset->path()); + } + + $allParams = array_merge($sourceParams, $params); + + $builder = UrlBuilderFactory::create('/', $this->options['key']); + + $urlPath = URL::tidy($this->options['route'].'/'.$cachePath, withTrailingSlash: false); + + return URL::makeRelative( + URL::prependSiteUrl($builder->getUrl($urlPath, $allParams)) + ); + } +} diff --git a/src/Imaging/ImageGenerator.php b/src/Imaging/ImageGenerator.php index c419010fc51..76e69ac4a00 100644 --- a/src/Imaging/ImageGenerator.php +++ b/src/Imaging/ImageGenerator.php @@ -83,7 +83,7 @@ public function setParams(array $params) public function generateByPath($path, array $params) { return Glide::cacheStore()->rememberForever( - 'path::'.$path.'::'.md5(json_encode($params)), + static::manipulationCacheKey('path', $path, $params), fn () => $this->doGenerateByPath($path, $params) ); } @@ -109,7 +109,7 @@ private function doGenerateByPath($path, array $params, $sourceFilesystemRoot = public function generateByUrl($url, array $params) { return Glide::cacheStore()->rememberForever( - 'url::'.$url.'::'.md5(json_encode($params)), + static::manipulationCacheKey('url', $url, $params), fn () => $this->doGenerateByUrl($url, $params) ); } @@ -159,7 +159,7 @@ public function generateByAsset($asset, array $params) return $this->generateVideoThumbnail($asset, $params); } - $manipulationCacheKey = 'asset::'.$asset->id().'::'.md5(json_encode($params)); + $manipulationCacheKey = static::manipulationCacheKey('asset', $asset, $params); $manifestCacheKey = static::assetCacheManifestKey($asset); // Store the cache key for this manipulation in a manifest so that we can easily remove when deleting an asset. @@ -190,6 +190,17 @@ private function doGenerateByAsset($asset, array $params) return $this->generate($this->asset->basename()); } + public static function manipulationCacheKey(string $type, $item, array $params): string + { + $id = $item; + + if ($type === 'asset') { + $id = $item->id(); + } + + return "{$type}::{$id}::".md5(json_encode($params)); + } + public static function assetCacheManifestKey($asset) { return 'asset::'.$asset->id(); @@ -290,22 +301,29 @@ private function generate($image) } /** - * Apply default Glide manipulations on the image. - * - * @return void + * Get the default Glide manipulation parameters for an asset. */ - private function applyDefaultManipulations() + public static function getDefaultManipulations(?Asset $asset = null): array { $defaults = Glide::normalizeParameters( Config::get('statamic.assets.image_manipulation.defaults') ?: [] ); - // Enable automatic cropping - if (Config::get('statamic.assets.auto_crop') && $this->asset) { - $defaults['fit'] = 'crop-'.$this->asset->get('focus', '50-50'); + if (Config::get('statamic.assets.auto_crop') && $asset) { + $defaults['fit'] = 'crop-'.$asset->get('focus', '50-50'); } - $this->server->setDefaults($defaults); + return $defaults; + } + + /** + * Apply default Glide manipulations on the image. + * + * @return void + */ + private function applyDefaultManipulations() + { + $this->server->setDefaults(static::getDefaultManipulations($this->asset)); } /** diff --git a/src/Providers/GlideServiceProvider.php b/src/Providers/GlideServiceProvider.php index 87be5dab6f3..b55f4d6f005 100644 --- a/src/Providers/GlideServiceProvider.php +++ b/src/Providers/GlideServiceProvider.php @@ -9,8 +9,10 @@ use Statamic\Contracts\Imaging\UrlBuilder; use Statamic\Facades\Config; use Statamic\Facades\Glide; +use Statamic\Imaging\GlideCachePathResolver; use Statamic\Imaging\GlideImageManipulator; use Statamic\Imaging\GlideUrlBuilder; +use Statamic\Imaging\HalfMeasureUrlBuilder; use Statamic\Imaging\ImageGenerator; use Statamic\Imaging\ImageValidator; use Statamic\Imaging\PresetGenerator; @@ -57,6 +59,16 @@ public function register() private function getBuilder() { + if (Glide::isUsingHalfMeasureCaching()) { + return new HalfMeasureUrlBuilder( + $this->app->make(GlideCachePathResolver::class), + [ + 'key' => (Config::get('statamic.assets.image_manipulation.secure')) ? Config::getAppKey() : null, + 'route' => Glide::url(), + ] + ); + } + if (Glide::shouldServeDirectly()) { return new StaticUrlBuilder($this->app->make(ImageGenerator::class), [ 'route' => Glide::url(), diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index 49677c7cf6d..2c03f88b828 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -9,6 +9,7 @@ use InvalidArgumentException; use League\Flysystem\Local\LocalFilesystemAdapter; use League\Glide\Server; +use Orchestra\Testbench\Attributes\DefineEnvironment; use PHPUnit\Framework\Attributes\Test; use Statamic\Contracts\Imaging\UrlBuilder; use Statamic\Facades\Asset; @@ -16,7 +17,9 @@ use Statamic\Facades\File; use Statamic\Facades\Glide; use Statamic\Facades\Path; +use Statamic\Imaging\GlideCachePathResolver; use Statamic\Imaging\GlideUrlBuilder; +use Statamic\Imaging\HalfMeasureUrlBuilder; use Statamic\Imaging\ImageGenerator; use Statamic\Imaging\StaticUrlBuilder; use Statamic\Support\Str; @@ -31,6 +34,11 @@ public function tearDown(): void { $this->clearGlideCache(); + // Clean up half-measure cache directory if it was created + if (file_exists($path = storage_path('glide-test-cache'))) { + File::delete($path); + } + parent::tearDown(); } @@ -70,6 +78,199 @@ public function cache_true_will_make_a_filesystem_using_the_cache_path_location( $this->assertEquals('/imgs', Glide::url()); } + #[Test] + public function half_measure_caching_will_make_a_filesystem_using_the_cache_path_location() + { + config([ + 'statamic.assets.image_manipulation.route' => 'imgs', + 'statamic.assets.image_manipulation.cache' => 'half', + 'statamic.assets.image_manipulation.cache_path' => public_path('imgcache'), + ]); + + $cache = Glide::server()->getCache(); + + $this->assertLocalAdapter($adapter = $this->getAdapterFromFilesystem($cache)); + $this->assertEquals('public', $this->defaultFolderVisibility($cache)); + $this->assertEquals(public_path('imgcache').DIRECTORY_SEPARATOR, $this->getRootFromLocalAdapter($adapter)); + $this->assertInstanceOf(HalfMeasureUrlBuilder::class, $this->app[UrlBuilder::class]); + $this->assertEquals('/imgs', Glide::url()); + } + + #[Test] + public function half_measure_caching_without_cache_path_will_throw_exception() + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Image manipulation cache path is not defined.'); + + config([ + 'statamic.assets.image_manipulation.route' => 'imgs', + 'statamic.assets.image_manipulation.cache' => 'half', + 'statamic.assets.image_manipulation.cache_path' => null, + ]); + + Glide::server()->getCache(); + } + + #[Test] + public function half_measure_caching_is_detected_as_half_measure() + { + config(['statamic.assets.image_manipulation.cache' => 'half']); + + $this->assertTrue(Glide::isUsingHalfMeasureCaching()); + $this->assertFalse(Glide::shouldServeDirectly()); + $this->assertFalse(Glide::shouldServeByHttp()); + } + + #[Test] + public function half_measure_caching_predicted_path_matches_generated_path() + { + config([ + 'statamic.assets.image_manipulation.cache' => false, + 'statamic.assets.auto_crop' => true, + ]); + + Storage::fake('test'); + $file = UploadedFile::fake()->image('hoff.jpg', 30, 60); + Storage::disk('test')->putFileAs('foo', $file, 'hoff.jpg'); + $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); + $asset = tap($container->makeAsset('foo/hoff.jpg'))->save(); + + $server = $this->app->make(Server::class); + $resolver = new GlideCachePathResolver($server); + + $params = ['w' => 100, 'h' => 50]; + + // Predict the path + $predictedPath = $resolver->resolveForAsset($asset, $params); + + // Actually generate the image + $generator = new ImageGenerator($server); + $generatedPath = $generator->generateByAsset($asset, $params); + + $this->assertEquals($generatedPath, $predictedPath); + } + + #[Test] + #[DefineEnvironment('halfMeasureCaching')] + public function half_measure_caching_generates_image_on_first_request() + { + Storage::fake('test'); + $file = UploadedFile::fake()->image('hoff.jpg', 30, 60); + Storage::disk('test')->putFileAs('foo', $file, 'hoff.jpg'); + $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); + tap($container->makeAsset('foo/hoff.jpg'))->save(); + + $encoded = Str::toBase64Url('test_container/foo/hoff.jpg'); + + $resolver = new GlideCachePathResolver($this->app->make(Server::class)); + $asset = Asset::find('test_container::foo/hoff.jpg'); + $expectedPath = $resolver->resolveForAsset($asset, ['w' => 100]); + + $this->assertFalse(Glide::cacheDisk()->exists($expectedPath)); + + $response = $this->get('/img/'.$expectedPath.'?asset='.$encoded.'&w=100'); + + $response->assertOk(); + $response->assertHeader('content-type', 'image/jpeg'); + $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); + } + + #[Test] + #[DefineEnvironment('halfMeasureCaching')] + public function half_measure_caching_serves_existing_cached_file() + { + // Write a real image to the cache disk at a known path + $fakePath = 'containers/test/fake-hash/image.jpg'; + $image = UploadedFile::fake()->image('image.jpg', 10, 10); + Glide::cacheDisk()->put($fakePath, file_get_contents($image->getPathname())); + + $response = $this->get('/img/'.$fakePath); + + $response->assertOk(); + } + + #[Test] + #[DefineEnvironment('halfMeasureCaching')] + public function half_measure_caching_returns_404_without_query_params_when_file_not_cached() + { + $response = $this->get('/img/containers/nonexistent/hash/image.jpg'); + + $response->assertNotFound(); + } + + #[Test] + #[DefineEnvironment('halfMeasureCaching')] + public function half_measure_caching_regenerates_when_file_deleted_but_cache_store_exists() + { + Storage::fake('test'); + $file = UploadedFile::fake()->image('hoff.jpg', 30, 60); + Storage::disk('test')->putFileAs('foo', $file, 'hoff.jpg'); + $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); + tap($container->makeAsset('foo/hoff.jpg'))->save(); + + $encoded = Str::toBase64Url('test_container/foo/hoff.jpg'); + $asset = Asset::find('test_container::foo/hoff.jpg'); + + $resolver = new GlideCachePathResolver($this->app->make(Server::class)); + $expectedPath = $resolver->resolveForAsset($asset, ['w' => 100]); + + // Generate the image first + $response = $this->get('/img/'.$expectedPath.'?asset='.$encoded.'&w=100'); + $response->assertOk(); + $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); + + // Delete the file but leave the cache store entry + Glide::cacheDisk()->delete($expectedPath); + $this->assertFalse(Glide::cacheDisk()->exists($expectedPath)); + + // Request again — should regenerate + $response = $this->get('/img/'.$expectedPath.'?asset='.$encoded.'&w=100'); + $response->assertOk(); + $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); + } + + #[Test] + #[DefineEnvironment('halfMeasureSecureCaching')] + public function half_measure_caching_rejects_request_with_invalid_signature() + { + $response = $this->get('/img/containers/test/fake-hash/image.jpg?asset=dGVzdC9pbWFnZS5qcGc&w=100&s=invalid'); + + $response->assertStatus(400); + } + + #[Test] + #[DefineEnvironment('halfMeasureSecureCaching')] + public function half_measure_caching_rejects_request_with_missing_signature() + { + $response = $this->get('/img/containers/test/fake-hash/image.jpg?asset=dGVzdC9pbWFnZS5qcGc&w=100'); + + $response->assertStatus(400); + } + + #[Test] + #[DefineEnvironment('halfMeasureCaching')] + public function half_measure_caching_generates_image_on_first_request_by_path() + { + $fakeImage = UploadedFile::fake()->image('test-path.jpg', 30, 60); + $imagePath = 'test-path.jpg'; + + // Place the image in the source filesystem (public path by default) + file_put_contents(public_path($imagePath), file_get_contents($fakeImage->getPathname())); + + $resolver = new GlideCachePathResolver($this->app->make(Server::class)); + $expectedPath = $resolver->resolveForPath($imagePath, ['w' => 100]); + + $this->assertFalse(Glide::cacheDisk()->exists($expectedPath)); + + $response = $this->get('/img/'.$expectedPath.'?src='.$imagePath.'&w=100'); + + $response->assertOk(); + $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); + + // Clean up + @unlink(public_path($imagePath)); + } + #[Test] public function cache_true_without_cache_path_will_throw_exception() { @@ -245,4 +446,20 @@ private function createImageManipulations($containerHandle, $assetPath, $manipul return collect(array_merge([$manifestCacheKey], $manifest)); } + + protected function halfMeasureCaching($app) + { + $app['config']->set('statamic.assets.image_manipulation.cache', 'half'); + $app['config']->set('statamic.assets.image_manipulation.cache_path', storage_path('glide-test-cache')); + $app['config']->set('statamic.assets.image_manipulation.secure', false); + $app['config']->set('statamic.assets.image_manipulation.route', 'img'); + } + + protected function halfMeasureSecureCaching($app) + { + $app['config']->set('statamic.assets.image_manipulation.cache', 'half'); + $app['config']->set('statamic.assets.image_manipulation.cache_path', storage_path('glide-test-cache')); + $app['config']->set('statamic.assets.image_manipulation.secure', true); + $app['config']->set('statamic.assets.image_manipulation.route', 'img'); + } } From 4408ec7bb68bff18003ba61254328c0912a5dec4 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 29 Apr 2026 14:47:01 +0100 Subject: [PATCH 02/24] remove unneccessary comments in test --- tests/Imaging/GlideTest.php | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index 2c03f88b828..09aee5218e6 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -34,7 +34,6 @@ public function tearDown(): void { $this->clearGlideCache(); - // Clean up half-measure cache directory if it was created if (file_exists($path = storage_path('glide-test-cache'))) { File::delete($path); } @@ -140,10 +139,8 @@ public function half_measure_caching_predicted_path_matches_generated_path() $params = ['w' => 100, 'h' => 50]; - // Predict the path $predictedPath = $resolver->resolveForAsset($asset, $params); - // Actually generate the image $generator = new ImageGenerator($server); $generatedPath = $generator->generateByAsset($asset, $params); @@ -179,7 +176,6 @@ public function half_measure_caching_generates_image_on_first_request() #[DefineEnvironment('halfMeasureCaching')] public function half_measure_caching_serves_existing_cached_file() { - // Write a real image to the cache disk at a known path $fakePath = 'containers/test/fake-hash/image.jpg'; $image = UploadedFile::fake()->image('image.jpg', 10, 10); Glide::cacheDisk()->put($fakePath, file_get_contents($image->getPathname())); @@ -214,16 +210,13 @@ public function half_measure_caching_regenerates_when_file_deleted_but_cache_sto $resolver = new GlideCachePathResolver($this->app->make(Server::class)); $expectedPath = $resolver->resolveForAsset($asset, ['w' => 100]); - // Generate the image first $response = $this->get('/img/'.$expectedPath.'?asset='.$encoded.'&w=100'); $response->assertOk(); $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); - // Delete the file but leave the cache store entry Glide::cacheDisk()->delete($expectedPath); $this->assertFalse(Glide::cacheDisk()->exists($expectedPath)); - // Request again — should regenerate $response = $this->get('/img/'.$expectedPath.'?asset='.$encoded.'&w=100'); $response->assertOk(); $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); @@ -254,7 +247,6 @@ public function half_measure_caching_generates_image_on_first_request_by_path() $fakeImage = UploadedFile::fake()->image('test-path.jpg', 30, 60); $imagePath = 'test-path.jpg'; - // Place the image in the source filesystem (public path by default) file_put_contents(public_path($imagePath), file_get_contents($fakeImage->getPathname())); $resolver = new GlideCachePathResolver($this->app->make(Server::class)); @@ -267,7 +259,6 @@ public function half_measure_caching_generates_image_on_first_request_by_path() $response->assertOk(); $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); - // Clean up @unlink(public_path($imagePath)); } From 05afbd974a4beadd58fb493d39295ba12214e878 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 29 Apr 2026 14:51:34 +0100 Subject: [PATCH 03/24] =?UTF-8?q?fix=20failing=20test=20on=20windows=20?= =?UTF-8?q?=F0=9F=A4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Imaging/GlideTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index 09aee5218e6..54af6364dcb 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -212,6 +212,7 @@ public function half_measure_caching_regenerates_when_file_deleted_but_cache_sto $response = $this->get('/img/'.$expectedPath.'?asset='.$encoded.'&w=100'); $response->assertOk(); + $response->streamedContent(); // Ensure the file handle is closed (Windows compat) $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); Glide::cacheDisk()->delete($expectedPath); From 697c950c93e8d55d80a6514de50fe560459f0e5c Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 29 Apr 2026 14:57:07 +0100 Subject: [PATCH 04/24] call it `hybrid` instead --- config/assets.php | 4 +- routes/routes.php | 2 +- src/Facades/Glide.php | 2 +- src/Http/Controllers/GlideController.php | 8 +-- src/Imaging/GlideManager.php | 10 ++-- ...ureUrlBuilder.php => HybridUrlBuilder.php} | 4 +- src/Providers/GlideServiceProvider.php | 6 +-- tests/Imaging/GlideTest.php | 50 +++++++++---------- 8 files changed, 43 insertions(+), 43 deletions(-) rename src/Imaging/{HalfMeasureUrlBuilder.php => HybridUrlBuilder.php} (91%) diff --git a/config/assets.php b/config/assets.php index 811cebc4396..2a4ad3670b7 100644 --- a/config/assets.php +++ b/config/assets.php @@ -51,11 +51,11 @@ | false - Images are generated on each HTTP request via Glide routes. | true - Images are eagerly generated during template rendering and | saved to a publicly accessible location. - | 'half' - Images are generated on-demand on the first HTTP request, + | 'hybrid' - Images are generated on-demand on the first HTTP request, | then saved to a publicly accessible location so the web | server can serve them directly on subsequent requests. | - | When using true or 'half', you should configure the cache_path below. + | When using true or 'hybrid', you should configure the cache_path below. | */ diff --git a/routes/routes.php b/routes/routes.php index 97bee65deb5..07605897936 100644 --- a/routes/routes.php +++ b/routes/routes.php @@ -29,7 +29,7 @@ }); } -if (Glide::shouldServeByHttp() || Glide::isUsingHalfMeasureCaching()) { +if (Glide::shouldServeByHttp() || Glide::isUsingHybridCaching()) { require __DIR__.'/glide.php'; } diff --git a/src/Facades/Glide.php b/src/Facades/Glide.php index e044f7b2aa7..39b77544752 100644 --- a/src/Facades/Glide.php +++ b/src/Facades/Glide.php @@ -10,7 +10,7 @@ * @method static \Illuminate\Contracts\Filesystem\Filesystem cacheDisk() * @method static bool shouldServeDirectly() * @method static bool shouldServeByHttp() - * @method static bool isUsingHalfMeasureCaching() + * @method static bool isUsingHybridCaching() * @method static string route() * @method static string url() * @method static \Illuminate\Contracts\Cache\Repository cacheStore() diff --git a/src/Http/Controllers/GlideController.php b/src/Http/Controllers/GlideController.php index cafc1eefdf2..6271e53a1e6 100644 --- a/src/Http/Controllers/GlideController.php +++ b/src/Http/Controllers/GlideController.php @@ -53,7 +53,7 @@ public function __construct(Server $server, Request $request, ImageGenerator $ge */ public function generateByPath($path) { - if (Glide::isUsingHalfMeasureCaching()) { + if (Glide::isUsingHybridCaching()) { return $this->generateOnDemand($path); } @@ -86,7 +86,7 @@ public function generateByUrl($url) } /** - * Generate an on-demand image for the half-measure caching strategy. + * Generate an on-demand image for the hybrid caching strategy. * * The URL path is the predicted cache path. Query parameters contain * the source identifier and manipulation parameters needed to generate. @@ -96,7 +96,7 @@ private function generateOnDemand(string $path) $this->validateSignature(); if (Glide::cacheDisk()->exists($path)) { - Log::debug('Glide half-measure cache loaded ['.$path.'] If you are seeing this, your server rewrite rules have not been set up correctly.'); + Log::debug('Glide hybrid cache loaded ['.$path.'] If you are seeing this, your server rewrite rules have not been set up correctly.'); return $this->createResponse($path); } @@ -131,7 +131,7 @@ private function generateOnDemand(string $path) /** * Forget any stale cache store entry, then generate the image. * - * In half-measure mode, the file on disk is the source of truth. + * In hybrid mode, the file on disk is the source of truth. * If we're here, the file doesn't exist, so the cache store * entry (if any) is stale and should be cleared first. */ diff --git a/src/Imaging/GlideManager.php b/src/Imaging/GlideManager.php index 9e6e6d113ea..b5ca3de7e14 100644 --- a/src/Imaging/GlideManager.php +++ b/src/Imaging/GlideManager.php @@ -51,7 +51,7 @@ private function wantsCustomFilesystem() { $cache = Config::get('statamic.assets.image_manipulation.cache'); - return is_string($cache) && $cache !== 'half'; + return is_string($cache) && $cache !== 'hybrid'; } private function localCacheFilesystem() @@ -79,7 +79,7 @@ private function customCacheFilesystem() */ private function cachePath() { - return ($this->shouldServeDirectly() || $this->isUsingHalfMeasureCaching()) + return ($this->shouldServeDirectly() || $this->isUsingHybridCaching()) ? Config::get('statamic.assets.image_manipulation.cache_path') : storage_path('statamic/glide'); } @@ -93,12 +93,12 @@ public function shouldServeDirectly() public function shouldServeByHttp() { - return ! $this->shouldServeDirectly() && ! $this->isUsingHalfMeasureCaching(); + return ! $this->shouldServeDirectly() && ! $this->isUsingHybridCaching(); } - public function isUsingHalfMeasureCaching() + public function isUsingHybridCaching() { - return Config::get('statamic.assets.image_manipulation.cache') === 'half'; + return Config::get('statamic.assets.image_manipulation.cache') === 'hybrid'; } public function route() diff --git a/src/Imaging/HalfMeasureUrlBuilder.php b/src/Imaging/HybridUrlBuilder.php similarity index 91% rename from src/Imaging/HalfMeasureUrlBuilder.php rename to src/Imaging/HybridUrlBuilder.php index 47b026c8871..80edf3e15ae 100644 --- a/src/Imaging/HalfMeasureUrlBuilder.php +++ b/src/Imaging/HybridUrlBuilder.php @@ -8,7 +8,7 @@ use Statamic\Facades\URL; use Statamic\Support\Str; -class HalfMeasureUrlBuilder extends ImageUrlBuilder +class HybridUrlBuilder extends ImageUrlBuilder { protected GlideCachePathResolver $resolver; @@ -40,7 +40,7 @@ public function build($item, $params) 'url' => ['url' => Str::toBase64Url($this->item)], 'id' => ['asset' => Str::toBase64Url(str_replace('::', '/', $this->item))], 'path' => ['src' => $this->item], - default => throw new Exception('Cannot build a half-measure Glide URL without a URL, path, or asset.'), + default => throw new Exception('Cannot build a hybrid Glide URL without a URL, path, or asset.'), }; if (isset($params['mark']) && $params['mark'] instanceof Asset) { diff --git a/src/Providers/GlideServiceProvider.php b/src/Providers/GlideServiceProvider.php index b55f4d6f005..0a299fe1d64 100644 --- a/src/Providers/GlideServiceProvider.php +++ b/src/Providers/GlideServiceProvider.php @@ -12,7 +12,7 @@ use Statamic\Imaging\GlideCachePathResolver; use Statamic\Imaging\GlideImageManipulator; use Statamic\Imaging\GlideUrlBuilder; -use Statamic\Imaging\HalfMeasureUrlBuilder; +use Statamic\Imaging\HybridUrlBuilder; use Statamic\Imaging\ImageGenerator; use Statamic\Imaging\ImageValidator; use Statamic\Imaging\PresetGenerator; @@ -59,8 +59,8 @@ public function register() private function getBuilder() { - if (Glide::isUsingHalfMeasureCaching()) { - return new HalfMeasureUrlBuilder( + if (Glide::isUsingHybridCaching()) { + return new HybridUrlBuilder( $this->app->make(GlideCachePathResolver::class), [ 'key' => (Config::get('statamic.assets.image_manipulation.secure')) ? Config::getAppKey() : null, diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index 54af6364dcb..fad266c0e69 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -19,7 +19,7 @@ use Statamic\Facades\Path; use Statamic\Imaging\GlideCachePathResolver; use Statamic\Imaging\GlideUrlBuilder; -use Statamic\Imaging\HalfMeasureUrlBuilder; +use Statamic\Imaging\HybridUrlBuilder; use Statamic\Imaging\ImageGenerator; use Statamic\Imaging\StaticUrlBuilder; use Statamic\Support\Str; @@ -78,11 +78,11 @@ public function cache_true_will_make_a_filesystem_using_the_cache_path_location( } #[Test] - public function half_measure_caching_will_make_a_filesystem_using_the_cache_path_location() + public function hybrid_caching_will_make_a_filesystem_using_the_cache_path_location() { config([ 'statamic.assets.image_manipulation.route' => 'imgs', - 'statamic.assets.image_manipulation.cache' => 'half', + 'statamic.assets.image_manipulation.cache' => 'hybrid', 'statamic.assets.image_manipulation.cache_path' => public_path('imgcache'), ]); @@ -91,19 +91,19 @@ public function half_measure_caching_will_make_a_filesystem_using_the_cache_path $this->assertLocalAdapter($adapter = $this->getAdapterFromFilesystem($cache)); $this->assertEquals('public', $this->defaultFolderVisibility($cache)); $this->assertEquals(public_path('imgcache').DIRECTORY_SEPARATOR, $this->getRootFromLocalAdapter($adapter)); - $this->assertInstanceOf(HalfMeasureUrlBuilder::class, $this->app[UrlBuilder::class]); + $this->assertInstanceOf(HybridUrlBuilder::class, $this->app[UrlBuilder::class]); $this->assertEquals('/imgs', Glide::url()); } #[Test] - public function half_measure_caching_without_cache_path_will_throw_exception() + public function hybrid_caching_without_cache_path_will_throw_exception() { $this->expectException(\Exception::class); $this->expectExceptionMessage('Image manipulation cache path is not defined.'); config([ 'statamic.assets.image_manipulation.route' => 'imgs', - 'statamic.assets.image_manipulation.cache' => 'half', + 'statamic.assets.image_manipulation.cache' => 'hybrid', 'statamic.assets.image_manipulation.cache_path' => null, ]); @@ -111,17 +111,17 @@ public function half_measure_caching_without_cache_path_will_throw_exception() } #[Test] - public function half_measure_caching_is_detected_as_half_measure() + public function hybrid_caching_is_detected_as_half_measure() { - config(['statamic.assets.image_manipulation.cache' => 'half']); + config(['statamic.assets.image_manipulation.cache' => 'hybrid']); - $this->assertTrue(Glide::isUsingHalfMeasureCaching()); + $this->assertTrue(Glide::isUsingHybridCaching()); $this->assertFalse(Glide::shouldServeDirectly()); $this->assertFalse(Glide::shouldServeByHttp()); } #[Test] - public function half_measure_caching_predicted_path_matches_generated_path() + public function hybrid_caching_predicted_path_matches_generated_path() { config([ 'statamic.assets.image_manipulation.cache' => false, @@ -148,8 +148,8 @@ public function half_measure_caching_predicted_path_matches_generated_path() } #[Test] - #[DefineEnvironment('halfMeasureCaching')] - public function half_measure_caching_generates_image_on_first_request() + #[DefineEnvironment('hybridCaching')] + public function hybrid_caching_generates_image_on_first_request() { Storage::fake('test'); $file = UploadedFile::fake()->image('hoff.jpg', 30, 60); @@ -173,8 +173,8 @@ public function half_measure_caching_generates_image_on_first_request() } #[Test] - #[DefineEnvironment('halfMeasureCaching')] - public function half_measure_caching_serves_existing_cached_file() + #[DefineEnvironment('hybridCaching')] + public function hybrid_caching_serves_existing_cached_file() { $fakePath = 'containers/test/fake-hash/image.jpg'; $image = UploadedFile::fake()->image('image.jpg', 10, 10); @@ -186,8 +186,8 @@ public function half_measure_caching_serves_existing_cached_file() } #[Test] - #[DefineEnvironment('halfMeasureCaching')] - public function half_measure_caching_returns_404_without_query_params_when_file_not_cached() + #[DefineEnvironment('hybridCaching')] + public function hybrid_caching_returns_404_without_query_params_when_file_not_cached() { $response = $this->get('/img/containers/nonexistent/hash/image.jpg'); @@ -195,8 +195,8 @@ public function half_measure_caching_returns_404_without_query_params_when_file_ } #[Test] - #[DefineEnvironment('halfMeasureCaching')] - public function half_measure_caching_regenerates_when_file_deleted_but_cache_store_exists() + #[DefineEnvironment('hybridCaching')] + public function hybrid_caching_regenerates_when_file_deleted_but_cache_store_exists() { Storage::fake('test'); $file = UploadedFile::fake()->image('hoff.jpg', 30, 60); @@ -225,7 +225,7 @@ public function half_measure_caching_regenerates_when_file_deleted_but_cache_sto #[Test] #[DefineEnvironment('halfMeasureSecureCaching')] - public function half_measure_caching_rejects_request_with_invalid_signature() + public function hybrid_caching_rejects_request_with_invalid_signature() { $response = $this->get('/img/containers/test/fake-hash/image.jpg?asset=dGVzdC9pbWFnZS5qcGc&w=100&s=invalid'); @@ -234,7 +234,7 @@ public function half_measure_caching_rejects_request_with_invalid_signature() #[Test] #[DefineEnvironment('halfMeasureSecureCaching')] - public function half_measure_caching_rejects_request_with_missing_signature() + public function hybrid_caching_rejects_request_with_missing_signature() { $response = $this->get('/img/containers/test/fake-hash/image.jpg?asset=dGVzdC9pbWFnZS5qcGc&w=100'); @@ -242,8 +242,8 @@ public function half_measure_caching_rejects_request_with_missing_signature() } #[Test] - #[DefineEnvironment('halfMeasureCaching')] - public function half_measure_caching_generates_image_on_first_request_by_path() + #[DefineEnvironment('hybridCaching')] + public function hybrid_caching_generates_image_on_first_request_by_path() { $fakeImage = UploadedFile::fake()->image('test-path.jpg', 30, 60); $imagePath = 'test-path.jpg'; @@ -439,9 +439,9 @@ private function createImageManipulations($containerHandle, $assetPath, $manipul return collect(array_merge([$manifestCacheKey], $manifest)); } - protected function halfMeasureCaching($app) + protected function hybridCaching($app) { - $app['config']->set('statamic.assets.image_manipulation.cache', 'half'); + $app['config']->set('statamic.assets.image_manipulation.cache', 'hybrid'); $app['config']->set('statamic.assets.image_manipulation.cache_path', storage_path('glide-test-cache')); $app['config']->set('statamic.assets.image_manipulation.secure', false); $app['config']->set('statamic.assets.image_manipulation.route', 'img'); @@ -449,7 +449,7 @@ protected function halfMeasureCaching($app) protected function halfMeasureSecureCaching($app) { - $app['config']->set('statamic.assets.image_manipulation.cache', 'half'); + $app['config']->set('statamic.assets.image_manipulation.cache', 'hybrid'); $app['config']->set('statamic.assets.image_manipulation.cache_path', storage_path('glide-test-cache')); $app['config']->set('statamic.assets.image_manipulation.secure', true); $app['config']->set('statamic.assets.image_manipulation.route', 'img'); From bf202d66c3d3b239cc9443d60b1d5d5891365bb2 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 29 Apr 2026 15:27:46 +0100 Subject: [PATCH 05/24] remove query parameters from url to avoid seo issues --- src/Http/Controllers/GlideController.php | 37 ++++++----------- src/Imaging/HybridUrlBuilder.php | 47 ++++++++++++++------- src/Providers/GlideServiceProvider.php | 5 +-- tests/Imaging/GlideTest.php | 53 +++++++++++------------- 4 files changed, 69 insertions(+), 73 deletions(-) diff --git a/src/Http/Controllers/GlideController.php b/src/Http/Controllers/GlideController.php index 6271e53a1e6..5575cda0a06 100644 --- a/src/Http/Controllers/GlideController.php +++ b/src/Http/Controllers/GlideController.php @@ -88,44 +88,31 @@ public function generateByUrl($url) /** * Generate an on-demand image for the hybrid caching strategy. * - * The URL path is the predicted cache path. Query parameters contain - * the source identifier and manipulation parameters needed to generate. + * The URL path is the predicted cache path. A mapping stored in the + * Glide cache store links it back to the source and manipulation params. */ private function generateOnDemand(string $path) { - $this->validateSignature(); - if (Glide::cacheDisk()->exists($path)) { Log::debug('Glide hybrid cache loaded ['.$path.'] If you are seeing this, your server rewrite rules have not been set up correctly.'); return $this->createResponse($path); } - $params = collect($this->request->all()) - ->except(['asset', 'url', 'src', 's']) - ->all(); - - if ($encoded = $this->request->query->get('asset')) { - $decoded = Str::fromBase64Url($encoded); + $mapping = Glide::cacheStore()->get('hybrid::'.$path); - [$container, $assetPath] = explode('/', $decoded, 2); + throw_unless($mapping, new NotFoundHttpException); - throw_unless($container = AssetContainer::find($container), new NotFoundHttpException); + $type = $mapping['type']; + $params = $mapping['params']; - throw_unless($asset = $container->asset($assetPath), new NotFoundHttpException); - - return $this->createResponse($this->ensureGenerated('asset', $asset, $params)); - } - - if ($url = $this->request->query->get('url')) { - return $this->createResponse($this->ensureGenerated('url', Str::fromBase64Url($url), $params)); - } - - if ($src = $this->request->query->get('src')) { - return $this->createResponse($this->ensureGenerated('path', $src, $params)); - } + $item = match ($type) { + 'asset' => Asset::find($mapping['id']) ?? throw new NotFoundHttpException, + 'url' => $mapping['url'], + 'path' => $mapping['path'], + }; - throw new NotFoundHttpException; + return $this->createResponse($this->ensureGenerated($type, $item, $params)); } /** diff --git a/src/Imaging/HybridUrlBuilder.php b/src/Imaging/HybridUrlBuilder.php index 80edf3e15ae..fc262e95720 100644 --- a/src/Imaging/HybridUrlBuilder.php +++ b/src/Imaging/HybridUrlBuilder.php @@ -3,8 +3,9 @@ namespace Statamic\Imaging; use Exception; -use League\Glide\Urls\UrlBuilderFactory; use Statamic\Contracts\Assets\Asset; +use Statamic\Facades\Asset as Assets; +use Statamic\Facades\Glide; use Statamic\Facades\URL; use Statamic\Support\Str; @@ -33,29 +34,43 @@ public function build($item, $params) { $this->item = $item; - $cachePath = $this->resolver->resolveForItem($item, $params); - - $sourceParams = match ($this->itemType()) { - 'asset' => ['asset' => Str::toBase64Url($this->item->containerId().'/'.$this->item->path())], - 'url' => ['url' => Str::toBase64Url($this->item)], - 'id' => ['asset' => Str::toBase64Url(str_replace('::', '/', $this->item))], - 'path' => ['src' => $this->item], - default => throw new Exception('Cannot build a hybrid Glide URL without a URL, path, or asset.'), - }; - if (isset($params['mark']) && $params['mark'] instanceof Asset) { $asset = $params['mark']; $params['mark'] = 'asset::'.Str::toBase64Url($asset->containerId().'/'.$asset->path()); } - $allParams = array_merge($sourceParams, $params); + $cachePath = $this->resolver->resolveForItem($item, $params); - $builder = UrlBuilderFactory::create('/', $this->options['key']); + $this->cacheSource($cachePath, $params); $urlPath = URL::tidy($this->options['route'].'/'.$cachePath, withTrailingSlash: false); - return URL::makeRelative( - URL::prependSiteUrl($builder->getUrl($urlPath, $allParams)) - ); + return URL::makeRelative(URL::prependSiteUrl($urlPath)); + } + + private function cacheSource(string $cachePath, array $params): void + { + $mapping = match ($this->itemType()) { + 'asset' => ['type' => 'asset', 'id' => $this->item->id(), 'params' => $params], + 'url' => ['type' => 'url', 'url' => $this->item, 'params' => $params], + 'id' => ['type' => 'asset', 'id' => str_replace('/', '::', $this->item), 'params' => $params], + 'path' => ['type' => 'path', 'path' => $this->item, 'params' => $params], + default => throw new Exception('Cannot build a hybrid Glide URL without a URL, path, or asset.'), + }; + + $mappingKey = 'hybrid::'.$cachePath; + + Glide::cacheStore()->forever($mappingKey, $mapping); + + // Add to the asset manifest so clearAsset() cleans up the mapping too. + if ($mapping['type'] === 'asset') { + $manifestKey = ImageGenerator::assetCacheManifestKey( + Assets::find($mapping['id']) + ); + + $manifest = Glide::cacheStore()->get($manifestKey, []); + $manifest[] = $mappingKey; + Glide::cacheStore()->forever($manifestKey, array_unique($manifest)); + } } } diff --git a/src/Providers/GlideServiceProvider.php b/src/Providers/GlideServiceProvider.php index 0a299fe1d64..bbd4285b483 100644 --- a/src/Providers/GlideServiceProvider.php +++ b/src/Providers/GlideServiceProvider.php @@ -62,10 +62,7 @@ private function getBuilder() if (Glide::isUsingHybridCaching()) { return new HybridUrlBuilder( $this->app->make(GlideCachePathResolver::class), - [ - 'key' => (Config::get('statamic.assets.image_manipulation.secure')) ? Config::getAppKey() : null, - 'route' => Glide::url(), - ] + ['route' => Glide::url()] ); } diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index fad266c0e69..bb4b91b78ec 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -157,15 +157,15 @@ public function hybrid_caching_generates_image_on_first_request() $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); tap($container->makeAsset('foo/hoff.jpg'))->save(); - $encoded = Str::toBase64Url('test_container/foo/hoff.jpg'); + $asset = Asset::find('test_container::foo/hoff.jpg'); + $url = $this->app->make(UrlBuilder::class)->build($asset, ['w' => 100]); $resolver = new GlideCachePathResolver($this->app->make(Server::class)); - $asset = Asset::find('test_container::foo/hoff.jpg'); $expectedPath = $resolver->resolveForAsset($asset, ['w' => 100]); $this->assertFalse(Glide::cacheDisk()->exists($expectedPath)); - $response = $this->get('/img/'.$expectedPath.'?asset='.$encoded.'&w=100'); + $response = $this->get($url); $response->assertOk(); $response->assertHeader('content-type', 'image/jpeg'); @@ -187,7 +187,7 @@ public function hybrid_caching_serves_existing_cached_file() #[Test] #[DefineEnvironment('hybridCaching')] - public function hybrid_caching_returns_404_without_query_params_when_file_not_cached() + public function hybrid_caching_returns_404_when_no_mapping_exists() { $response = $this->get('/img/containers/nonexistent/hash/image.jpg'); @@ -196,7 +196,7 @@ public function hybrid_caching_returns_404_without_query_params_when_file_not_ca #[Test] #[DefineEnvironment('hybridCaching')] - public function hybrid_caching_regenerates_when_file_deleted_but_cache_store_exists() + public function hybrid_caching_regenerates_when_file_deleted_but_mapping_exists() { Storage::fake('test'); $file = UploadedFile::fake()->image('hoff.jpg', 30, 60); @@ -204,41 +204,43 @@ public function hybrid_caching_regenerates_when_file_deleted_but_cache_store_exi $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); tap($container->makeAsset('foo/hoff.jpg'))->save(); - $encoded = Str::toBase64Url('test_container/foo/hoff.jpg'); $asset = Asset::find('test_container::foo/hoff.jpg'); + $url = $this->app->make(UrlBuilder::class)->build($asset, ['w' => 100]); $resolver = new GlideCachePathResolver($this->app->make(Server::class)); $expectedPath = $resolver->resolveForAsset($asset, ['w' => 100]); - $response = $this->get('/img/'.$expectedPath.'?asset='.$encoded.'&w=100'); + // Generate the image + $response = $this->get($url); $response->assertOk(); $response->streamedContent(); // Ensure the file handle is closed (Windows compat) $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); + // Delete the file but leave the mapping Glide::cacheDisk()->delete($expectedPath); $this->assertFalse(Glide::cacheDisk()->exists($expectedPath)); - $response = $this->get('/img/'.$expectedPath.'?asset='.$encoded.'&w=100'); + // Request again — should regenerate via the mapping + $response = $this->get($url); $response->assertOk(); $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); } #[Test] - #[DefineEnvironment('halfMeasureSecureCaching')] - public function hybrid_caching_rejects_request_with_invalid_signature() + #[DefineEnvironment('hybridCaching')] + public function hybrid_caching_url_has_no_query_params() { - $response = $this->get('/img/containers/test/fake-hash/image.jpg?asset=dGVzdC9pbWFnZS5qcGc&w=100&s=invalid'); - - $response->assertStatus(400); - } + Storage::fake('test'); + $file = UploadedFile::fake()->image('hoff.jpg', 30, 60); + Storage::disk('test')->putFileAs('foo', $file, 'hoff.jpg'); + $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); + tap($container->makeAsset('foo/hoff.jpg'))->save(); - #[Test] - #[DefineEnvironment('halfMeasureSecureCaching')] - public function hybrid_caching_rejects_request_with_missing_signature() - { - $response = $this->get('/img/containers/test/fake-hash/image.jpg?asset=dGVzdC9pbWFnZS5qcGc&w=100'); + $asset = Asset::find('test_container::foo/hoff.jpg'); + $url = $this->app->make(UrlBuilder::class)->build($asset, ['w' => 100]); - $response->assertStatus(400); + $this->assertStringNotContainsString('?', $url); + $this->assertStringStartsWith('/img/', $url); } #[Test] @@ -250,12 +252,14 @@ public function hybrid_caching_generates_image_on_first_request_by_path() file_put_contents(public_path($imagePath), file_get_contents($fakeImage->getPathname())); + $url = $this->app->make(UrlBuilder::class)->build($imagePath, ['w' => 100]); + $resolver = new GlideCachePathResolver($this->app->make(Server::class)); $expectedPath = $resolver->resolveForPath($imagePath, ['w' => 100]); $this->assertFalse(Glide::cacheDisk()->exists($expectedPath)); - $response = $this->get('/img/'.$expectedPath.'?src='.$imagePath.'&w=100'); + $response = $this->get($url); $response->assertOk(); $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); @@ -447,11 +451,4 @@ protected function hybridCaching($app) $app['config']->set('statamic.assets.image_manipulation.route', 'img'); } - protected function halfMeasureSecureCaching($app) - { - $app['config']->set('statamic.assets.image_manipulation.cache', 'hybrid'); - $app['config']->set('statamic.assets.image_manipulation.cache_path', storage_path('glide-test-cache')); - $app['config']->set('statamic.assets.image_manipulation.secure', true); - $app['config']->set('statamic.assets.image_manipulation.route', 'img'); - } } From bd2be924e8413e7dc7c90c39114f96722a9202c6 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 29 Apr 2026 15:31:35 +0100 Subject: [PATCH 06/24] formatting --- tests/Imaging/GlideTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index bb4b91b78ec..af6aa820552 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -450,5 +450,4 @@ protected function hybridCaching($app) $app['config']->set('statamic.assets.image_manipulation.secure', false); $app['config']->set('statamic.assets.image_manipulation.route', 'img'); } - } From ea457e9f5d6cb4106dd4f20e0c120d98b91efc27 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Thu, 14 May 2026 09:03:56 +0100 Subject: [PATCH 07/24] Fix hybrid URL builder mangling asset ID strings 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 --- src/Imaging/HybridUrlBuilder.php | 2 +- tests/Imaging/GlideTest.php | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/Imaging/HybridUrlBuilder.php b/src/Imaging/HybridUrlBuilder.php index fc262e95720..0ab41893a01 100644 --- a/src/Imaging/HybridUrlBuilder.php +++ b/src/Imaging/HybridUrlBuilder.php @@ -53,7 +53,7 @@ private function cacheSource(string $cachePath, array $params): void $mapping = match ($this->itemType()) { 'asset' => ['type' => 'asset', 'id' => $this->item->id(), 'params' => $params], 'url' => ['type' => 'url', 'url' => $this->item, 'params' => $params], - 'id' => ['type' => 'asset', 'id' => str_replace('/', '::', $this->item), 'params' => $params], + 'id' => ['type' => 'asset', 'id' => $this->item, 'params' => $params], 'path' => ['type' => 'path', 'path' => $this->item, 'params' => $params], default => throw new Exception('Cannot build a hybrid Glide URL without a URL, path, or asset.'), }; diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index af6aa820552..204e9fae1d2 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -243,6 +243,32 @@ public function hybrid_caching_url_has_no_query_params() $this->assertStringStartsWith('/img/', $url); } + #[Test] + #[DefineEnvironment('hybridCaching')] + public function hybrid_caching_generates_image_on_first_request_by_asset_id_string() + { + Storage::fake('test'); + $file = UploadedFile::fake()->image('hoff.jpg', 30, 60); + Storage::disk('test')->putFileAs('foo', $file, 'hoff.jpg'); + $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); + tap($container->makeAsset('foo/hoff.jpg'))->save(); + + $assetId = 'test_container::foo/hoff.jpg'; + $url = $this->app->make(UrlBuilder::class)->build($assetId, ['w' => 100]); + + $asset = Asset::find($assetId); + $resolver = new GlideCachePathResolver($this->app->make(Server::class)); + $expectedPath = $resolver->resolveForAsset($asset, ['w' => 100]); + + $this->assertFalse(Glide::cacheDisk()->exists($expectedPath)); + + $response = $this->get($url); + + $response->assertOk(); + $response->assertHeader('content-type', 'image/jpeg'); + $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); + } + #[Test] #[DefineEnvironment('hybridCaching')] public function hybrid_caching_generates_image_on_first_request_by_path() From b0b8138674d0b97acc3391c1e3f8c5d149236322 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 14 Sep 2026 20:34:47 +0100 Subject: [PATCH 08/24] throw `AssetNotFoundException` when a hybrid asset id cannot be resolved Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt --- src/Imaging/GlideCachePathResolver.php | 10 ------- src/Imaging/HybridUrlBuilder.php | 41 ++++++++++++++++++-------- tests/Tags/GlideTest.php | 24 +++++++++++++++ 3 files changed, 52 insertions(+), 23 deletions(-) diff --git a/src/Imaging/GlideCachePathResolver.php b/src/Imaging/GlideCachePathResolver.php index 62d897233b1..b4a778a212c 100644 --- a/src/Imaging/GlideCachePathResolver.php +++ b/src/Imaging/GlideCachePathResolver.php @@ -4,9 +4,7 @@ use League\Glide\Server; use Statamic\Contracts\Assets\Asset; -use Statamic\Facades\Asset as Assets; use Statamic\Facades\URL; -use Statamic\Support\Str; class GlideCachePathResolver { @@ -55,14 +53,6 @@ public function resolveForItem($item, array $params): string return $this->resolveForAsset($item, $params); } - if (is_string($item) && Str::contains($item, '::')) { - $asset = Assets::find($item); - - if ($asset) { - return $this->resolveForAsset($asset, $params); - } - } - if (is_string($item) && URL::isAbsolute($item)) { return $this->resolveForUrl($item, $params); } diff --git a/src/Imaging/HybridUrlBuilder.php b/src/Imaging/HybridUrlBuilder.php index 0ab41893a01..200e80ebe78 100644 --- a/src/Imaging/HybridUrlBuilder.php +++ b/src/Imaging/HybridUrlBuilder.php @@ -2,7 +2,6 @@ namespace Statamic\Imaging; -use Exception; use Statamic\Contracts\Assets\Asset; use Statamic\Facades\Asset as Assets; use Statamic\Facades\Glide; @@ -34,12 +33,16 @@ public function build($item, $params) { $this->item = $item; + if ($this->itemType() === 'id') { + $this->item = $this->findAsset($item); + } + if (isset($params['mark']) && $params['mark'] instanceof Asset) { $asset = $params['mark']; $params['mark'] = 'asset::'.Str::toBase64Url($asset->containerId().'/'.$asset->path()); } - $cachePath = $this->resolver->resolveForItem($item, $params); + $cachePath = $this->resolver->resolveForItem($this->item, $params); $this->cacheSource($cachePath, $params); @@ -48,29 +51,41 @@ public function build($item, $params) return URL::makeRelative(URL::prependSiteUrl($urlPath)); } + private function findAsset(string $id): Asset + { + if ($asset = Assets::find($id)) { + return $asset; + } + + throw new AssetNotFoundException( + sprintf('Could not generate a hybrid manipulated image URL from asset [%s]', $id) + ); + } + private function cacheSource(string $cachePath, array $params): void { $mapping = match ($this->itemType()) { 'asset' => ['type' => 'asset', 'id' => $this->item->id(), 'params' => $params], 'url' => ['type' => 'url', 'url' => $this->item, 'params' => $params], - 'id' => ['type' => 'asset', 'id' => $this->item, 'params' => $params], 'path' => ['type' => 'path', 'path' => $this->item, 'params' => $params], - default => throw new Exception('Cannot build a hybrid Glide URL without a URL, path, or asset.'), }; $mappingKey = 'hybrid::'.$cachePath; Glide::cacheStore()->forever($mappingKey, $mapping); - // Add to the asset manifest so clearAsset() cleans up the mapping too. - if ($mapping['type'] === 'asset') { - $manifestKey = ImageGenerator::assetCacheManifestKey( - Assets::find($mapping['id']) - ); - - $manifest = Glide::cacheStore()->get($manifestKey, []); - $manifest[] = $mappingKey; - Glide::cacheStore()->forever($manifestKey, array_unique($manifest)); + if ($this->item instanceof Asset) { + $this->addToAssetManifest($mappingKey); } } + + private function addToAssetManifest(string $mappingKey): void + { + $manifestKey = ImageGenerator::assetCacheManifestKey($this->item); + + $manifest = Glide::cacheStore()->get($manifestKey, []); + $manifest[] = $mappingKey; + + Glide::cacheStore()->forever($manifestKey, array_unique($manifest)); + } } diff --git a/tests/Tags/GlideTest.php b/tests/Tags/GlideTest.php index ebea97bf08d..d953dd5ed9d 100644 --- a/tests/Tags/GlideTest.php +++ b/tests/Tags/GlideTest.php @@ -80,6 +80,24 @@ public function it_doesnt_error_when_an_asset_id_cannot_be_resolved_and_images_a $this->assertSame('', (string) Parse::template($tag, trusted: true)); } + #[Test] + #[DefineEnvironment('hybridCaching')] + public function it_doesnt_error_when_a_url_cannot_be_resolved_to_an_asset_and_hybrid_caching_is_enabled() + { + $tag = '{{ glide src="http://external.com/bar (1).jpg" width="100" }}{{ url }}{{ /glide }}'; + + $this->assertSame('', (string) Parse::template($tag, trusted: true)); + } + + #[Test] + #[DefineEnvironment('hybridCaching')] + public function it_doesnt_error_when_an_asset_id_cannot_be_resolved_and_hybrid_caching_is_enabled() + { + $tag = '{{ glide src="test::bar.jpg" width="100" fit="crop_focal" }}{{ url }}{{ /glide }}'; + + $this->assertSame('', (string) Parse::template($tag, trusted: true)); + } + #[Test] public function it_outputs_a_data_url() { @@ -126,6 +144,12 @@ public function absoluteHttpsRouteUrl($app) $this->configureGlideCacheDiskWithUrl($app, 'https://localhost/glide'); } + public function hybridCaching($app) + { + $app['config']->set('statamic.assets.image_manipulation.cache', 'hybrid'); + $app['config']->set('statamic.assets.image_manipulation.cache_path', public_path('img')); + } + private function configureGlideCacheDiskWithUrl($app, $url, $cache = 'glide') { $app['config']->set('filesystems.disks.glide', [ From aa44e90d133eda688704a841558421ef2ef21518 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 14 Sep 2026 20:35:16 +0100 Subject: [PATCH 09/24] run hybrid caching tests with the default secure setting Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt --- tests/Imaging/GlideTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index 204e9fae1d2..2e84c7b23b3 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -473,7 +473,6 @@ protected function hybridCaching($app) { $app['config']->set('statamic.assets.image_manipulation.cache', 'hybrid'); $app['config']->set('statamic.assets.image_manipulation.cache_path', storage_path('glide-test-cache')); - $app['config']->set('statamic.assets.image_manipulation.secure', false); $app['config']->set('statamic.assets.image_manipulation.route', 'img'); } } From 56f1f5bccb8601f7f69d498c340f5937bb6b5214 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 14 Sep 2026 20:35:50 +0100 Subject: [PATCH 10/24] only register the cache path glide route in hybrid mode Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt --- routes/glide.php | 7 +++++-- tests/Imaging/GlideTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/routes/glide.php b/routes/glide.php index d78da3caee1..d3ffc105208 100644 --- a/routes/glide.php +++ b/routes/glide.php @@ -10,8 +10,11 @@ return trim(URL::makeRelative($site->url()), '/'); })->unique()->each(function ($sitePrefix) { Route::group(['prefix' => $sitePrefix.'/'.Glide::route()], function () { - Route::get('/asset/{container}/{path?}', [GlideController::class, 'generateByAsset'])->where('path', '.*'); - Route::get('/http/{url}/{filename?}', [GlideController::class, 'generateByUrl']); + if (! Glide::isUsingHybridCaching()) { + Route::get('/asset/{container}/{path?}', [GlideController::class, 'generateByAsset'])->where('path', '.*'); + Route::get('/http/{url}/{filename?}', [GlideController::class, 'generateByUrl']); + } + Route::get('{path}', [GlideController::class, 'generateByPath'])->where('path', '.*'); }); }); diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index 2e84c7b23b3..b2cd110eee2 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -5,6 +5,7 @@ use Illuminate\Cache\FileStore; use Illuminate\Contracts\Cache\Repository; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Storage; use InvalidArgumentException; use League\Flysystem\Local\LocalFilesystemAdapter; @@ -14,6 +15,7 @@ use Statamic\Contracts\Imaging\UrlBuilder; use Statamic\Facades\Asset; use Statamic\Facades\AssetContainer; +use Statamic\Facades\Config; use Statamic\Facades\File; use Statamic\Facades\Glide; use Statamic\Facades\Path; @@ -293,6 +295,28 @@ public function hybrid_caching_generates_image_on_first_request_by_path() @unlink(public_path($imagePath)); } + #[Test] + #[DefineEnvironment('hybridCaching')] + public function hybrid_caching_only_registers_the_cache_path_route() + { + Storage::fake('test'); + $file = UploadedFile::fake()->image('hoff.jpg', 30, 60); + Storage::disk('test')->putFileAs('foo', $file, 'hoff.jpg'); + $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); + tap($container->makeAsset('foo/hoff.jpg'))->save(); + + $uris = collect(Route::getRoutes()->getRoutes())->map->uri(); + + $this->assertContains('img/{path}', $uris->all()); + $this->assertNotContains('img/asset/{container}/{path?}', $uris->all()); + $this->assertNotContains('img/http/{url}/{filename?}', $uris->all()); + + $asset = Asset::find('test_container::foo/hoff.jpg'); + $signedUrl = (new GlideUrlBuilder(['key' => Config::getAppKey(), 'route' => '/img']))->build($asset, ['w' => 100]); + + $this->get($signedUrl)->assertNotFound(); + } + #[Test] public function cache_true_without_cache_path_will_throw_exception() { From 8fe77e4cb733ea563f49f9c5a1759e683a4f4b47 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 14 Sep 2026 20:36:45 +0100 Subject: [PATCH 11/24] stop `RemoteUrlValidator::parse()` resolving dns Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt --- src/Imaging/ImageGenerator.php | 6 ++++- src/Imaging/RemoteUrlValidator.php | 17 +++++++------ tests/Imaging/RemoteUrlValidatorTest.php | 32 ++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/src/Imaging/ImageGenerator.php b/src/Imaging/ImageGenerator.php index 989fb47d1b3..842a3fdc5bf 100644 --- a/src/Imaging/ImageGenerator.php +++ b/src/Imaging/ImageGenerator.php @@ -373,6 +373,10 @@ private function guzzleSourceFilesystem($base) private function parseUrl($url) { - return app(RemoteUrlValidator::class)->parse($url); + $validator = app(RemoteUrlValidator::class); + + $validator->validate($url); + + return $validator->parse($url); } } diff --git a/src/Imaging/RemoteUrlValidator.php b/src/Imaging/RemoteUrlValidator.php index 73e8681a16e..dbd4118bac6 100644 --- a/src/Imaging/RemoteUrlValidator.php +++ b/src/Imaging/RemoteUrlValidator.php @@ -14,9 +14,13 @@ public function __construct(?callable $resolver = null) $this->resolver = $resolver ?? fn ($host) => dns_get_record($host, DNS_A + DNS_AAAA) ?: []; } + /** + * Parse the URL without resolving its host. Safe to call while rendering + * templates, but call validate() or resolve() before fetching anything. + */ public function parse($url) { - $components = $this->validatedComponents($url); + $components = $this->components($url); return [ 'path' => Str::after($components['path'], '/'), @@ -27,7 +31,7 @@ public function parse($url) public function validate($url) { - $this->parse($url); + $this->resolve($url); } /** @@ -38,16 +42,16 @@ public function validate($url) */ public function resolve($url) { - $components = $this->validatedComponents($url); + $components = $this->components($url); return [ 'host' => $components['host'], 'port' => $components['port'], - 'ips' => $components['ips'], + 'ips' => $this->ensureHostResolvesToPublicIps($components['host']), ]; } - protected function validatedComponents($url) + protected function components($url) { $parsed = parse_url($url); @@ -81,8 +85,6 @@ protected function validatedComponents($url) throw new InvalidRemoteUrlException('Invalid URL host.'); } - $ips = $this->ensureHostResolvesToPublicIps($host); - return [ 'scheme' => $scheme, 'host' => $host, @@ -90,7 +92,6 @@ protected function validatedComponents($url) 'port_suffix' => isset($parsed['port']) ? ':'.$parsed['port'] : '', 'path' => $parsed['path'] ?? '/', 'query' => $parsed['query'] ?? null, - 'ips' => $ips, ]; } diff --git a/tests/Imaging/RemoteUrlValidatorTest.php b/tests/Imaging/RemoteUrlValidatorTest.php index fb546113660..38c393d05ba 100644 --- a/tests/Imaging/RemoteUrlValidatorTest.php +++ b/tests/Imaging/RemoteUrlValidatorTest.php @@ -126,6 +126,38 @@ public function it_parses_the_base_path_and_query() ], $this->validator()->parse('https://example.com/foo/bar.jpg?w=100')); } + #[Test] + public function it_parses_without_resolving_the_host() + { + $parsed = $this->validator(function () { + throw new \Exception('The resolver should not be called when parsing.'); + })->parse('https://unknown.test/foo.jpg?w=100'); + + $this->assertSame([ + 'path' => 'foo.jpg', + 'base' => 'https://unknown.test', + 'query' => 'w=100', + ], $parsed); + } + + #[Test] + public function it_still_rejects_malformed_urls_when_parsing() + { + $this->expectException(InvalidRemoteUrlException::class); + $this->expectExceptionMessage('URLs with credentials are not allowed.'); + + $this->validator()->parse('https://user:pass@example.com/foo.jpg'); + } + + #[Test] + public function it_resolves_the_host_when_validating() + { + $this->expectException(InvalidRemoteUrlException::class); + $this->expectExceptionMessage('Unable to resolve URL host.'); + + $this->validator()->validate('https://unknown.test/foo.jpg'); + } + #[Test] public function it_includes_an_explicit_port_in_the_parsed_base() { From a1de8534f04115664142a8667c95fe1158f99c32 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 14 Sep 2026 20:38:15 +0100 Subject: [PATCH 12/24] predict watermarked cache paths using the `mark` param glide hashes Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt --- src/Imaging/GlideCachePathResolver.php | 4 +++ src/Imaging/GlideUrlBuilder.php | 6 +--- src/Imaging/HybridUrlBuilder.php | 8 +---- src/Imaging/ImageGenerator.php | 50 ++++++++++++++++++-------- src/Imaging/ImageUrlBuilder.php | 10 ++++++ tests/Imaging/GlideTest.php | 23 ++++++++++++ 6 files changed, 75 insertions(+), 26 deletions(-) diff --git a/src/Imaging/GlideCachePathResolver.php b/src/Imaging/GlideCachePathResolver.php index b4a778a212c..f00a20d35bf 100644 --- a/src/Imaging/GlideCachePathResolver.php +++ b/src/Imaging/GlideCachePathResolver.php @@ -62,6 +62,10 @@ public function resolveForItem($item, array $params): string private function resolve(string $image, array $params, string $sourcePathPrefix, string $cachePathPrefix, ?Asset $asset = null): string { + if (isset($params['mark'])) { + $params['mark'] = ImageGenerator::watermarkParam($params['mark']); + } + $origSourcePrefix = $this->server->getSourcePathPrefix(); $origCachePrefix = $this->server->getCachePathPrefix(); $origDefaults = $this->server->getDefaults(); diff --git a/src/Imaging/GlideUrlBuilder.php b/src/Imaging/GlideUrlBuilder.php index d3cbedbc832..247e7b4af2a 100644 --- a/src/Imaging/GlideUrlBuilder.php +++ b/src/Imaging/GlideUrlBuilder.php @@ -4,7 +4,6 @@ use Exception; use League\Glide\Urls\UrlBuilderFactory; -use Statamic\Contracts\Assets\Asset; use Statamic\Facades\URL; use Statamic\Support\Str; @@ -58,10 +57,7 @@ public function build($item, $params) $path .= Str::ensureLeft(URL::encode($filename), '/'); } - if (isset($params['mark']) && $params['mark'] instanceof Asset) { - $asset = $params['mark']; - $params['mark'] = 'asset::'.Str::toBase64Url($asset->containerId().'/'.$asset->path()); - } + $params = $this->withEncodedWatermark($params); return URL::makeRelative( URL::prependSiteUrl($builder->getUrl($path, $params)) diff --git a/src/Imaging/HybridUrlBuilder.php b/src/Imaging/HybridUrlBuilder.php index 200e80ebe78..56deb9c47cc 100644 --- a/src/Imaging/HybridUrlBuilder.php +++ b/src/Imaging/HybridUrlBuilder.php @@ -6,7 +6,6 @@ use Statamic\Facades\Asset as Assets; use Statamic\Facades\Glide; use Statamic\Facades\URL; -use Statamic\Support\Str; class HybridUrlBuilder extends ImageUrlBuilder { @@ -37,14 +36,9 @@ public function build($item, $params) $this->item = $this->findAsset($item); } - if (isset($params['mark']) && $params['mark'] instanceof Asset) { - $asset = $params['mark']; - $params['mark'] = 'asset::'.Str::toBase64Url($asset->containerId().'/'.$asset->path()); - } - $cachePath = $this->resolver->resolveForItem($this->item, $params); - $this->cacheSource($cachePath, $params); + $this->cacheSource($cachePath, $this->withEncodedWatermark($params)); $urlPath = URL::tidy($this->options['route'].'/'.$cachePath, withTrailingSlash: false); diff --git a/src/Imaging/ImageGenerator.php b/src/Imaging/ImageGenerator.php index 842a3fdc5bf..d7561292d0a 100644 --- a/src/Imaging/ImageGenerator.php +++ b/src/Imaging/ImageGenerator.php @@ -227,32 +227,54 @@ public function toEleven() private function setUpWatermark($watermark): string { - [$filesystem, $param] = $this->getWatermarkFilesystemAndParam($watermark); + $watermark = static::decodeWatermark($watermark); - $this->updateWatermarkFilesystem($filesystem); + $this->updateWatermarkFilesystem($this->watermarkFilesystem($watermark)); - return $param; + return static::watermarkParam($watermark); } - private function getWatermarkFilesystemAndParam($item) + /** + * The `mark` param as Glide will see it, which is what the cache path is hashed from. + */ + public static function watermarkParam($watermark): string { - if (is_string($item) && Str::startsWith($item, 'asset::')) { - $decoded = Str::fromBase64Url(Str::after($item, 'asset::')); - [$container, $path] = explode('/', $decoded, 2); - $item = Assets::find($container.'::'.$path); + $watermark = static::decodeWatermark($watermark); + + if ($watermark instanceof Asset) { + return $watermark->path(); } - if ($item instanceof Asset) { - return [$item->disk()->filesystem()->getDriver(), $item->path()]; + if (URL::isAbsolute($watermark)) { + return app(RemoteUrlValidator::class)->parse($watermark)['path']; } - if (URL::isAbsolute($item)) { - $parsed = $this->parseUrl($item); + return $watermark; + } + + private static function decodeWatermark($watermark) + { + if (! is_string($watermark) || ! Str::startsWith($watermark, 'asset::')) { + return $watermark; + } + + $decoded = Str::fromBase64Url(Str::after($watermark, 'asset::')); + [$container, $path] = explode('/', $decoded, 2); + + return Assets::find($container.'::'.$path); + } + + private function watermarkFilesystem($watermark) + { + if ($watermark instanceof Asset) { + return $watermark->disk()->filesystem()->getDriver(); + } - return [$this->guzzleSourceFilesystem($parsed['base']), $parsed['path']]; + if (URL::isAbsolute($watermark)) { + return $this->guzzleSourceFilesystem($this->parseUrl($watermark)['base']); } - return [$this->pathSourceFilesystem(), $item]; + return $this->pathSourceFilesystem(); } private function updateWatermarkFilesystem($filesystem) diff --git a/src/Imaging/ImageUrlBuilder.php b/src/Imaging/ImageUrlBuilder.php index 1f3fe8ee149..d755ace1fe8 100644 --- a/src/Imaging/ImageUrlBuilder.php +++ b/src/Imaging/ImageUrlBuilder.php @@ -31,4 +31,14 @@ public function itemType() return 'path'; } + + protected function withEncodedWatermark(array $params): array + { + if (isset($params['mark']) && $params['mark'] instanceof Asset) { + $asset = $params['mark']; + $params['mark'] = 'asset::'.Str::toBase64Url($asset->containerId().'/'.$asset->path()); + } + + return $params; + } } diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index b2cd110eee2..7d5788e6ee0 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -174,6 +174,29 @@ public function hybrid_caching_generates_image_on_first_request() $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); } + #[Test] + #[DefineEnvironment('hybridCaching')] + public function hybrid_caching_generates_watermarked_image_at_the_predicted_path() + { + Storage::fake('test'); + Storage::disk('test')->putFileAs('foo', UploadedFile::fake()->image('hoff.jpg', 30, 60), 'hoff.jpg'); + Storage::disk('test')->putFileAs('foo', UploadedFile::fake()->image('mark.png', 10, 10), 'mark.png'); + $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); + $asset = tap($container->makeAsset('foo/hoff.jpg'))->save(); + $watermark = tap($container->makeAsset('foo/mark.png'))->save(); + + $url = $this->app->make(UrlBuilder::class)->build($asset, ['w' => 100, 'mark' => $watermark]); + $expectedPath = Str::after($url, '/img/'); + + $this->assertFalse(Glide::cacheDisk()->exists($expectedPath)); + + $response = $this->get($url); + + $response->assertOk(); + $response->streamedContent(); + $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); + } + #[Test] #[DefineEnvironment('hybridCaching')] public function hybrid_caching_serves_existing_cached_file() From 773e2eb60029212ce9d2bc445ceb9a0b846d187a Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 14 Sep 2026 20:38:38 +0100 Subject: [PATCH 13/24] return 404 when a hybrid glide path traverses outside the cache Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt --- src/Http/Controllers/GlideController.php | 12 +++++++++++- tests/Imaging/GlideTest.php | 9 +++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Http/Controllers/GlideController.php b/src/Http/Controllers/GlideController.php index 2e79f48a51b..d5896659bd8 100644 --- a/src/Http/Controllers/GlideController.php +++ b/src/Http/Controllers/GlideController.php @@ -4,6 +4,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; +use League\Flysystem\PathTraversalDetected; use League\Flysystem\UnableToReadFile; use League\Glide\Server; use League\Glide\Signatures\SignatureException; @@ -93,7 +94,7 @@ public function generateByUrl($url) */ private function generateOnDemand(string $path) { - if (Glide::cacheDisk()->exists($path)) { + if ($this->existsInCache($path)) { Log::debug('Glide hybrid cache loaded ['.$path.'] If you are seeing this, your server rewrite rules have not been set up correctly.'); return $this->createResponse($path); @@ -115,6 +116,15 @@ private function generateOnDemand(string $path) return $this->createResponse($this->ensureGenerated($type, $item, $params)); } + private function existsInCache(string $path): bool + { + try { + return Glide::cacheDisk()->exists($path); + } catch (PathTraversalDetected $e) { + throw new NotFoundHttpException; + } + } + /** * Forget any stale cache store entry, then generate the image. * diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index 7d5788e6ee0..0438869ebe8 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -219,6 +219,15 @@ public function hybrid_caching_returns_404_when_no_mapping_exists() $response->assertNotFound(); } + #[Test] + #[DefineEnvironment('hybridCaching')] + public function hybrid_caching_returns_404_when_path_traverses_outside_the_cache() + { + $response = $this->get('/img/../../.env'); + + $response->assertNotFound(); + } + #[Test] #[DefineEnvironment('hybridCaching')] public function hybrid_caching_regenerates_when_file_deleted_but_mapping_exists() From 0088bae546146b944df95304e69ba7bb2ac3c4ce Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 14 Sep 2026 20:40:36 +0100 Subject: [PATCH 14/24] warn when the hybrid cache path is not served by the glide route Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt --- src/Facades/Glide.php | 1 + src/Imaging/GlideManager.php | 15 +++++++ src/Providers/GlideServiceProvider.php | 8 ++++ tests/Imaging/GlideTest.php | 60 +++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/Facades/Glide.php b/src/Facades/Glide.php index 39b77544752..3e5c92df1e1 100644 --- a/src/Facades/Glide.php +++ b/src/Facades/Glide.php @@ -11,6 +11,7 @@ * @method static bool shouldServeDirectly() * @method static bool shouldServeByHttp() * @method static bool isUsingHybridCaching() + * @method static bool cachePathIsServedByRoute() * @method static string route() * @method static string url() * @method static \Illuminate\Contracts\Cache\Repository cacheStore() diff --git a/src/Imaging/GlideManager.php b/src/Imaging/GlideManager.php index b5ca3de7e14..755bd4d77b1 100644 --- a/src/Imaging/GlideManager.php +++ b/src/Imaging/GlideManager.php @@ -9,6 +9,7 @@ use Statamic\Events\GlideAssetCacheCleared; use Statamic\Facades\Config; use Statamic\Facades\Image; +use Statamic\Facades\Path; use Statamic\Facades\URL; use Statamic\Imaging\ResponseFactory as LaravelResponseFactory; use Statamic\Support\Str; @@ -106,6 +107,20 @@ public function route() return Config::get('statamic.assets.image_manipulation.route'); } + public function cachePathIsServedByRoute() + { + $publicPath = Path::tidy(public_path()); + $cachePath = Path::tidy(Config::get('statamic.assets.image_manipulation.cache_path')); + + if (! Str::startsWith($cachePath, $publicPath)) { + return false; + } + + $servedPath = trim(Str::after($cachePath, $publicPath), '/'); + + return $servedPath === trim(URL::makeRelative($this->route()), '/'); + } + public function url() { $url = $this->wantsCustomFilesystem() diff --git a/src/Providers/GlideServiceProvider.php b/src/Providers/GlideServiceProvider.php index d58720b42bb..ae9ad32c5ef 100644 --- a/src/Providers/GlideServiceProvider.php +++ b/src/Providers/GlideServiceProvider.php @@ -2,6 +2,7 @@ namespace Statamic\Providers; +use Illuminate\Support\Facades\Log; use Illuminate\Support\ServiceProvider; use League\Glide\Server; use Statamic\Contracts\Imaging\ImageManipulator; @@ -49,6 +50,13 @@ public function register() }); } + public function boot() + { + if (Glide::isUsingHybridCaching() && ! Glide::cachePathIsServedByRoute()) { + Log::warning('Glide hybrid caching: the image_manipulation.cache_path must live at the image_manipulation.route inside the public directory, otherwise cached images are never served directly by the web server.'); + } + } + private function getBuilder() { if (Glide::isUsingHybridCaching()) { diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index 0438869ebe8..6c0f609b55c 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -5,12 +5,14 @@ use Illuminate\Cache\FileStore; use Illuminate\Contracts\Cache\Repository; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Storage; use InvalidArgumentException; use League\Flysystem\Local\LocalFilesystemAdapter; use League\Glide\Server; use Orchestra\Testbench\Attributes\DefineEnvironment; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Contracts\Imaging\UrlBuilder; use Statamic\Facades\Asset; @@ -24,6 +26,7 @@ use Statamic\Imaging\HybridUrlBuilder; use Statamic\Imaging\ImageGenerator; use Statamic\Imaging\StaticUrlBuilder; +use Statamic\Providers\GlideServiceProvider; use Statamic\Support\Str; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -36,7 +39,7 @@ public function tearDown(): void { $this->clearGlideCache(); - if (file_exists($path = storage_path('glide-test-cache'))) { + if (file_exists($path = public_path('img'))) { File::delete($path); } @@ -122,6 +125,59 @@ public function hybrid_caching_is_detected_as_half_measure() $this->assertFalse(Glide::shouldServeByHttp()); } + #[Test] + #[DataProvider('cachePathServedByRouteProvider')] + public function hybrid_caching_knows_when_the_cache_path_is_served_by_the_route($route, $cachePath, $expected) + { + config([ + 'statamic.assets.image_manipulation.cache' => 'hybrid', + 'statamic.assets.image_manipulation.route' => $route, + 'statamic.assets.image_manipulation.cache_path' => $cachePath(), + ]); + + $this->assertSame($expected, Glide::cachePathIsServedByRoute()); + } + + public static function cachePathServedByRouteProvider() + { + return [ + 'matching' => ['img', fn () => public_path('img'), true], + 'matching with slashes' => ['/img/', fn () => public_path('img/'), true], + 'matching absolute route' => ['http://localhost/img', fn () => public_path('img'), true], + 'matching nested' => ['assets/img', fn () => public_path('assets/img'), true], + 'different directory' => ['img', fn () => public_path('imgcache'), false], + 'outside public' => ['img', fn () => storage_path('img'), false], + ]; + } + + #[Test] + public function hybrid_caching_warns_when_the_cache_path_is_not_served_by_the_route() + { + config([ + 'statamic.assets.image_manipulation.cache' => 'hybrid', + 'statamic.assets.image_manipulation.route' => 'img', + 'statamic.assets.image_manipulation.cache_path' => public_path('imgcache'), + ]); + + Log::shouldReceive('warning')->once()->withArgs(fn ($message) => str_contains($message, 'hybrid')); + + (new GlideServiceProvider($this->app))->boot(); + } + + #[Test] + public function hybrid_caching_does_not_warn_when_the_cache_path_is_served_by_the_route() + { + config([ + 'statamic.assets.image_manipulation.cache' => 'hybrid', + 'statamic.assets.image_manipulation.route' => 'img', + 'statamic.assets.image_manipulation.cache_path' => public_path('img'), + ]); + + Log::shouldReceive('warning')->never(); + + (new GlideServiceProvider($this->app))->boot(); + } + #[Test] public function hybrid_caching_predicted_path_matches_generated_path() { @@ -528,7 +584,7 @@ private function createImageManipulations($containerHandle, $assetPath, $manipul protected function hybridCaching($app) { $app['config']->set('statamic.assets.image_manipulation.cache', 'hybrid'); - $app['config']->set('statamic.assets.image_manipulation.cache_path', storage_path('glide-test-cache')); + $app['config']->set('statamic.assets.image_manipulation.cache_path', public_path('img')); $app['config']->set('statamic.assets.image_manipulation.route', 'img'); } } From ff8fb4bdfb52facf33cd519a6cc1d90e8cd29331 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 14 Sep 2026 20:41:16 +0100 Subject: [PATCH 15/24] test hybrid caching of remote urls Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt --- tests/Imaging/GlideTest.php | 73 +++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index 6c0f609b55c..cf3b6b5f3f3 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -2,6 +2,9 @@ namespace Tests\Imaging; +use GuzzleHttp\Client; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\Psr7\Response; use Illuminate\Cache\FileStore; use Illuminate\Contracts\Cache\Repository; use Illuminate\Http\UploadedFile; @@ -25,6 +28,7 @@ use Statamic\Imaging\GlideUrlBuilder; use Statamic\Imaging\HybridUrlBuilder; use Statamic\Imaging\ImageGenerator; +use Statamic\Imaging\RemoteUrlValidator; use Statamic\Imaging\StaticUrlBuilder; use Statamic\Providers\GlideServiceProvider; use Statamic\Support\Str; @@ -205,6 +209,35 @@ public function hybrid_caching_predicted_path_matches_generated_path() $this->assertEquals($generatedPath, $predictedPath); } + #[Test] + #[DataProvider('remoteUrlProvider')] + public function hybrid_caching_predicted_path_matches_generated_path_for_a_url($url) + { + config(['statamic.assets.image_manipulation.cache' => false]); + + $this->bindRemoteImage(); + + $server = $this->app->make(Server::class); + $resolver = new GlideCachePathResolver($server); + + $params = ['w' => 100, 'h' => 50]; + + $predictedPath = $resolver->resolveForUrl($url, $params); + + $generator = new ImageGenerator($server); + $generatedPath = $generator->generateByUrl($url, $params); + + $this->assertEquals($generatedPath, $predictedPath); + } + + public static function remoteUrlProvider() + { + return [ + 'plain' => ['https://example.com/foo/hoff.jpg'], + 'with query string' => ['https://example.com/foo/hoff.jpg?query=david'], + ]; + } + #[Test] #[DefineEnvironment('hybridCaching')] public function hybrid_caching_generates_image_on_first_request() @@ -359,6 +392,34 @@ public function hybrid_caching_generates_image_on_first_request_by_asset_id_stri $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); } + #[Test] + #[DefineEnvironment('hybridCaching')] + public function hybrid_caching_generates_image_on_first_request_by_url() + { + $this->app->bind(RemoteUrlValidator::class, fn () => new RemoteUrlValidator(function () { + throw new \Exception('The host should not be resolved when building a URL.'); + })); + + $url = $this->app->make(UrlBuilder::class)->build('https://example.com/foo/hoff.jpg', ['w' => 100]); + $expectedPath = Str::after($url, '/img/'); + + $this->assertStringStartsWith('/img/http/foo/hoff.jpg/', $url); + $this->assertSame([ + 'type' => 'url', + 'url' => 'https://example.com/foo/hoff.jpg', + 'params' => ['w' => 100], + ], Glide::cacheStore()->get('hybrid::'.$expectedPath)); + $this->assertFalse(Glide::cacheDisk()->exists($expectedPath)); + + $this->bindRemoteImage(); + + $response = $this->get($url); + + $response->assertOk(); + $response->assertHeader('content-type', 'image/jpeg'); + $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); + } + #[Test] #[DefineEnvironment('hybridCaching')] public function hybrid_caching_generates_image_on_first_request_by_path() @@ -581,6 +642,18 @@ private function createImageManipulations($containerHandle, $assetPath, $manipul return collect(array_merge([$manifestCacheKey], $manifest)); } + private function bindRemoteImage() + { + $this->app->bind(RemoteUrlValidator::class, fn () => new RemoteUrlValidator(fn () => [['ip' => '93.184.216.34']])); + + $this->app->bind('statamic.imaging.guzzle', function () { + $file = UploadedFile::fake()->image('', 30, 60); + $response = new Response(200, [], file_get_contents($file->getPathname())); + + return new Client(['handler' => new MockHandler([$response, $response, $response])]); + }); + } + protected function hybridCaching($app) { $app['config']->set('statamic.assets.image_manipulation.cache', 'hybrid'); From 96868a3484f8fd78296e5055168be2a04b3e766e Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Mon, 14 Sep 2026 20:42:37 +0100 Subject: [PATCH 16/24] tidy up hybrid caching tests and builder Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01U772LC9aHMR8DvJ76tBDYt --- src/Imaging/HybridUrlBuilder.php | 8 +--- tests/Imaging/GlideTest.php | 69 +++++++++++--------------------- 2 files changed, 24 insertions(+), 53 deletions(-) diff --git a/src/Imaging/HybridUrlBuilder.php b/src/Imaging/HybridUrlBuilder.php index 56deb9c47cc..32a518af58b 100644 --- a/src/Imaging/HybridUrlBuilder.php +++ b/src/Imaging/HybridUrlBuilder.php @@ -9,14 +9,8 @@ class HybridUrlBuilder extends ImageUrlBuilder { - protected GlideCachePathResolver $resolver; - - protected array $options; - - public function __construct(GlideCachePathResolver $resolver, array $options = []) + public function __construct(private GlideCachePathResolver $resolver, private array $options = []) { - $this->resolver = $resolver; - $this->options = $options; } /** diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index cf3b6b5f3f3..947eb3f46a6 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -17,6 +17,7 @@ use Orchestra\Testbench\Attributes\DefineEnvironment; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Statamic\Contracts\Assets\Asset as AssetContract; use Statamic\Contracts\Imaging\UrlBuilder; use Statamic\Facades\Asset; use Statamic\Facades\AssetContainer; @@ -190,11 +191,7 @@ public function hybrid_caching_predicted_path_matches_generated_path() 'statamic.assets.auto_crop' => true, ]); - Storage::fake('test'); - $file = UploadedFile::fake()->image('hoff.jpg', 30, 60); - Storage::disk('test')->putFileAs('foo', $file, 'hoff.jpg'); - $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); - $asset = tap($container->makeAsset('foo/hoff.jpg'))->save(); + $asset = $this->createAsset(); $server = $this->app->make(Server::class); $resolver = new GlideCachePathResolver($server); @@ -242,13 +239,7 @@ public static function remoteUrlProvider() #[DefineEnvironment('hybridCaching')] public function hybrid_caching_generates_image_on_first_request() { - Storage::fake('test'); - $file = UploadedFile::fake()->image('hoff.jpg', 30, 60); - Storage::disk('test')->putFileAs('foo', $file, 'hoff.jpg'); - $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); - tap($container->makeAsset('foo/hoff.jpg'))->save(); - - $asset = Asset::find('test_container::foo/hoff.jpg'); + $asset = $this->createAsset(); $url = $this->app->make(UrlBuilder::class)->build($asset, ['w' => 100]); $resolver = new GlideCachePathResolver($this->app->make(Server::class)); @@ -267,12 +258,8 @@ public function hybrid_caching_generates_image_on_first_request() #[DefineEnvironment('hybridCaching')] public function hybrid_caching_generates_watermarked_image_at_the_predicted_path() { - Storage::fake('test'); - Storage::disk('test')->putFileAs('foo', UploadedFile::fake()->image('hoff.jpg', 30, 60), 'hoff.jpg'); - Storage::disk('test')->putFileAs('foo', UploadedFile::fake()->image('mark.png', 10, 10), 'mark.png'); - $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); - $asset = tap($container->makeAsset('foo/hoff.jpg'))->save(); - $watermark = tap($container->makeAsset('foo/mark.png'))->save(); + $asset = $this->createAsset('foo/hoff.jpg'); + $watermark = $this->createAsset('foo/mark.png'); $url = $this->app->make(UrlBuilder::class)->build($asset, ['w' => 100, 'mark' => $watermark]); $expectedPath = Str::after($url, '/img/'); @@ -321,13 +308,7 @@ public function hybrid_caching_returns_404_when_path_traverses_outside_the_cache #[DefineEnvironment('hybridCaching')] public function hybrid_caching_regenerates_when_file_deleted_but_mapping_exists() { - Storage::fake('test'); - $file = UploadedFile::fake()->image('hoff.jpg', 30, 60); - Storage::disk('test')->putFileAs('foo', $file, 'hoff.jpg'); - $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); - tap($container->makeAsset('foo/hoff.jpg'))->save(); - - $asset = Asset::find('test_container::foo/hoff.jpg'); + $asset = $this->createAsset(); $url = $this->app->make(UrlBuilder::class)->build($asset, ['w' => 100]); $resolver = new GlideCachePathResolver($this->app->make(Server::class)); @@ -353,13 +334,7 @@ public function hybrid_caching_regenerates_when_file_deleted_but_mapping_exists( #[DefineEnvironment('hybridCaching')] public function hybrid_caching_url_has_no_query_params() { - Storage::fake('test'); - $file = UploadedFile::fake()->image('hoff.jpg', 30, 60); - Storage::disk('test')->putFileAs('foo', $file, 'hoff.jpg'); - $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); - tap($container->makeAsset('foo/hoff.jpg'))->save(); - - $asset = Asset::find('test_container::foo/hoff.jpg'); + $asset = $this->createAsset(); $url = $this->app->make(UrlBuilder::class)->build($asset, ['w' => 100]); $this->assertStringNotContainsString('?', $url); @@ -370,16 +345,10 @@ public function hybrid_caching_url_has_no_query_params() #[DefineEnvironment('hybridCaching')] public function hybrid_caching_generates_image_on_first_request_by_asset_id_string() { - Storage::fake('test'); - $file = UploadedFile::fake()->image('hoff.jpg', 30, 60); - Storage::disk('test')->putFileAs('foo', $file, 'hoff.jpg'); - $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); - tap($container->makeAsset('foo/hoff.jpg'))->save(); + $asset = $this->createAsset(); - $assetId = 'test_container::foo/hoff.jpg'; - $url = $this->app->make(UrlBuilder::class)->build($assetId, ['w' => 100]); + $url = $this->app->make(UrlBuilder::class)->build('test_container::foo/hoff.jpg', ['w' => 100]); - $asset = Asset::find($assetId); $resolver = new GlideCachePathResolver($this->app->make(Server::class)); $expectedPath = $resolver->resolveForAsset($asset, ['w' => 100]); @@ -448,11 +417,7 @@ public function hybrid_caching_generates_image_on_first_request_by_path() #[DefineEnvironment('hybridCaching')] public function hybrid_caching_only_registers_the_cache_path_route() { - Storage::fake('test'); - $file = UploadedFile::fake()->image('hoff.jpg', 30, 60); - Storage::disk('test')->putFileAs('foo', $file, 'hoff.jpg'); - $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); - tap($container->makeAsset('foo/hoff.jpg'))->save(); + $asset = $this->createAsset(); $uris = collect(Route::getRoutes()->getRoutes())->map->uri(); @@ -460,7 +425,6 @@ public function hybrid_caching_only_registers_the_cache_path_route() $this->assertNotContains('img/asset/{container}/{path?}', $uris->all()); $this->assertNotContains('img/http/{url}/{filename?}', $uris->all()); - $asset = Asset::find('test_container::foo/hoff.jpg'); $signedUrl = (new GlideUrlBuilder(['key' => Config::getAppKey(), 'route' => '/img']))->build($asset, ['w' => 100]); $this->get($signedUrl)->assertNotFound(); @@ -642,6 +606,19 @@ private function createImageManipulations($containerHandle, $assetPath, $manipul return collect(array_merge([$manifestCacheKey], $manifest)); } + private function createAsset(string $path = 'foo/hoff.jpg'): AssetContract + { + if (! $container = AssetContainer::find('test_container')) { + Storage::fake('test'); + $container = tap(AssetContainer::make('test_container')->disk('test'))->save(); + } + + $file = UploadedFile::fake()->image(basename($path), 30, 60); + Storage::disk('test')->putFileAs(dirname($path), $file, basename($path)); + + return tap($container->makeAsset($path))->save(); + } + private function bindRemoteImage() { $this->app->bind(RemoteUrlValidator::class, fn () => new RemoteUrlValidator(fn () => [['ip' => '93.184.216.34']])); From 08f295608d4424502429f6d88f2056a279000e1e Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 16 Sep 2026 09:00:06 +0100 Subject: [PATCH 17/24] assert hybrid urls point at the file the generator writes Co-Authored-By: Claude Opus 5 (1M context) --- tests/Imaging/GlideTest.php | 167 ++++++++++-------------------------- 1 file changed, 44 insertions(+), 123 deletions(-) diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index 947eb3f46a6..826096e07c4 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -44,9 +44,9 @@ public function tearDown(): void { $this->clearGlideCache(); - if (file_exists($path = public_path('img'))) { - File::delete($path); - } + File::delete(public_path('img')); + File::delete(public_path('test-path.jpg')); + File::delete(public_path('mark.png')); parent::tearDown(); } @@ -183,94 +183,50 @@ public function hybrid_caching_does_not_warn_when_the_cache_path_is_served_by_th (new GlideServiceProvider($this->app))->boot(); } - #[Test] - public function hybrid_caching_predicted_path_matches_generated_path() - { - config([ - 'statamic.assets.image_manipulation.cache' => false, - 'statamic.assets.auto_crop' => true, - ]); - - $asset = $this->createAsset(); - - $server = $this->app->make(Server::class); - $resolver = new GlideCachePathResolver($server); - - $params = ['w' => 100, 'h' => 50]; - - $predictedPath = $resolver->resolveForAsset($asset, $params); - - $generator = new ImageGenerator($server); - $generatedPath = $generator->generateByAsset($asset, $params); - - $this->assertEquals($generatedPath, $predictedPath); - } - - #[Test] - #[DataProvider('remoteUrlProvider')] - public function hybrid_caching_predicted_path_matches_generated_path_for_a_url($url) - { - config(['statamic.assets.image_manipulation.cache' => false]); - - $this->bindRemoteImage(); - - $server = $this->app->make(Server::class); - $resolver = new GlideCachePathResolver($server); - - $params = ['w' => 100, 'h' => 50]; - - $predictedPath = $resolver->resolveForUrl($url, $params); - - $generator = new ImageGenerator($server); - $generatedPath = $generator->generateByUrl($url, $params); - - $this->assertEquals($generatedPath, $predictedPath); - } - - public static function remoteUrlProvider() - { - return [ - 'plain' => ['https://example.com/foo/hoff.jpg'], - 'with query string' => ['https://example.com/foo/hoff.jpg?query=david'], - ]; - } - #[Test] #[DefineEnvironment('hybridCaching')] - public function hybrid_caching_generates_image_on_first_request() + #[DataProvider('hybridItemProvider')] + public function hybrid_caching_generates_the_image_where_the_url_points($setUp) { - $asset = $this->createAsset(); - $url = $this->app->make(UrlBuilder::class)->build($asset, ['w' => 100]); + [$item, $params] = $setUp($this); - $resolver = new GlideCachePathResolver($this->app->make(Server::class)); - $expectedPath = $resolver->resolveForAsset($asset, ['w' => 100]); + $url = $this->app->make(UrlBuilder::class)->build($item, $params); + $servedPath = public_path(rawurldecode($url)); - $this->assertFalse(Glide::cacheDisk()->exists($expectedPath)); + $this->assertStringStartsWith('/img/', $url); + $this->assertFileDoesNotExist($servedPath); $response = $this->get($url); $response->assertOk(); - $response->assertHeader('content-type', 'image/jpeg'); - $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); + $response->streamedContent(); + $this->assertFileExists($servedPath); } - #[Test] - #[DefineEnvironment('hybridCaching')] - public function hybrid_caching_generates_watermarked_image_at_the_predicted_path() + public static function hybridItemProvider() { - $asset = $this->createAsset('foo/hoff.jpg'); - $watermark = $this->createAsset('foo/mark.png'); - - $url = $this->app->make(UrlBuilder::class)->build($asset, ['w' => 100, 'mark' => $watermark]); - $expectedPath = Str::after($url, '/img/'); - - $this->assertFalse(Glide::cacheDisk()->exists($expectedPath)); - - $response = $this->get($url); - - $response->assertOk(); - $response->streamedContent(); - $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); + return [ + 'asset' => [fn ($test) => [$test->createAsset(), ['w' => 100]]], + 'asset with auto crop' => [function ($test) { + config(['statamic.assets.auto_crop' => true]); + + return [$test->createAsset(), ['w' => 100, 'h' => 50]]; + }], + 'asset id' => [fn ($test) => [$test->createAsset()->id(), ['w' => 100]]], + 'path' => [fn ($test) => [$test->createPublicImage('test-path.jpg'), ['w' => 100]]], + 'remote url' => [function ($test) { + $test->bindRemoteImage(); + + return ['https://example.com/foo/hoff.jpg', ['w' => 100]]; + }], + 'asset watermark' => [fn ($test) => [$test->createAsset(), ['w' => 100, 'mark' => $test->createAsset('foo/mark.png')]]], + 'path watermark' => [fn ($test) => [$test->createAsset(), ['w' => 100, 'mark' => $test->createPublicImage('mark.png')]]], + 'remote url watermark' => [function ($test) { + $test->bindRemoteImage(); + + return [$test->createAsset(), ['w' => 100, 'mark' => 'https://example.com/mark.png']]; + }], + ]; } #[Test] @@ -341,26 +297,6 @@ public function hybrid_caching_url_has_no_query_params() $this->assertStringStartsWith('/img/', $url); } - #[Test] - #[DefineEnvironment('hybridCaching')] - public function hybrid_caching_generates_image_on_first_request_by_asset_id_string() - { - $asset = $this->createAsset(); - - $url = $this->app->make(UrlBuilder::class)->build('test_container::foo/hoff.jpg', ['w' => 100]); - - $resolver = new GlideCachePathResolver($this->app->make(Server::class)); - $expectedPath = $resolver->resolveForAsset($asset, ['w' => 100]); - - $this->assertFalse(Glide::cacheDisk()->exists($expectedPath)); - - $response = $this->get($url); - - $response->assertOk(); - $response->assertHeader('content-type', 'image/jpeg'); - $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); - } - #[Test] #[DefineEnvironment('hybridCaching')] public function hybrid_caching_generates_image_on_first_request_by_url() @@ -389,30 +325,6 @@ public function hybrid_caching_generates_image_on_first_request_by_url() $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); } - #[Test] - #[DefineEnvironment('hybridCaching')] - public function hybrid_caching_generates_image_on_first_request_by_path() - { - $fakeImage = UploadedFile::fake()->image('test-path.jpg', 30, 60); - $imagePath = 'test-path.jpg'; - - file_put_contents(public_path($imagePath), file_get_contents($fakeImage->getPathname())); - - $url = $this->app->make(UrlBuilder::class)->build($imagePath, ['w' => 100]); - - $resolver = new GlideCachePathResolver($this->app->make(Server::class)); - $expectedPath = $resolver->resolveForPath($imagePath, ['w' => 100]); - - $this->assertFalse(Glide::cacheDisk()->exists($expectedPath)); - - $response = $this->get($url); - - $response->assertOk(); - $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); - - @unlink(public_path($imagePath)); - } - #[Test] #[DefineEnvironment('hybridCaching')] public function hybrid_caching_only_registers_the_cache_path_route() @@ -619,6 +531,15 @@ private function createAsset(string $path = 'foo/hoff.jpg'): AssetContract return tap($container->makeAsset($path))->save(); } + private function createPublicImage(string $path): string + { + $file = UploadedFile::fake()->image(basename($path), 30, 60); + + file_put_contents(public_path($path), file_get_contents($file->getPathname())); + + return $path; + } + private function bindRemoteImage() { $this->app->bind(RemoteUrlValidator::class, fn () => new RemoteUrlValidator(fn () => [['ip' => '93.184.216.34']])); From 70389124cc943b3532c578c5c0b74865f9489161 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 16 Sep 2026 09:00:45 +0100 Subject: [PATCH 18/24] encode each segment of the hybrid cache path in the url Co-Authored-By: Claude Opus 5 (1M context) --- src/Imaging/HybridUrlBuilder.php | 4 +++- tests/Imaging/GlideTest.php | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Imaging/HybridUrlBuilder.php b/src/Imaging/HybridUrlBuilder.php index 32a518af58b..938fd74a5e9 100644 --- a/src/Imaging/HybridUrlBuilder.php +++ b/src/Imaging/HybridUrlBuilder.php @@ -34,7 +34,9 @@ public function build($item, $params) $this->cacheSource($cachePath, $this->withEncodedWatermark($params)); - $urlPath = URL::tidy($this->options['route'].'/'.$cachePath, withTrailingSlash: false); + $encodedCachePath = collect(explode('/', $cachePath))->map(rawurlencode(...))->implode('/'); + + $urlPath = URL::tidy($this->options['route'].'/'.$encodedCachePath, withTrailingSlash: false); return URL::makeRelative(URL::prependSiteUrl($urlPath)); } diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index 826096e07c4..620486ebc17 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -212,6 +212,7 @@ public static function hybridItemProvider() return [$test->createAsset(), ['w' => 100, 'h' => 50]]; }], + 'asset with reserved characters in its filename' => [fn ($test) => [$test->createAsset('foo/photo #1.jpg'), ['w' => 100]]], 'asset id' => [fn ($test) => [$test->createAsset()->id(), ['w' => 100]]], 'path' => [fn ($test) => [$test->createPublicImage('test-path.jpg'), ['w' => 100]]], 'remote url' => [function ($test) { @@ -219,6 +220,16 @@ public static function hybridItemProvider() return ['https://example.com/foo/hoff.jpg', ['w' => 100]]; }], + 'remote url with a query string' => [function ($test) { + $test->bindRemoteImage(); + + return ['https://example.com/foo/hoff.jpg?query=david', ['w' => 100]]; + }], + 'remote url with an encoded character' => [function ($test) { + $test->bindRemoteImage(); + + return ['https://example.com/foo/photo%23one.jpg', ['w' => 100]]; + }], 'asset watermark' => [fn ($test) => [$test->createAsset(), ['w' => 100, 'mark' => $test->createAsset('foo/mark.png')]]], 'path watermark' => [fn ($test) => [$test->createAsset(), ['w' => 100, 'mark' => $test->createPublicImage('mark.png')]]], 'remote url watermark' => [function ($test) { From ffb172be5ac91c11ff2123057254506e171a8226 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 16 Sep 2026 09:01:36 +0100 Subject: [PATCH 19/24] serve hybrid images from the root on every site 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) --- routes/glide.php | 15 ++++++++++----- src/Imaging/HybridUrlBuilder.php | 2 +- tests/Imaging/GlideRoutePrefixTest.php | 23 +++++++++++++++++++++++ tests/Imaging/GlideTest.php | 11 +++++++++++ 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/routes/glide.php b/routes/glide.php index d3ffc105208..65762256418 100644 --- a/routes/glide.php +++ b/routes/glide.php @@ -6,15 +6,20 @@ use Statamic\Facades\URL; use Statamic\Http\Controllers\GlideController; +if (Glide::isUsingHybridCaching()) { + Route::group(['prefix' => Glide::route()], function () { + Route::get('{path}', [GlideController::class, 'generateByPath'])->where('path', '.*'); + }); + + return; +} + Site::all()->map(function ($site) { return trim(URL::makeRelative($site->url()), '/'); })->unique()->each(function ($sitePrefix) { Route::group(['prefix' => $sitePrefix.'/'.Glide::route()], function () { - if (! Glide::isUsingHybridCaching()) { - Route::get('/asset/{container}/{path?}', [GlideController::class, 'generateByAsset'])->where('path', '.*'); - Route::get('/http/{url}/{filename?}', [GlideController::class, 'generateByUrl']); - } - + Route::get('/asset/{container}/{path?}', [GlideController::class, 'generateByAsset'])->where('path', '.*'); + Route::get('/http/{url}/{filename?}', [GlideController::class, 'generateByUrl']); Route::get('{path}', [GlideController::class, 'generateByPath'])->where('path', '.*'); }); }); diff --git a/src/Imaging/HybridUrlBuilder.php b/src/Imaging/HybridUrlBuilder.php index 938fd74a5e9..855a0ca9924 100644 --- a/src/Imaging/HybridUrlBuilder.php +++ b/src/Imaging/HybridUrlBuilder.php @@ -38,7 +38,7 @@ public function build($item, $params) $urlPath = URL::tidy($this->options['route'].'/'.$encodedCachePath, withTrailingSlash: false); - return URL::makeRelative(URL::prependSiteUrl($urlPath)); + return URL::makeRelative($urlPath); } private function findAsset(string $id): Asset diff --git a/tests/Imaging/GlideRoutePrefixTest.php b/tests/Imaging/GlideRoutePrefixTest.php index 4dae4cd6e61..368cadbef84 100644 --- a/tests/Imaging/GlideRoutePrefixTest.php +++ b/tests/Imaging/GlideRoutePrefixTest.php @@ -42,4 +42,27 @@ public function it_registers_glide_routes_without_a_double_slash_for_a_path_pref 'Glide route prefix should not contain a double slash.' ); } + + #[Test] + public function it_registers_the_hybrid_caching_route_once_without_a_site_prefix() + { + config([ + 'statamic.assets.image_manipulation.cache' => 'hybrid', + 'statamic.assets.image_manipulation.route' => 'hybrid-img', + ]); + + $this->setSites([ + 'en' => ['name' => 'English', 'locale' => 'en_US', 'url' => '/en/'], + 'fr' => ['name' => 'French', 'locale' => 'fr_FR', 'url' => '/fr/'], + ]); + + require __DIR__.'/../../routes/glide.php'; + + $uris = collect(Route::getRoutes()->getRoutes()) + ->map->uri() + ->filter(fn ($uri) => str_contains($uri, 'hybrid-img')) + ->values(); + + $this->assertSame(['hybrid-img/{path}'], $uris->all()); + } } diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index 620486ebc17..886865cc2c6 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -25,6 +25,7 @@ use Statamic\Facades\File; use Statamic\Facades\Glide; use Statamic\Facades\Path; +use Statamic\Facades\Site; use Statamic\Imaging\GlideCachePathResolver; use Statamic\Imaging\GlideUrlBuilder; use Statamic\Imaging\HybridUrlBuilder; @@ -214,6 +215,16 @@ public static function hybridItemProvider() }], 'asset with reserved characters in its filename' => [fn ($test) => [$test->createAsset('foo/photo #1.jpg'), ['w' => 100]]], 'asset id' => [fn ($test) => [$test->createAsset()->id(), ['w' => 100]]], + 'asset on a subdirectory site' => [function ($test) { + $test->setSites([ + 'english' => ['url' => '/', 'locale' => 'en_US'], + 'french' => ['url' => '/fr/', 'locale' => 'fr_FR'], + ]); + + Site::setCurrent('french'); + + return [$test->createAsset(), ['w' => 100]]; + }], 'path' => [fn ($test) => [$test->createPublicImage('test-path.jpg'), ['w' => 100]]], 'remote url' => [function ($test) { $test->bindRemoteImage(); From a38ff339acb32ecee9a5caea8158644ea2931e7d Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 16 Sep 2026 09:02:10 +0100 Subject: [PATCH 20/24] predict the thumbnail cache path for video assets Co-Authored-By: Claude Opus 5 (1M context) --- src/Imaging/GlideCachePathResolver.php | 4 ++++ src/Imaging/ThumbnailExtractor.php | 8 ++++++-- tests/Imaging/GlideTest.php | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/Imaging/GlideCachePathResolver.php b/src/Imaging/GlideCachePathResolver.php index f00a20d35bf..ce4e1986f95 100644 --- a/src/Imaging/GlideCachePathResolver.php +++ b/src/Imaging/GlideCachePathResolver.php @@ -14,6 +14,10 @@ public function __construct(private Server $server) public function resolveForAsset(Asset $asset, array $params): string { + if ($asset->isVideo()) { + return $this->resolveForPath(ThumbnailExtractor::getFileName($asset), $params); + } + return $this->resolve( $asset->basename(), $params, diff --git a/src/Imaging/ThumbnailExtractor.php b/src/Imaging/ThumbnailExtractor.php index 62e748b199d..e230a47d1b0 100644 --- a/src/Imaging/ThumbnailExtractor.php +++ b/src/Imaging/ThumbnailExtractor.php @@ -33,11 +33,15 @@ public static function cachePath() ); } + public static function getFileName(Asset $asset) + { + return 'thumb_'.md5($asset->id()).'.jpg'; + } + public static function getCachePath(Asset $asset) { - $fileName = 'thumb_'.md5($asset->id()).'.jpg'; $cacheDirectory = static::cachePath(); - $finalPath = Path::tidy($cacheDirectory.'/'.$fileName); + $finalPath = Path::tidy($cacheDirectory.'/'.static::getFileName($asset)); if (! file_exists($cacheDirectory)) { mkdir($cacheDirectory, 0755, true); diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index 886865cc2c6..49cd91ef346 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -17,6 +17,7 @@ use Orchestra\Testbench\Attributes\DefineEnvironment; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use Statamic\Console\Processes\Ffmpeg; use Statamic\Contracts\Assets\Asset as AssetContract; use Statamic\Contracts\Imaging\UrlBuilder; use Statamic\Facades\Asset; @@ -225,6 +226,11 @@ public static function hybridItemProvider() return [$test->createAsset(), ['w' => 100]]; }], + 'video asset' => [function ($test) { + $test->fakeFfmpeg(); + + return [$test->createAsset('foo/clip.mp4'), ['w' => 100]]; + }], 'path' => [fn ($test) => [$test->createPublicImage('test-path.jpg'), ['w' => 100]]], 'remote url' => [function ($test) { $test->bindRemoteImage(); @@ -574,6 +580,18 @@ private function bindRemoteImage() }); } + private function fakeFfmpeg() + { + $this->mock(Ffmpeg::class, function ($mock) { + $mock->shouldReceive('available')->andReturnTrue(); + $mock->shouldReceive('extractThumbnail')->andReturnUsing(function ($input, $output) { + $thumbnail = UploadedFile::fake()->image('thumbnail.jpg', 30, 60); + + return tap($output, fn () => file_put_contents($output, file_get_contents($thumbnail->getPathname()))); + }); + }); + } + protected function hybridCaching($app) { $app['config']->set('statamic.assets.image_manipulation.cache', 'hybrid'); From 48b3cbc401645e1645e913226700a4e2651b1884 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 16 Sep 2026 09:02:47 +0100 Subject: [PATCH 21/24] drop the watermark when its asset no longer exists Co-Authored-By: Claude Opus 5 (1M context) --- src/Imaging/ImageGenerator.php | 4 ++-- tests/Imaging/ImageGeneratorTest.php | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Imaging/ImageGenerator.php b/src/Imaging/ImageGenerator.php index d7561292d0a..99116c6fd06 100644 --- a/src/Imaging/ImageGenerator.php +++ b/src/Imaging/ImageGenerator.php @@ -225,7 +225,7 @@ public function toEleven() @set_time_limit(config('statamic.system.php_max_execution_time')); } - private function setUpWatermark($watermark): string + private function setUpWatermark($watermark): ?string { $watermark = static::decodeWatermark($watermark); @@ -237,7 +237,7 @@ private function setUpWatermark($watermark): string /** * The `mark` param as Glide will see it, which is what the cache path is hashed from. */ - public static function watermarkParam($watermark): string + public static function watermarkParam($watermark): ?string { $watermark = static::decodeWatermark($watermark); diff --git a/tests/Imaging/ImageGeneratorTest.php b/tests/Imaging/ImageGeneratorTest.php index 4fa2c833d96..f95c9e2e5eb 100644 --- a/tests/Imaging/ImageGeneratorTest.php +++ b/tests/Imaging/ImageGeneratorTest.php @@ -415,6 +415,19 @@ public function the_watermark_disk_is_the_container_when_an_asset_encoded_url_st $this->assertEquals(['mark' => 'foo/hoff.jpg'], $generator->getParams()); } + #[Test] + public function the_watermark_is_dropped_when_an_asset_encoded_url_string_no_longer_resolves() + { + Storage::fake('test'); + tap(AssetContainer::make('test_container')->disk('test'))->save(); + + $generator = $this->makeGenerator(); + + $generator->setParams(['mark' => 'asset::'.base64_encode('test_container/foo/deleted.jpg')]); + + $this->assertEquals(['mark' => null], $generator->getParams()); + } + #[Test] public function the_watermark_disk_is_a_local_adapter_when_a_path_is_provided() { From cf0ac879e52a3cb83cdd5488b5d6c5e0b5125faf Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 16 Sep 2026 09:03:07 +0100 Subject: [PATCH 22/24] only warn about a misconfigured hybrid cache path once Co-Authored-By: Claude Opus 5 (1M context) --- src/Providers/GlideServiceProvider.php | 6 +++++- tests/Imaging/GlideTest.php | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Providers/GlideServiceProvider.php b/src/Providers/GlideServiceProvider.php index ae9ad32c5ef..95e98334ade 100644 --- a/src/Providers/GlideServiceProvider.php +++ b/src/Providers/GlideServiceProvider.php @@ -52,7 +52,11 @@ public function register() public function boot() { - if (Glide::isUsingHybridCaching() && ! Glide::cachePathIsServedByRoute()) { + if (! Glide::isUsingHybridCaching() || Glide::cachePathIsServedByRoute()) { + return; + } + + if (Glide::cacheStore()->add('hybrid-cache-path-warning', true)) { Log::warning('Glide hybrid caching: the image_manipulation.cache_path must live at the image_manipulation.route inside the public directory, otherwise cached images are never served directly by the web server.'); } } diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index 49cd91ef346..85e8d1675e5 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -158,7 +158,7 @@ public static function cachePathServedByRouteProvider() } #[Test] - public function hybrid_caching_warns_when_the_cache_path_is_not_served_by_the_route() + public function hybrid_caching_warns_once_when_the_cache_path_is_not_served_by_the_route() { config([ 'statamic.assets.image_manipulation.cache' => 'hybrid', @@ -169,6 +169,7 @@ public function hybrid_caching_warns_when_the_cache_path_is_not_served_by_the_ro Log::shouldReceive('warning')->once()->withArgs(fn ($message) => str_contains($message, 'hybrid')); (new GlideServiceProvider($this->app))->boot(); + (new GlideServiceProvider($this->app))->boot(); } #[Test] From 89f5a7ec34c7e86f0e39311645ec594690f8c1d2 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 16 Sep 2026 09:03:39 +0100 Subject: [PATCH 23/24] simplify the hybrid url assembly Co-Authored-By: Claude Opus 5 (1M context) --- src/Imaging/HybridUrlBuilder.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Imaging/HybridUrlBuilder.php b/src/Imaging/HybridUrlBuilder.php index 855a0ca9924..60f11cc1140 100644 --- a/src/Imaging/HybridUrlBuilder.php +++ b/src/Imaging/HybridUrlBuilder.php @@ -36,9 +36,7 @@ public function build($item, $params) $encodedCachePath = collect(explode('/', $cachePath))->map(rawurlencode(...))->implode('/'); - $urlPath = URL::tidy($this->options['route'].'/'.$encodedCachePath, withTrailingSlash: false); - - return URL::makeRelative($urlPath); + return URL::makeRelative($this->options['route'].'/'.$encodedCachePath); } private function findAsset(string $id): Asset From 223e8881511c48c49d4a503af5e9d74fa70c9503 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Tue, 22 Sep 2026 12:36:35 -0400 Subject: [PATCH 24/24] warn once when an existing hybrid image is served through php 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 cf0ac879e 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 --- src/Http/Controllers/GlideController.php | 16 ++++++++++++++- tests/Imaging/GlideTest.php | 26 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/Http/Controllers/GlideController.php b/src/Http/Controllers/GlideController.php index d5896659bd8..655fd4fa2fb 100644 --- a/src/Http/Controllers/GlideController.php +++ b/src/Http/Controllers/GlideController.php @@ -95,7 +95,7 @@ public function generateByUrl($url) private function generateOnDemand(string $path) { if ($this->existsInCache($path)) { - Log::debug('Glide hybrid cache loaded ['.$path.'] If you are seeing this, your server rewrite rules have not been set up correctly.'); + $this->warnAboutServingThroughPhp($path); return $this->createResponse($path); } @@ -125,6 +125,20 @@ private function existsInCache(string $path): bool } } + /** + * The image already exists, so the web server should have served it without + * involving PHP. Warn once, since otherwise this fires on every request + * for every image, which is the situation we're complaining about. + */ + private function warnAboutServingThroughPhp(string $path): void + { + if (! Glide::cacheStore()->add('hybrid-served-through-php-warning', true)) { + return; + } + + Log::warning('Glide hybrid caching: ['.$path.'] already exists but was still served by PHP. Check that the image_manipulation.cache_path is inside your public directory and reachable at the image_manipulation.route.'); + } + /** * Forget any stale cache store entry, then generate the image. * diff --git a/tests/Imaging/GlideTest.php b/tests/Imaging/GlideTest.php index 85e8d1675e5..4848b8816dc 100644 --- a/tests/Imaging/GlideTest.php +++ b/tests/Imaging/GlideTest.php @@ -271,6 +271,32 @@ public function hybrid_caching_serves_existing_cached_file() $response->assertOk(); } + #[Test] + #[DefineEnvironment('hybridCaching')] + public function hybrid_caching_warns_once_when_an_existing_image_is_served_through_php() + { + $fakePath = 'containers/test/fake-hash/image.jpg'; + $image = UploadedFile::fake()->image('image.jpg', 10, 10); + Glide::cacheDisk()->put($fakePath, file_get_contents($image->getPathname())); + + Log::shouldReceive('warning')->once()->withArgs(fn ($message) => str_contains($message, 'served by PHP')); + + $this->get('/img/'.$fakePath)->assertOk(); + $this->get('/img/'.$fakePath)->assertOk(); + } + + #[Test] + #[DefineEnvironment('hybridCaching')] + public function hybrid_caching_does_not_warn_when_the_image_is_generated_on_demand() + { + $asset = $this->createAsset(); + $url = $this->app->make(UrlBuilder::class)->build($asset, ['w' => 100]); + + Log::shouldReceive('warning')->never(); + + $this->get($url)->assertOk()->streamedContent(); + } + #[Test] #[DefineEnvironment('hybridCaching')] public function hybrid_caching_returns_404_when_no_mapping_exists()