From e47f3e3b6b8020e767320c2ac4648b3876e5d8fb Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Thu, 1 May 2025 11:07:47 +0200 Subject: [PATCH 01/18] Refactored image generation --- README.md | 21 ++++-- config/glide-directive.php | 2 +- routes/web.php | 21 ++++++ src/Controllers/ImageController.php | 73 +++++++++++++++++++ src/Jobs/GenerateGlideImageJob.php | 42 ----------- src/Responsive.php | 49 ++++--------- src/ServiceProvider.php | 8 ++ tests/Jobs/GenerateGlideImageJobTest.php | 93 ------------------------ tests/ResponsiveTest.php | 45 ++---------- 9 files changed, 136 insertions(+), 218 deletions(-) create mode 100644 routes/web.php create mode 100644 src/Controllers/ImageController.php delete mode 100644 src/Jobs/GenerateGlideImageJob.php delete mode 100644 tests/Jobs/GenerateGlideImageJobTest.php diff --git a/README.md b/README.md index c3d9f65..384468f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ composer require justbetter/statamic-glide-directive ``` ## Usage -This package adds a Blade directive. You can use an asset in the directive, and it will render the image according to the presets defined in the config. Here’s an example: +This package adds a Blade directive. You can use an asset in the directive, and it will render the image according to the presets defined in the config. Here's an example: ```php @responsive($image, [ @@ -31,18 +31,23 @@ To allow images to change on resize, include this in your head: @include('statamic-glide-directive::partials.head') ``` -We recommend generating your presets using: +### Image Generation + +Images are served directly through custom routes that properly handle the content type and caching. When a preset image is requested, it's generated on demand and stored in the public directory. + +We recommend pre-generating your presets for optimal performance: ```bash php please assets:generate-presets ``` -For performance, consider using Redis for your queue connection. If kept on sync, images will be generated on the fly, affecting page load times. When using Redis, images will also be created on the fly while processing jobs in the queue. If an image doesn't have a Glide preset ready, the original image URL will be used for the first page load. - -To ensure that the image generation does not block the response, we're using the `dispatchAfterResponse` method when generating the resizes: +For larger sites with many images, consider using Redis for your queue connection: ```php -GenerateGlideImageJob::dispatchAfterResponse($asset, $preset, $fit, $format); +// Set in your .env file +QUEUE_CONNECTION=redis ``` +When using a queue (like Redis), image generation will happen in the background without affecting page load times. If an image preset hasn't been generated yet, a placeholder will be used temporarily until the optimized version is ready. + ## Config The package has default configurations. By default, it will use the presets defined in this addon's config. If you've defined your asset presets in the Statamic config, those will be used. @@ -78,7 +83,7 @@ Configure which sources to use. By default, only WebP sources are used. You can 'sources' => 'webp', ``` -### Publish +### Publish Configuration ```bash php artisan vendor:publish --provider="JustBetter\ImageOptimize\ServiceProvider" -``` +``` \ No newline at end of file diff --git a/config/glide-directive.php b/config/glide-directive.php index ba0df47..c31473d 100644 --- a/config/glide-directive.php +++ b/config/glide-directive.php @@ -34,5 +34,5 @@ 'default_queue' => env('STATAMIC_GLIDE_DIRECTIVE_DEFAULT_QUEUE', 'default'), // Set the threshold width to use for the image source sets. - 'image_resize_threshold' => 480 + 'image_resize_threshold' => 480, ]; diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..6bbbe18 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,21 @@ + '.*', + 'format' => '\..+', +]; + +Route::get('glide-image/placeholder/{file}', [ImageController::class, 'placeholder']) + ->where($patterns) + ->name('glide-image.placeholder'); + +Route::get('storage/glide-image/{preset}/{fit}/{signature}/{file}{format}', [ImageController::class, 'getPreset']) + ->where($patterns) + ->name('glide-image.preset'); diff --git a/src/Controllers/ImageController.php b/src/Controllers/ImageController.php new file mode 100644 index 0000000..b31bdff --- /dev/null +++ b/src/Controllers/ImageController.php @@ -0,0 +1,73 @@ +build(); + + $publicPath = public_path($path); + + if (! file_exists($publicPath)) { + abort(404); + } + + /* @phpstan-ignore-next-line */ + $contentType = $asset->mimeType(); + $fileContent = file_get_contents($publicPath) ?: ''; + + return response($fileContent) + ->header('Content-Type', $contentType) + ->header('Cache-Control', 'public, max-age=31536000'); + } + + protected static function getManipulator(Asset $asset, string $preset, string $fit, ?string $format = null): ImageManipulator|string + { + $manipulator = Image::manipulate($asset); + collect(['p' => $preset, 'fm' => $format, 'fit' => $fit])->each(fn ($value, $param) => $manipulator->$param($value)); + + return $manipulator; + } + + public function placeholder(Request $request, string $file, string $webp = ''): Response + { + $asset = AssetFacade::findByUrl(Str::start($file, '/')); + + if (! $asset) { + abort(404); + } + + /* @phpstan-ignore-next-line */ + $presets = Responsive::getPresets($asset); + $base64Image = $presets['placeholder'] ?? ''; + $base64Content = preg_replace('/^data:image\/\w+;base64,/', '', $base64Image); + $imageData = base64_decode($base64Content); + /* @phpstan-ignore-next-line */ + $mimeType = $asset->mimeType(); + + return response($imageData) + ->header('Content-Type', $mimeType) + ->header('Content-Disposition', 'inline'); + } +} diff --git a/src/Jobs/GenerateGlideImageJob.php b/src/Jobs/GenerateGlideImageJob.php deleted file mode 100644 index c2a2479..0000000 --- a/src/Jobs/GenerateGlideImageJob.php +++ /dev/null @@ -1,42 +0,0 @@ -queue = config('justbetter.glide-directive.default_queue', 'default'); - } - - public function handle(): void - { - Statamic::tag( - $this->preset === 'placeholder' ? 'glide:data_url' : 'glide' - )->params( - [ - 'preset' => $this->preset, - 'src' => $this->asset->url(), - 'format' => $this->format, - 'fit' => $this->fit, - ] - )->fetch(); - } -} diff --git a/src/Responsive.php b/src/Responsive.php index c0f3e23..0b7fe6f 100644 --- a/src/Responsive.php +++ b/src/Responsive.php @@ -4,15 +4,12 @@ use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\View; -use JustBetter\GlideDirective\Jobs\GenerateGlideImageJob; +use League\Glide\Signatures\SignatureFactory; use Statamic\Assets\Asset; use Statamic\Contracts\Imaging\ImageManipulator; -use Statamic\Facades\Glide; use Statamic\Facades\Image; -use Statamic\Facades\URL; use Statamic\Fields\Value; use Statamic\Statamic; -use Statamic\Support\Str; class Responsive { @@ -68,7 +65,7 @@ public static function getPresets(Asset $asset): array $index = 0; foreach ($configPresets as $preset => $data) { - if(!($data['w'] ?? false)) { + if (! ($data['w'] ?? false)) { continue; } @@ -89,7 +86,7 @@ public static function getPresets(Asset $asset): array } if (self::canUseMimeTypeSource()) { - if ($glideUrl = self::getGlideUrl($asset, $preset, $fit ?? $data['fit'], $asset->mimeType())) { + if ($glideUrl = self::getGlideUrl($asset, $preset, $fit ?? $data['fit'], $asset->extension())) { $presets[$asset->mimeType()] .= $glideUrl.' '.$size; if ($preset !== 'placeholder') { @@ -125,37 +122,21 @@ public static function getPresets(Asset $asset): array protected static function getGlideUrl(Asset $asset, string $preset, string $fit, ?string $format = null): ?string { if ($preset === 'placeholder') { - return Statamic::tag('glide:data_url')->params([ - 'preset' => $preset, - 'src' => $asset->url(), - 'format' => $format, - 'fit' => $fit, - ])->fetch(); - } - - $manipulator = self::getManipulator($asset, $preset, $fit, $format); - - if (is_string($manipulator)) { - return null; - } - - $params = $manipulator->getParams(); - - $manipulationCacheKey = 'asset::'.$asset->id().'::'.md5(json_encode($params) ? json_encode($params) : ''); - - if ($cachedUrl = Glide::cacheStore()->get($manipulationCacheKey)) { - $url = Str::ensureLeft(config('statamic.assets.image_manipulation.route'), '/').'/'.$cachedUrl; - - return URL::encode($url); + return route('glide-image.placeholder', [ + 'file' => ltrim($asset->url(), '/'), + ]); } - if (config('queue.default') === 'redis') { - GenerateGlideImageJob::dispatch($asset, $preset, $fit, $format); - } else { - GenerateGlideImageJob::dispatchAfterResponse($asset, $preset, $fit, $format); - } + $signatureFactory = SignatureFactory::create(config('app.key')); + $signature = $signatureFactory->generateSignature($asset->url(), ['preset' => $preset, 'fit' => $fit, 'format' => '.'.$format]); - return null; + return route('glide-image.preset', [ + 'file' => ltrim($asset->url(), '/'), + 'signature' => $signature, + 'preset' => $preset, + 'fit' => $fit, + 'format' => '.'.$format, + ]); } protected static function getManipulator(Asset $item, string $preset, string $fit, ?string $format = null): ImageManipulator|string diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 427f299..5792565 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -13,6 +13,7 @@ public function boot(): void ->bootConfig() ->bootDirectives() ->bootViews() + ->bootRoutes() ->bootClasses(); } @@ -40,6 +41,13 @@ protected function bootDirectives(): static return $this; } + protected function bootRoutes(): static + { + $this->loadRoutesFrom(__DIR__.'/../routes/web.php'); + + return $this; + } + protected function bootViews(): static { $this->loadViewsFrom(__DIR__.'/../resources/views', 'statamic-glide-directive'); diff --git a/tests/Jobs/GenerateGlideImageJobTest.php b/tests/Jobs/GenerateGlideImageJobTest.php deleted file mode 100644 index ff04e30..0000000 --- a/tests/Jobs/GenerateGlideImageJobTest.php +++ /dev/null @@ -1,93 +0,0 @@ -uploadTestAsset('upload.png'); - - // Clear the cache to ensure fresh generation - /* @phpstan-ignore-next-line */ - Glide::cacheStore()->flush(); - - $job = new GenerateGlideImageJob( - asset: $asset, - preset: 'xs', - fit: 'contain', - format: 'webp' - ); - $job->handle(); - - $this->assertTrue(true); // If we get here without exceptions, the test passed - - $asset->delete(); - } - - #[Test] - public function it_skips_generation_if_image_exists(): void - { - $asset = $this->uploadTestAsset('upload.png'); - - $job = new GenerateGlideImageJob( - asset: $asset, - preset: 'xs', - fit: 'contain' - ); - - // Generate the image first - $job->handle(); - - // Try to generate again - $job->handle(); - - $this->assertTrue(true); // If we get here without exceptions, the test passed - - $asset->delete(); - } - - #[Test] - public function it_handles_invalid_paths(): void - { - // Create a mock Asset object - $asset = $this->createMock(Asset::class); - $asset->method('url')->willReturn('non-existent-path.jpg'); - - $job = new GenerateGlideImageJob( - asset: $asset, - preset: 'xs' - ); - - // This should not throw an exception - $job->handle(); - - $this->assertTrue(true); // If we get here, the test passed - } - - #[Test] - public function it_generates_image_with_focal_point(): void - { - $asset = $this->uploadTestAsset('upload.png'); - $asset->data(['focus' => '50-50'])->save(); - - $job = new GenerateGlideImageJob( - asset: $asset, - preset: 'xs', - fit: 'crop-50-50' - ); - $job->handle(); - - $this->assertTrue(true); // If we get here without exceptions, the test passed - - $asset->delete(); - } -} diff --git a/tests/ResponsiveTest.php b/tests/ResponsiveTest.php index a857b47..f9c5449 100644 --- a/tests/ResponsiveTest.php +++ b/tests/ResponsiveTest.php @@ -2,14 +2,9 @@ namespace JustBetter\GlideDirective\Tests; -use Illuminate\Support\Facades\Bus; -use Illuminate\Support\Facades\Queue; -use JustBetter\GlideDirective\Jobs\GenerateGlideImageJob; use JustBetter\GlideDirective\Responsive; use PHPUnit\Framework\Attributes\Test; -use Statamic\Facades\Glide; use Statamic\Fields\Value; -use Statamic\Statamic; class ResponsiveTest extends TestCase { @@ -77,56 +72,26 @@ public function it_handles_custom_attributes(): void $asset->delete(); } - #[Test] - public function it_handles_image_generation(): void - { - Queue::fake(); - $asset = $this->uploadTestAsset('upload.png'); - - // Test uncached image - /* @phpstan-ignore-next-line */ - Glide::cacheStore()->flush(); - $view = Responsive::handle($asset); - /* @phpstan-ignore-next-line */ - $view->render(); - Bus::dispatchAfterResponse(GenerateGlideImageJob::class); - - // Test cached image - Statamic::tag('glide')->params([ - 'src' => $asset->url(), - 'preset' => 'xs', - ])->fetch(); - - $view = Responsive::handle($asset); - /* @phpstan-ignore-next-line */ - $rendered = $view->render(); - - $this->assertStringContainsString('src="', $rendered); - $this->assertStringContainsString('.png', $rendered); - - $asset->delete(); - } - #[Test] public function it_creates_mime_type_source_when_configured(): void { $asset = $this->uploadTestAsset('upload.png'); config()->set('justbetter.glide-directive.sources', 'mime_type'); - + $view = Responsive::handle($asset); /* @phpstan-ignore-next-line */ $rendered = $view->render(); $this->assertStringNotContainsString('set('justbetter.glide-directive.sources', 'webp'); - + $view = Responsive::handle($asset); /* @phpstan-ignore-next-line */ $rendered = $view->render(); - + $this->assertStringNotContainsString('delete(); } } From c8006a41db916ade92af15fa1c7242987ba34b70 Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Thu, 1 May 2025 11:29:23 +0200 Subject: [PATCH 02/18] Fixed check --- resources/views/image.blade.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/resources/views/image.blade.php b/resources/views/image.blade.php index 770b4f1..11fa6de 100644 --- a/resources/views/image.blade.php +++ b/resources/views/image.blade.php @@ -10,20 +10,20 @@ class="{{ $class }}" height="{{ $height }}" /> @else - @isset($presets['webp']) + @if(isset($presets['webp'])) - @endisset - @isset($presets[$image->mimeType()]) + @endif + @if(isset($presets[$image->mimeType()]) && $image->mimeType() !== 'image/webp') - @endisset + @endif Date: Thu, 1 May 2025 11:39:26 +0200 Subject: [PATCH 03/18] =?UTF-8?q?=C3=85dded=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Controllers/ImageControllerTest.php | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/Controllers/ImageControllerTest.php diff --git a/tests/Controllers/ImageControllerTest.php b/tests/Controllers/ImageControllerTest.php new file mode 100644 index 0000000..0ebe256 --- /dev/null +++ b/tests/Controllers/ImageControllerTest.php @@ -0,0 +1,28 @@ +uploadTestAsset('upload.png'); + $response = $this->get('/glide-image/placeholder/'.$asset->url()); + + $response->assertSuccessful(); + } + + #[Test] + public function it_gets_presets(): void + { + $asset = $this->uploadTestAsset('upload.png'); + $presets = Responsive::getPresets($asset); + + $this->assertArrayHasKey('webp', $presets); + } +} From db23dc07c200fb81d3826dfa01980a3abab9d71b Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Thu, 1 May 2025 11:47:39 +0200 Subject: [PATCH 04/18] Updated tests --- tests/Controllers/ImageControllerTest.php | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/Controllers/ImageControllerTest.php b/tests/Controllers/ImageControllerTest.php index 0ebe256..20e447f 100644 --- a/tests/Controllers/ImageControllerTest.php +++ b/tests/Controllers/ImageControllerTest.php @@ -2,12 +2,21 @@ namespace JustBetter\GlideDirective\Controllers\Tests; +use JustBetter\GlideDirective\Controllers\ImageController; use JustBetter\GlideDirective\Responsive; use JustBetter\GlideDirective\Tests\TestCase; use PHPUnit\Framework\Attributes\Test; class ImageControllerTest extends TestCase { + protected ImageController $controller; + + protected function setUp(): void + { + parent::setUp(); + $this->controller = app(ImageController::class); + } + #[Test] public function it_returns_placeholder(): void { @@ -25,4 +34,19 @@ public function it_gets_presets(): void $this->assertArrayHasKey('webp', $presets); } + + #[Test] + public function it_returns_404_for_missing_asset(): void + { + $this->expectException(\Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class); + + $this->controller->getPreset( + request(), + 'xs', + 'contain', + 'dummy-signature', + 'non-existent-file.jpg', + 'jpg' + ); + } } From 225d2c0ca93741d8afb2c15c2864ed97bf7a234b Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Thu, 1 May 2025 11:49:12 +0200 Subject: [PATCH 05/18] Lowered coverage minimum --- .github/workflows/coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 46cd0f5..c8e5068 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -35,4 +35,4 @@ jobs: composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update composer update --${{ matrix.stability }} --prefer-dist --no-interaction - name: Execute tests - run: XDEBUG_MODE=coverage php vendor/bin/pest --coverage --min=85 \ No newline at end of file + run: XDEBUG_MODE=coverage php vendor/bin/pest --coverage --min=80 \ No newline at end of file From a747d721d2e0951624fc829e192047fe760ed9ca Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Thu, 15 May 2025 10:04:16 +0200 Subject: [PATCH 06/18] Added typehints + Ran pint --- src/Controllers/ImageController.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Controllers/ImageController.php b/src/Controllers/ImageController.php index b31bdff..34b2dc1 100644 --- a/src/Controllers/ImageController.php +++ b/src/Controllers/ImageController.php @@ -7,7 +7,8 @@ use Illuminate\Routing\Controller; use Illuminate\Support\Str; use JustBetter\GlideDirective\Responsive; -use Statamic\Contracts\Assets\Asset; +use Statamic\Assets\Asset; +use Statamic\Contracts\Assets\Asset as AssetContract; use Statamic\Contracts\Imaging\ImageManipulator; use Statamic\Facades\Asset as AssetFacade; use Statamic\Facades\Image; @@ -17,6 +18,7 @@ class ImageController extends Controller { public function getPreset(Request $request, string $preset, string $fit, string $signature, string $file, string $format): Response { + /** @var ?Asset $asset */ $asset = AssetFacade::findByUrl(Str::start($file, '/')); if (! $asset) { @@ -33,7 +35,6 @@ public function getPreset(Request $request, string $preset, string $fit, string abort(404); } - /* @phpstan-ignore-next-line */ $contentType = $asset->mimeType(); $fileContent = file_get_contents($publicPath) ?: ''; @@ -42,7 +43,7 @@ public function getPreset(Request $request, string $preset, string $fit, string ->header('Cache-Control', 'public, max-age=31536000'); } - protected static function getManipulator(Asset $asset, string $preset, string $fit, ?string $format = null): ImageManipulator|string + protected static function getManipulator(AssetContract $asset, string $preset, string $fit, ?string $format = null): ImageManipulator|string { $manipulator = Image::manipulate($asset); collect(['p' => $preset, 'fm' => $format, 'fit' => $fit])->each(fn ($value, $param) => $manipulator->$param($value)); @@ -52,18 +53,17 @@ protected static function getManipulator(Asset $asset, string $preset, string $f public function placeholder(Request $request, string $file, string $webp = ''): Response { + /** @var ?Asset $asset */ $asset = AssetFacade::findByUrl(Str::start($file, '/')); if (! $asset) { abort(404); } - /* @phpstan-ignore-next-line */ $presets = Responsive::getPresets($asset); $base64Image = $presets['placeholder'] ?? ''; $base64Content = preg_replace('/^data:image\/\w+;base64,/', '', $base64Image); $imageData = base64_decode($base64Content); - /* @phpstan-ignore-next-line */ $mimeType = $asset->mimeType(); return response($imageData) From da3e890b34c05813f746d85ca950de50c31f68a8 Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Thu, 15 May 2025 10:18:33 +0200 Subject: [PATCH 07/18] Added return types --- src/Controllers/ImageController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Controllers/ImageController.php b/src/Controllers/ImageController.php index 34b2dc1..7208380 100644 --- a/src/Controllers/ImageController.php +++ b/src/Controllers/ImageController.php @@ -46,7 +46,7 @@ public function getPreset(Request $request, string $preset, string $fit, string protected static function getManipulator(AssetContract $asset, string $preset, string $fit, ?string $format = null): ImageManipulator|string { $manipulator = Image::manipulate($asset); - collect(['p' => $preset, 'fm' => $format, 'fit' => $fit])->each(fn ($value, $param) => $manipulator->$param($value)); + collect(['p' => $preset, 'fm' => $format, 'fit' => $fit])->each(fn (string $value, string $param) => $manipulator->$param($value)); return $manipulator; } From f03ba2aa175c6cccb1811454a4f1dd68cbd1322a Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Thu, 15 May 2025 11:37:58 +0200 Subject: [PATCH 08/18] Change binary response & validate signature --- routes/web.php | 2 +- src/Controllers/ImageController.php | 20 +++++++++++++++----- src/Responsive.php | 10 +++------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/routes/web.php b/routes/web.php index 6bbbe18..6afa116 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,6 +16,6 @@ ->where($patterns) ->name('glide-image.placeholder'); -Route::get('storage/glide-image/{preset}/{fit}/{signature}/{file}{format}', [ImageController::class, 'getPreset']) +Route::get('storage/glide-image/{preset}/{fit}/{s}/{file}{format}', [ImageController::class, 'getPreset']) ->where($patterns) ->name('glide-image.preset'); diff --git a/src/Controllers/ImageController.php b/src/Controllers/ImageController.php index 7208380..35b2003 100644 --- a/src/Controllers/ImageController.php +++ b/src/Controllers/ImageController.php @@ -13,10 +13,13 @@ use Statamic\Facades\Asset as AssetFacade; use Statamic\Facades\Image; use Statamic\Imaging\GlideImageManipulator; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use League\Glide\Signatures\Signature; +use League\Glide\Signatures\SignatureException; class ImageController extends Controller { - public function getPreset(Request $request, string $preset, string $fit, string $signature, string $file, string $format): Response + public function getPreset(Request $request, string $preset, string $fit, string $signature, string $file, string $format): BinaryFileResponse { /** @var ?Asset $asset */ $asset = AssetFacade::findByUrl(Str::start($file, '/')); @@ -25,6 +28,13 @@ public function getPreset(Request $request, string $preset, string $fit, string abort(404); } + try { + $signatureFactory = new Signature(config('app.key')); + $signatureFactory->validateRequest($asset->url(), ['s' => $signature, 'preset' => $preset, 'fit' => $fit, 'format' => $format]); + } catch (SignatureException $e) { + abort(404); + } + /** @var GlideImageManipulator $manipulator */ $manipulator = self::getManipulator($asset, $preset, $fit, $format); $path = $manipulator->build(); @@ -36,11 +46,11 @@ public function getPreset(Request $request, string $preset, string $fit, string } $contentType = $asset->mimeType(); - $fileContent = file_get_contents($publicPath) ?: ''; - return response($fileContent) - ->header('Content-Type', $contentType) - ->header('Cache-Control', 'public, max-age=31536000'); + return new BinaryFileResponse($publicPath, 200, [ + 'Content-Type' => $contentType, + 'Cache-Control' => 'public, max-age=31536000', + ]); } protected static function getManipulator(AssetContract $asset, string $preset, string $fit, ?string $format = null): ImageManipulator|string diff --git a/src/Responsive.php b/src/Responsive.php index 0b7fe6f..620f40f 100644 --- a/src/Responsive.php +++ b/src/Responsive.php @@ -128,15 +128,11 @@ protected static function getGlideUrl(Asset $asset, string $preset, string $fit, } $signatureFactory = SignatureFactory::create(config('app.key')); - $signature = $signatureFactory->generateSignature($asset->url(), ['preset' => $preset, 'fit' => $fit, 'format' => '.'.$format]); + $params = $signatureFactory->addSignature($asset->url(), ['preset' => $preset, 'fit' => $fit, 'format' => '.'.$format]); - return route('glide-image.preset', [ + return route('glide-image.preset', array_merge($params, [ 'file' => ltrim($asset->url(), '/'), - 'signature' => $signature, - 'preset' => $preset, - 'fit' => $fit, - 'format' => '.'.$format, - ]); + ])); } protected static function getManipulator(Asset $item, string $preset, string $fit, ?string $format = null): ImageManipulator|string From af16bfa1ae487f80daf058a00904c7a824dfa283 Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Thu, 15 May 2025 11:39:42 +0200 Subject: [PATCH 09/18] Ran Pint --- src/Controllers/ImageController.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Controllers/ImageController.php b/src/Controllers/ImageController.php index 35b2003..50962ae 100644 --- a/src/Controllers/ImageController.php +++ b/src/Controllers/ImageController.php @@ -7,6 +7,8 @@ use Illuminate\Routing\Controller; use Illuminate\Support\Str; use JustBetter\GlideDirective\Responsive; +use League\Glide\Signatures\Signature; +use League\Glide\Signatures\SignatureException; use Statamic\Assets\Asset; use Statamic\Contracts\Assets\Asset as AssetContract; use Statamic\Contracts\Imaging\ImageManipulator; @@ -14,8 +16,6 @@ use Statamic\Facades\Image; use Statamic\Imaging\GlideImageManipulator; use Symfony\Component\HttpFoundation\BinaryFileResponse; -use League\Glide\Signatures\Signature; -use League\Glide\Signatures\SignatureException; class ImageController extends Controller { @@ -56,7 +56,7 @@ public function getPreset(Request $request, string $preset, string $fit, string protected static function getManipulator(AssetContract $asset, string $preset, string $fit, ?string $format = null): ImageManipulator|string { $manipulator = Image::manipulate($asset); - collect(['p' => $preset, 'fm' => $format, 'fit' => $fit])->each(fn (string $value, string $param) => $manipulator->$param($value)); + collect(['p' => $preset, 'fm' => $format, 'fit' => $fit])->each(fn (?string $value, ?string $param) => $manipulator->$param($value)); return $manipulator; } From 8aaa8d1a21887cea55d6719af6149ca3fa3c496a Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Thu, 15 May 2025 13:32:55 +0200 Subject: [PATCH 10/18] Changed storage path for resizes --- config/glide-directive.php | 3 + routes/web.php | 2 +- src/Controllers/ImageController.php | 94 ++++++++++++++++------- tests/Controllers/ImageControllerTest.php | 2 +- 4 files changed, 72 insertions(+), 29 deletions(-) diff --git a/config/glide-directive.php b/config/glide-directive.php index c31473d..b18975c 100644 --- a/config/glide-directive.php +++ b/config/glide-directive.php @@ -35,4 +35,7 @@ // Set the threshold width to use for the image source sets. 'image_resize_threshold' => 480, + + // Set the storage prefix to use for the image source sets. + 'storage_prefix' => 'storage/glide-image', ]; diff --git a/routes/web.php b/routes/web.php index 6afa116..cca07db 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,6 +16,6 @@ ->where($patterns) ->name('glide-image.placeholder'); -Route::get('storage/glide-image/{preset}/{fit}/{s}/{file}{format}', [ImageController::class, 'getPreset']) +Route::get('img/'.config('justbetter.glide-directive.storage_prefix', 'storage/glide-image').'/{preset}/{fit}/{s}/{file}{format}', [ImageController::class, 'getImageByPreset']) ->where($patterns) ->name('glide-image.preset'); diff --git a/src/Controllers/ImageController.php b/src/Controllers/ImageController.php index 50962ae..62b7407 100644 --- a/src/Controllers/ImageController.php +++ b/src/Controllers/ImageController.php @@ -2,11 +2,14 @@ namespace JustBetter\GlideDirective\Controllers; +use Closure; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Routing\Controller; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use JustBetter\GlideDirective\Responsive; +use League\Glide\Server; use League\Glide\Signatures\Signature; use League\Glide\Signatures\SignatureException; use Statamic\Assets\Asset; @@ -14,38 +17,64 @@ use Statamic\Contracts\Imaging\ImageManipulator; use Statamic\Facades\Asset as AssetFacade; use Statamic\Facades\Image; -use Statamic\Imaging\GlideImageManipulator; +use Statamic\Imaging\ImageGenerator; use Symfony\Component\HttpFoundation\BinaryFileResponse; class ImageController extends Controller { - public function getPreset(Request $request, string $preset, string $fit, string $signature, string $file, string $format): BinaryFileResponse + protected ?AssetContract $asset; + + protected array $params; + + public function __construct(protected ImageGenerator $imageGenerator, protected Server $server) {} + + public function placeholder(Request $request, string $file, string $webp = ''): Response { /** @var ?Asset $asset */ $asset = AssetFacade::findByUrl(Str::start($file, '/')); + $this->asset = $asset; - if (! $asset) { + if (! $this->asset) { + abort(404); + } + + $presets = Responsive::getPresets($this->asset); + $base64Image = $presets['placeholder'] ?? ''; + $base64Content = preg_replace('/^data:image\/\w+;base64,/', '', $base64Image); + $imageData = base64_decode($base64Content); + $mimeType = $this->asset->mimeType(); + + return response($imageData) + ->header('Content-Type', $mimeType) + ->header('Content-Disposition', 'inline'); + } + + public function getImageByPreset(Request $request, string $preset, string $fit, string $signature, string $file, string $format): BinaryFileResponse + { + /** @var ?Asset $asset */ + $asset = AssetFacade::findByUrl(Str::start($file, '/')); + $this->asset = $asset; + $this->params = ['s' => $signature, 'preset' => $preset, 'fit' => $fit, 'format' => $format]; + + if (! $this->asset) { abort(404); } try { $signatureFactory = new Signature(config('app.key')); - $signatureFactory->validateRequest($asset->url(), ['s' => $signature, 'preset' => $preset, 'fit' => $fit, 'format' => $format]); + $signatureFactory->validateRequest($this->asset->url(), $this->params); } catch (SignatureException $e) { abort(404); } - - /** @var GlideImageManipulator $manipulator */ - $manipulator = self::getManipulator($asset, $preset, $fit, $format); - $path = $manipulator->build(); - - $publicPath = public_path($path); + $path = $this->buildImage(); + $cachePath = config('statamic.assets.image_manipulation.cache_path'); + $publicPath = $cachePath.'/'.$path; if (! file_exists($publicPath)) { abort(404); } - $contentType = $asset->mimeType(); + $contentType = $this->asset->mimeType(); return new BinaryFileResponse($publicPath, 200, [ 'Content-Type' => $contentType, @@ -53,31 +82,42 @@ public function getPreset(Request $request, string $preset, string $fit, string ]); } - protected static function getManipulator(AssetContract $asset, string $preset, string $fit, ?string $format = null): ImageManipulator|string + protected function getManipulator(): ImageManipulator|string { - $manipulator = Image::manipulate($asset); - collect(['p' => $preset, 'fm' => $format, 'fit' => $fit])->each(fn (?string $value, ?string $param) => $manipulator->$param($value)); + $manipulator = Image::manipulate($this->asset); + collect(['p' => $this->params['preset'], 'fm' => $this->params['format'], 'fit' => $this->params['fit']])->each(fn (?string $value, ?string $param) => $manipulator->$param($value)); return $manipulator; } - public function placeholder(Request $request, string $file, string $webp = ''): Response + protected function buildImage(): ?string { - /** @var ?Asset $asset */ - $asset = AssetFacade::findByUrl(Str::start($file, '/')); + if (! $this->asset) { + return null; + } + + $this->server->setSource(Storage::build(['driver' => 'local', 'root' => public_path()])->getDriver()); + $this->server->setSourcePathPrefix('/'); + $this->server->setCachePathPrefix(config('justbetter.glide-directive.storage_prefix', 'storage/glide-image').'/'.$this->params['preset'].'/'.$this->params['fit'].'/'.$this->params['s']); + $this->server->setCachePathCallable($this->getCachePathCallable()); + + $path = $this->server->makeImage($this->asset->url(), $this->params); + + return $path; + } + + protected function getCachePathCallable(): ?Closure + { + $server = $this->server; + $asset = $this->asset; + $params = $this->params; if (! $asset) { - abort(404); + return null; } - $presets = Responsive::getPresets($asset); - $base64Image = $presets['placeholder'] ?? ''; - $base64Content = preg_replace('/^data:image\/\w+;base64,/', '', $base64Image); - $imageData = base64_decode($base64Content); - $mimeType = $asset->mimeType(); - - return response($imageData) - ->header('Content-Type', $mimeType) - ->header('Content-Disposition', 'inline'); + return function () use ($server, $asset, $params) { + return $server->getCachePathPrefix().$asset->url().$params['format']; + }; } } diff --git a/tests/Controllers/ImageControllerTest.php b/tests/Controllers/ImageControllerTest.php index 20e447f..f3cddeb 100644 --- a/tests/Controllers/ImageControllerTest.php +++ b/tests/Controllers/ImageControllerTest.php @@ -40,7 +40,7 @@ public function it_returns_404_for_missing_asset(): void { $this->expectException(\Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class); - $this->controller->getPreset( + $this->controller->getImageByPreset( request(), 'xs', 'contain', From dc29fd86ec7358fc718ee1a432b30ea81a4d4aed Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Thu, 15 May 2025 15:25:34 +0200 Subject: [PATCH 11/18] Removed old code + updated readme --- README.md | 9 +-------- src/Controllers/ImageController.php | 8 -------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/README.md b/README.md index 384468f..0cb92c8 100644 --- a/README.md +++ b/README.md @@ -34,20 +34,13 @@ To allow images to change on resize, include this in your head: ### Image Generation Images are served directly through custom routes that properly handle the content type and caching. When a preset image is requested, it's generated on demand and stored in the public directory. +If an image preset hasn't been generated yet, a placeholder will be used temporarily until the optimized version is ready. We recommend pre-generating your presets for optimal performance: ```bash php please assets:generate-presets ``` -For larger sites with many images, consider using Redis for your queue connection: -```php -// Set in your .env file -QUEUE_CONNECTION=redis -``` - -When using a queue (like Redis), image generation will happen in the background without affecting page load times. If an image preset hasn't been generated yet, a placeholder will be used temporarily until the optimized version is ready. - ## Config The package has default configurations. By default, it will use the presets defined in this addon's config. If you've defined your asset presets in the Statamic config, those will be used. diff --git a/src/Controllers/ImageController.php b/src/Controllers/ImageController.php index 62b7407..2e3c3c8 100644 --- a/src/Controllers/ImageController.php +++ b/src/Controllers/ImageController.php @@ -82,14 +82,6 @@ public function getImageByPreset(Request $request, string $preset, string $fit, ]); } - protected function getManipulator(): ImageManipulator|string - { - $manipulator = Image::manipulate($this->asset); - collect(['p' => $this->params['preset'], 'fm' => $this->params['format'], 'fit' => $this->params['fit']])->each(fn (?string $value, ?string $param) => $manipulator->$param($value)); - - return $manipulator; - } - protected function buildImage(): ?string { if (! $this->asset) { From b1e765ecf54651f9921165fd33bdba292455dd52 Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Thu, 15 May 2025 15:35:33 +0200 Subject: [PATCH 12/18] Lowered coverage minimum --- .github/workflows/coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index c8e5068..74c11dc 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -35,4 +35,4 @@ jobs: composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update composer update --${{ matrix.stability }} --prefer-dist --no-interaction - name: Execute tests - run: XDEBUG_MODE=coverage php vendor/bin/pest --coverage --min=80 \ No newline at end of file + run: XDEBUG_MODE=coverage php vendor/bin/pest --coverage --min=70 \ No newline at end of file From 71394c8b8c29909b0e673a88afd06613ab8fdbe2 Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Tue, 20 May 2025 14:31:47 +0200 Subject: [PATCH 13/18] Added cache prefix config --- config/glide-directive.php | 5 ++++- routes/web.php | 5 ++++- src/Controllers/ImageController.php | 4 +--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/config/glide-directive.php b/config/glide-directive.php index b18975c..c583387 100644 --- a/config/glide-directive.php +++ b/config/glide-directive.php @@ -36,6 +36,9 @@ // Set the threshold width to use for the image source sets. 'image_resize_threshold' => 480, + // Set the cache prefix to use for the image source sets. + 'cache_prefix' => 'img', + // Set the storage prefix to use for the image source sets. - 'storage_prefix' => 'storage/glide-image', + 'storage_prefix' => 'glide-image', ]; diff --git a/routes/web.php b/routes/web.php index cca07db..0ed02bb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,6 +16,9 @@ ->where($patterns) ->name('glide-image.placeholder'); -Route::get('img/'.config('justbetter.glide-directive.storage_prefix', 'storage/glide-image').'/{preset}/{fit}/{s}/{file}{format}', [ImageController::class, 'getImageByPreset']) +Route::get( + config('justbetter.glide-directive.cache_prefix', 'img').'/'.config('justbetter.glide-directive.storage_prefix', 'glide-image').'/{preset}/{fit}/{s}/{file}{format}', + [ImageController::class, 'getImageByPreset'] +) ->where($patterns) ->name('glide-image.preset'); diff --git a/src/Controllers/ImageController.php b/src/Controllers/ImageController.php index 2e3c3c8..2709b0d 100644 --- a/src/Controllers/ImageController.php +++ b/src/Controllers/ImageController.php @@ -14,9 +14,7 @@ use League\Glide\Signatures\SignatureException; use Statamic\Assets\Asset; use Statamic\Contracts\Assets\Asset as AssetContract; -use Statamic\Contracts\Imaging\ImageManipulator; use Statamic\Facades\Asset as AssetFacade; -use Statamic\Facades\Image; use Statamic\Imaging\ImageGenerator; use Symfony\Component\HttpFoundation\BinaryFileResponse; @@ -90,7 +88,7 @@ protected function buildImage(): ?string $this->server->setSource(Storage::build(['driver' => 'local', 'root' => public_path()])->getDriver()); $this->server->setSourcePathPrefix('/'); - $this->server->setCachePathPrefix(config('justbetter.glide-directive.storage_prefix', 'storage/glide-image').'/'.$this->params['preset'].'/'.$this->params['fit'].'/'.$this->params['s']); + $this->server->setCachePathPrefix(config('justbetter.glide-directive.storage_prefix', 'glide-image').'/'.$this->params['preset'].'/'.$this->params['fit'].'/'.$this->params['s']); $this->server->setCachePathCallable($this->getCachePathCallable()); $path = $this->server->makeImage($this->asset->url(), $this->params); From e6e3784381fc37079e1f4827d6b4e70b4dd9753d Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Tue, 20 May 2025 14:41:38 +0200 Subject: [PATCH 14/18] Generate placeholder on-the-fly --- routes/web.php | 4 ---- src/Responsive.php | 9 ++++++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/routes/web.php b/routes/web.php index 0ed02bb..e3cb496 100644 --- a/routes/web.php +++ b/routes/web.php @@ -12,10 +12,6 @@ 'format' => '\..+', ]; -Route::get('glide-image/placeholder/{file}', [ImageController::class, 'placeholder']) - ->where($patterns) - ->name('glide-image.placeholder'); - Route::get( config('justbetter.glide-directive.cache_prefix', 'img').'/'.config('justbetter.glide-directive.storage_prefix', 'glide-image').'/{preset}/{fit}/{s}/{file}{format}', [ImageController::class, 'getImageByPreset'] diff --git a/src/Responsive.php b/src/Responsive.php index 620f40f..76e3fb3 100644 --- a/src/Responsive.php +++ b/src/Responsive.php @@ -122,9 +122,12 @@ public static function getPresets(Asset $asset): array protected static function getGlideUrl(Asset $asset, string $preset, string $fit, ?string $format = null): ?string { if ($preset === 'placeholder') { - return route('glide-image.placeholder', [ - 'file' => ltrim($asset->url(), '/'), - ]); + return Statamic::tag('glide:data_url')->params([ + 'preset' => $preset, + 'src' => $asset->url(), + 'format' => $format, + 'fit' => $fit, + ])->fetch(); } $signatureFactory = SignatureFactory::create(config('app.key')); From c443f68b99766fc7130fb2f730ea5aae34125beb Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Tue, 20 May 2025 14:42:45 +0200 Subject: [PATCH 15/18] Removed old route --- src/Controllers/ImageController.php | 23 ----------------------- tests/Controllers/ImageControllerTest.php | 9 --------- 2 files changed, 32 deletions(-) diff --git a/src/Controllers/ImageController.php b/src/Controllers/ImageController.php index 2709b0d..4e0e6d7 100644 --- a/src/Controllers/ImageController.php +++ b/src/Controllers/ImageController.php @@ -4,11 +4,9 @@ use Closure; use Illuminate\Http\Request; -use Illuminate\Http\Response; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; -use JustBetter\GlideDirective\Responsive; use League\Glide\Server; use League\Glide\Signatures\Signature; use League\Glide\Signatures\SignatureException; @@ -26,27 +24,6 @@ class ImageController extends Controller public function __construct(protected ImageGenerator $imageGenerator, protected Server $server) {} - public function placeholder(Request $request, string $file, string $webp = ''): Response - { - /** @var ?Asset $asset */ - $asset = AssetFacade::findByUrl(Str::start($file, '/')); - $this->asset = $asset; - - if (! $this->asset) { - abort(404); - } - - $presets = Responsive::getPresets($this->asset); - $base64Image = $presets['placeholder'] ?? ''; - $base64Content = preg_replace('/^data:image\/\w+;base64,/', '', $base64Image); - $imageData = base64_decode($base64Content); - $mimeType = $this->asset->mimeType(); - - return response($imageData) - ->header('Content-Type', $mimeType) - ->header('Content-Disposition', 'inline'); - } - public function getImageByPreset(Request $request, string $preset, string $fit, string $signature, string $file, string $format): BinaryFileResponse { /** @var ?Asset $asset */ diff --git a/tests/Controllers/ImageControllerTest.php b/tests/Controllers/ImageControllerTest.php index f3cddeb..8ffbd06 100644 --- a/tests/Controllers/ImageControllerTest.php +++ b/tests/Controllers/ImageControllerTest.php @@ -17,15 +17,6 @@ protected function setUp(): void $this->controller = app(ImageController::class); } - #[Test] - public function it_returns_placeholder(): void - { - $asset = $this->uploadTestAsset('upload.png'); - $response = $this->get('/glide-image/placeholder/'.$asset->url()); - - $response->assertSuccessful(); - } - #[Test] public function it_gets_presets(): void { From ed8218e999649768ddbe22bfdb1025162315d23b Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Tue, 3 Jun 2025 15:38:53 +0200 Subject: [PATCH 16/18] Added tests --- .github/workflows/coverage.yml | 2 +- tests/Controllers/ImageControllerTest.php | 180 ++++++++++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 74c11dc..46cd0f5 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -35,4 +35,4 @@ jobs: composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update composer update --${{ matrix.stability }} --prefer-dist --no-interaction - name: Execute tests - run: XDEBUG_MODE=coverage php vendor/bin/pest --coverage --min=70 \ No newline at end of file + run: XDEBUG_MODE=coverage php vendor/bin/pest --coverage --min=85 \ No newline at end of file diff --git a/tests/Controllers/ImageControllerTest.php b/tests/Controllers/ImageControllerTest.php index 8ffbd06..90e3d51 100644 --- a/tests/Controllers/ImageControllerTest.php +++ b/tests/Controllers/ImageControllerTest.php @@ -40,4 +40,184 @@ public function it_returns_404_for_missing_asset(): void 'jpg' ); } + + #[Test] + public function it_returns_404_for_invalid_signature(): void + { + $asset = $this->uploadTestAsset('upload.png'); + + $this->expectException(\Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class); + + $this->controller->getImageByPreset( + request(), + 'xs', + 'contain', + 'invalid-signature', + $asset->filename(), + '.webp' + ); + } + + #[Test] + public function it_handles_different_format_extensions(): void + { + $asset = $this->uploadTestAsset('upload.png'); + $formats = ['.webp', '.jpg', '.png', '.gif']; + + foreach ($formats as $format) { + $exceptionThrown = false; + + try { + $this->controller->getImageByPreset( + request(), + 'md', + 'crop', + 'invalid-signature', + $asset->filename(), + $format + ); + } catch (\Symfony\Component\HttpKernel\Exception\NotFoundHttpException $e) { + $exceptionThrown = true; + } + + $this->assertTrue($exceptionThrown, "Expected NotFoundHttpException for format: {$format}"); + } + } + + #[Test] + public function it_successfully_validates_signature_and_builds_image(): void + { + $asset = $this->uploadTestAsset('upload.png'); + $signatureFactory = new \League\Glide\Signatures\Signature(config('app.key')); + + $params = [ + 's' => '', + 'preset' => 'xs', + 'fit' => 'contain', + 'format' => '.webp', + ]; + + $signature = $signatureFactory->generateSignature($asset->url(), $params); + $params['s'] = $signature; + + $cachePath = config('statamic.assets.image_manipulation.cache_path', 'public/img'); + $storagePrefix = config('justbetter.glide-directive.storage_prefix', 'glide-image'); + $expectedImagePath = $cachePath.'/'.$storagePrefix.'/xs/contain/'.$signature.$asset->url().'.webp'; + + $directory = dirname($expectedImagePath); + if (! is_dir($directory)) { + mkdir($directory, 0755, true); + } + + file_put_contents($expectedImagePath, 'fake-image-content'); + + try { + $response = $this->controller->getImageByPreset( + request(), + 'xs', + 'contain', + $signature, + $asset->url(), + '.webp' + ); + + $this->assertInstanceOf(\Symfony\Component\HttpFoundation\BinaryFileResponse::class, $response); + $this->assertEquals(200, $response->getStatusCode()); + + $this->assertEquals('max-age=31536000, public', $response->headers->get('Cache-Control')); + $this->assertEquals($asset->mimeType(), $response->headers->get('Content-Type')); + + } finally { + if (file_exists($expectedImagePath)) { + unlink($expectedImagePath); + } + + $dir = dirname($expectedImagePath); + while ($dir && $dir !== $cachePath && is_dir($dir) && scandir($dir) && count(scandir($dir)) === 2) { + rmdir($dir); + $dir = dirname($dir); + } + } + } + + #[Test] + public function it_returns_404_when_image_file_missing_after_valid_signature(): void + { + $asset = $this->uploadTestAsset('upload.png'); + + $signatureFactory = new \League\Glide\Signatures\Signature(config('app.key')); + $params = [ + 's' => '', + 'preset' => 'md', + 'fit' => 'crop', + 'format' => '.jpg', + ]; + + $signature = $signatureFactory->generateSignature($asset->url(), $params); + + $this->expectException(\Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class); + + $this->controller->getImageByPreset( + request(), + 'md', + 'crop', + $signature, + $asset->filename(), + '.jpg' + ); + } + + #[Test] + public function it_handles_different_preset_values(): void + { + $asset = $this->uploadTestAsset('upload.png'); + + $presets = ['xs', 'sm', 'md', 'lg', 'xl', '2xl']; + + foreach ($presets as $preset) { + $exceptionThrown = false; + + try { + $this->controller->getImageByPreset( + request(), + $preset, + 'contain', + 'invalid-signature', + $asset->filename(), + '.webp' + ); + } catch (\Symfony\Component\HttpKernel\Exception\NotFoundHttpException $e) { + $exceptionThrown = true; + } + + $this->assertTrue($exceptionThrown, "Expected NotFoundHttpException for preset: {$preset}"); + } + } + + #[Test] + public function it_handles_different_fit_values(): void + { + $asset = $this->uploadTestAsset('upload.png'); + + $fitModes = ['contain', 'crop', 'fill', 'stretch']; + + foreach ($fitModes as $fit) { + $exceptionThrown = false; + + try { + $this->controller->getImageByPreset( + request(), + 'md', + $fit, + 'invalid-signature', + $asset->filename(), + '.webp' + ); + } catch (\Symfony\Component\HttpKernel\Exception\NotFoundHttpException $e) { + $exceptionThrown = true; + } + + $this->assertTrue($exceptionThrown, "Expected NotFoundHttpException for fit: {$fit}"); + } + } } From 88c6c24ebbc346e616071e0f9e07f7c4945e8154 Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Tue, 29 Jul 2025 10:15:24 +0200 Subject: [PATCH 17/18] Fixed feedback --- config/glide-directive.php | 3 +++ resources/views/image.blade.php | 2 +- routes/web.php | 2 +- src/Controllers/ImageController.php | 4 +--- src/Responsive.php | 26 ++++++++++++++++++++++- tests/Controllers/ImageControllerTest.php | 4 ++-- 6 files changed, 33 insertions(+), 8 deletions(-) diff --git a/config/glide-directive.php b/config/glide-directive.php index c583387..d8d61b7 100644 --- a/config/glide-directive.php +++ b/config/glide-directive.php @@ -5,6 +5,9 @@ // This will add a blurry image as a placeholder before loading the correct size. 'placeholder' => true, + // The default preset used in the img src. + 'default_preset' => 'sm', + // The default presets used for generating the resizes. // If the config in statamic.assets.image_manipulation.presets is empty this will be used instead. 'presets' => [ diff --git a/resources/views/image.blade.php b/resources/views/image.blade.php index 11fa6de..6c121c3 100644 --- a/resources/views/image.blade.php +++ b/resources/views/image.blade.php @@ -27,7 +27,7 @@ class="{{ $class }}" {{ $alt ?? $image->alt() }}where($patterns) diff --git a/src/Controllers/ImageController.php b/src/Controllers/ImageController.php index 4e0e6d7..376a7b2 100644 --- a/src/Controllers/ImageController.php +++ b/src/Controllers/ImageController.php @@ -26,9 +26,7 @@ public function __construct(protected ImageGenerator $imageGenerator, protected public function getImageByPreset(Request $request, string $preset, string $fit, string $signature, string $file, string $format): BinaryFileResponse { - /** @var ?Asset $asset */ - $asset = AssetFacade::findByUrl(Str::start($file, '/')); - $this->asset = $asset; + $this->asset = AssetFacade::findByUrl(Str::start($file, '/')); $this->params = ['s' => $signature, 'preset' => $preset, 'fit' => $fit, 'format' => $format]; if (! $this->asset) { diff --git a/src/Responsive.php b/src/Responsive.php index 76e3fb3..920d658 100644 --- a/src/Responsive.php +++ b/src/Responsive.php @@ -23,8 +23,11 @@ public static function handle(mixed ...$arguments): Factory|View|string return ''; } + + return view('statamic-glide-directive::image', [ 'image' => $asset, + 'default_preset' => self::getDefaultPreset($asset), 'presets' => self::getPresets($asset), 'attributes' => self::getAttributeBag($arguments), 'class' => $arguments['class'] ?? '', @@ -65,7 +68,7 @@ public static function getPresets(Asset $asset): array $index = 0; foreach ($configPresets as $preset => $data) { - if (! ($data['w'] ?? false)) { + if (! isset($data['w'])) { continue; } @@ -119,6 +122,27 @@ public static function getPresets(Asset $asset): array return array_filter($presets); } + protected static function getDefaultPreset(Asset $asset): string + { + $assetMeta = $asset->meta(); + $fit = isset($assetMeta['data']['focus']) ? sprintf('crop-%s', $assetMeta['data']['focus']) : null; + + $config = config('statamic.assets.image_manipulation.presets'); + $configPresets = self::getPresetsByRatio($asset, $config); + $defaultPreset = $configPresets[config('justbetter.glide-directive.default_preset')] ?? false; + + if(!$defaultPreset) { + return $asset->url(); + } + + return self::getGlideUrl( + $asset, + config('justbetter.glide-directive.default_preset', 'sm'), + $fit ?? ($defaultPreset['fit'] ?? 'contain'), + self::canUseWebpSource() ? 'webp' : $asset->mimeType() + ); + } + protected static function getGlideUrl(Asset $asset, string $preset, string $fit, ?string $format = null): ?string { if ($preset === 'placeholder') { diff --git a/tests/Controllers/ImageControllerTest.php b/tests/Controllers/ImageControllerTest.php index 90e3d51..9e60c2b 100644 --- a/tests/Controllers/ImageControllerTest.php +++ b/tests/Controllers/ImageControllerTest.php @@ -100,8 +100,8 @@ public function it_successfully_validates_signature_and_builds_image(): void $signature = $signatureFactory->generateSignature($asset->url(), $params); $params['s'] = $signature; - $cachePath = config('statamic.assets.image_manipulation.cache_path', 'public/img'); - $storagePrefix = config('justbetter.glide-directive.storage_prefix', 'glide-image'); + $cachePath = config('statamic.assets.image_manipulation.cache_path'); + $storagePrefix = config('justbetter.glide-directive.storage_prefix'); $expectedImagePath = $cachePath.'/'.$storagePrefix.'/xs/contain/'.$signature.$asset->url().'.webp'; $directory = dirname($expectedImagePath); From 51fbafc0b9c7f8babba51c4ebc8b17ee089c80a3 Mon Sep 17 00:00:00 2001 From: Kevin Meijer Date: Tue, 29 Jul 2025 10:47:52 +0200 Subject: [PATCH 18/18] Fixed phpstan errors + ran pint --- src/Controllers/ImageController.php | 4 +++- src/Responsive.php | 7 ++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Controllers/ImageController.php b/src/Controllers/ImageController.php index 376a7b2..ff8a890 100644 --- a/src/Controllers/ImageController.php +++ b/src/Controllers/ImageController.php @@ -18,7 +18,7 @@ class ImageController extends Controller { - protected ?AssetContract $asset; + protected null|Asset|AssetContract $asset; protected array $params; @@ -39,6 +39,7 @@ public function getImageByPreset(Request $request, string $preset, string $fit, } catch (SignatureException $e) { abort(404); } + $path = $this->buildImage(); $cachePath = config('statamic.assets.image_manipulation.cache_path'); $publicPath = $cachePath.'/'.$path; @@ -47,6 +48,7 @@ public function getImageByPreset(Request $request, string $preset, string $fit, abort(404); } + // @phpstan-ignore-next-line $contentType = $this->asset->mimeType(); return new BinaryFileResponse($publicPath, 200, [ diff --git a/src/Responsive.php b/src/Responsive.php index 920d658..2b27e8f 100644 --- a/src/Responsive.php +++ b/src/Responsive.php @@ -23,8 +23,6 @@ public static function handle(mixed ...$arguments): Factory|View|string return ''; } - - return view('statamic-glide-directive::image', [ 'image' => $asset, 'default_preset' => self::getDefaultPreset($asset), @@ -122,7 +120,7 @@ public static function getPresets(Asset $asset): array return array_filter($presets); } - protected static function getDefaultPreset(Asset $asset): string + protected static function getDefaultPreset(Asset $asset): ?string { $assetMeta = $asset->meta(); $fit = isset($assetMeta['data']['focus']) ? sprintf('crop-%s', $assetMeta['data']['focus']) : null; @@ -131,7 +129,7 @@ protected static function getDefaultPreset(Asset $asset): string $configPresets = self::getPresetsByRatio($asset, $config); $defaultPreset = $configPresets[config('justbetter.glide-directive.default_preset')] ?? false; - if(!$defaultPreset) { + if (! $defaultPreset) { return $asset->url(); } @@ -175,7 +173,6 @@ protected static function getPresetsByRatio(Asset $asset, array $config): array { $presets = collect($config); - // filter config based on aspect ratio $vertical = $asset->height() > $asset->width(); $presets = $presets->filter(fn ($preset, $key) => $key === 'placeholder' || (($preset['h'] > $preset['w']) === $vertical));