diff --git a/config/assets.php b/config/assets.php index ddeab7d3b36..2a4ad3670b7 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. + | '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 'hybrid', you should configure the cache_path below. | */ diff --git a/routes/routes.php b/routes/routes.php index 31a12883ff2..07605897936 100644 --- a/routes/routes.php +++ b/routes/routes.php @@ -29,7 +29,7 @@ }); } -if (Glide::shouldServeByHttp()) { +if (Glide::shouldServeByHttp() || Glide::isUsingHybridCaching()) { require __DIR__.'/glide.php'; } diff --git a/src/Facades/Glide.php b/src/Facades/Glide.php index 70f97521a1e..39b77544752 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 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 62603b32247..5575cda0a06 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::isUsingHybridCaching()) { + 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,50 @@ public function generateByUrl($url) return $this->createResponse($this->generateBy('url', $url)); } + /** + * Generate an on-demand image for the hybrid caching strategy. + * + * 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) + { + 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); + } + + $mapping = Glide::cacheStore()->get('hybrid::'.$path); + + throw_unless($mapping, new NotFoundHttpException); + + $type = $mapping['type']; + $params = $mapping['params']; + + $item = match ($type) { + 'asset' => Asset::find($mapping['id']) ?? throw new NotFoundHttpException, + 'url' => $mapping['url'], + 'path' => $mapping['path'], + }; + + return $this->createResponse($this->ensureGenerated($type, $item, $params)); + } + + /** + * Forget any stale cache store entry, then generate the image. + * + * 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. + */ + 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 +158,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..b5ca3de7e14 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 !== 'hybrid'; } private function localCacheFilesystem() @@ -77,19 +79,26 @@ private function customCacheFilesystem() */ private function cachePath() { - return $this->shouldServeDirectly() + return ($this->shouldServeDirectly() || $this->isUsingHybridCaching()) ? 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->isUsingHybridCaching(); + } + + public function isUsingHybridCaching() + { + return Config::get('statamic.assets.image_manipulation.cache') === 'hybrid'; } public function route() diff --git a/src/Imaging/HybridUrlBuilder.php b/src/Imaging/HybridUrlBuilder.php new file mode 100644 index 00000000000..fc262e95720 --- /dev/null +++ b/src/Imaging/HybridUrlBuilder.php @@ -0,0 +1,76 @@ +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; + + 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); + + $this->cacheSource($cachePath, $params); + + $urlPath = URL::tidy($this->options['route'].'/'.$cachePath, withTrailingSlash: false); + + 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/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..bbd4285b483 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\HybridUrlBuilder; use Statamic\Imaging\ImageGenerator; use Statamic\Imaging\ImageValidator; use Statamic\Imaging\PresetGenerator; @@ -57,6 +59,13 @@ public function register() private function getBuilder() { + if (Glide::isUsingHybridCaching()) { + return new HybridUrlBuilder( + $this->app->make(GlideCachePathResolver::class), + ['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..af6aa820552 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\HybridUrlBuilder; use Statamic\Imaging\ImageGenerator; use Statamic\Imaging\StaticUrlBuilder; use Statamic\Support\Str; @@ -31,6 +34,10 @@ public function tearDown(): void { $this->clearGlideCache(); + if (file_exists($path = storage_path('glide-test-cache'))) { + File::delete($path); + } + parent::tearDown(); } @@ -70,6 +77,196 @@ public function cache_true_will_make_a_filesystem_using_the_cache_path_location( $this->assertEquals('/imgs', Glide::url()); } + #[Test] + 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' => 'hybrid', + '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(HybridUrlBuilder::class, $this->app[UrlBuilder::class]); + $this->assertEquals('/imgs', Glide::url()); + } + + #[Test] + 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' => 'hybrid', + 'statamic.assets.image_manipulation.cache_path' => null, + ]); + + Glide::server()->getCache(); + } + + #[Test] + public function hybrid_caching_is_detected_as_half_measure() + { + config(['statamic.assets.image_manipulation.cache' => 'hybrid']); + + $this->assertTrue(Glide::isUsingHybridCaching()); + $this->assertFalse(Glide::shouldServeDirectly()); + $this->assertFalse(Glide::shouldServeByHttp()); + } + + #[Test] + public function hybrid_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]; + + $predictedPath = $resolver->resolveForAsset($asset, $params); + + $generator = new ImageGenerator($server); + $generatedPath = $generator->generateByAsset($asset, $params); + + $this->assertEquals($generatedPath, $predictedPath); + } + + #[Test] + #[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'); + $url = $this->app->make(UrlBuilder::class)->build($asset, ['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_serves_existing_cached_file() + { + $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('hybridCaching')] + public function hybrid_caching_returns_404_when_no_mapping_exists() + { + $response = $this->get('/img/containers/nonexistent/hash/image.jpg'); + + $response->assertNotFound(); + } + + #[Test] + #[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'); + $url = $this->app->make(UrlBuilder::class)->build($asset, ['w' => 100]); + + $resolver = new GlideCachePathResolver($this->app->make(Server::class)); + $expectedPath = $resolver->resolveForAsset($asset, ['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)); + + // Request again — should regenerate via the mapping + $response = $this->get($url); + $response->assertOk(); + $this->assertTrue(Glide::cacheDisk()->exists($expectedPath)); + } + + #[Test] + #[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'); + $url = $this->app->make(UrlBuilder::class)->build($asset, ['w' => 100]); + + $this->assertStringNotContainsString('?', $url); + $this->assertStringStartsWith('/img/', $url); + } + + #[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] public function cache_true_without_cache_path_will_throw_exception() { @@ -245,4 +442,12 @@ private function createImageManipulations($containerHandle, $assetPath, $manipul return collect(array_merge([$manifestCacheKey], $manifest)); } + + 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'); + } }