From 2e3d8faaab3caec120b7ee9ab9966adf9ffe3250 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Wed, 29 Apr 2026 12:55:31 +0100 Subject: [PATCH 1/6] 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 2/6] 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 3/6] =?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 4/6] 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 5/6] 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 6/6] 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'); } - }