diff --git a/README.md b/README.md index c3d9f65..0cb92c8 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,16 +31,14 @@ To allow images to change on resize, include this in your head: @include('statamic-glide-directive::partials.head') ``` -We recommend generating your presets using: -```bash -php please assets:generate-presets -``` +### Image Generation -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. +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. -To ensure that the image generation does not block the response, we're using the `dispatchAfterResponse` method when generating the resizes: -```php -GenerateGlideImageJob::dispatchAfterResponse($asset, $preset, $fit, $format); +We recommend pre-generating your presets for optimal performance: +```bash +php please assets:generate-presets ``` ## Config @@ -78,7 +76,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..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' => [ @@ -34,5 +37,11 @@ '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, + + // 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' => 'glide-image', ]; diff --git a/resources/views/image.blade.php b/resources/views/image.blade.php index 770b4f1..6c121c3 100644 --- a/resources/views/image.blade.php +++ b/resources/views/image.blade.php @@ -10,24 +10,24 @@ 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 {{ $alt ?? $image->alt() }} '.*', + 'format' => '\..+', +]; + +Route::get( + config('justbetter.glide-directive.cache_prefix').'/'.config('justbetter.glide-directive.storage_prefix').'/{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 new file mode 100644 index 0000000..ff8a890 --- /dev/null +++ b/src/Controllers/ImageController.php @@ -0,0 +1,90 @@ +asset = AssetFacade::findByUrl(Str::start($file, '/')); + $this->params = ['s' => $signature, 'preset' => $preset, 'fit' => $fit, 'format' => $format]; + + if (! $this->asset) { + abort(404); + } + + try { + $signatureFactory = new Signature(config('app.key')); + $signatureFactory->validateRequest($this->asset->url(), $this->params); + } catch (SignatureException $e) { + abort(404); + } + + $path = $this->buildImage(); + $cachePath = config('statamic.assets.image_manipulation.cache_path'); + $publicPath = $cachePath.'/'.$path; + + if (! file_exists($publicPath)) { + abort(404); + } + + // @phpstan-ignore-next-line + $contentType = $this->asset->mimeType(); + + return new BinaryFileResponse($publicPath, 200, [ + 'Content-Type' => $contentType, + 'Cache-Control' => 'public, max-age=31536000', + ]); + } + + protected function buildImage(): ?string + { + 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', '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) { + return null; + } + + return function () use ($server, $asset, $params) { + return $server->getCachePathPrefix().$asset->url().$params['format']; + }; + } +} 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..2b27e8f 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 { @@ -28,6 +25,7 @@ public static function handle(mixed ...$arguments): Factory|View|string return view('statamic-glide-directive::image', [ 'image' => $asset, + 'default_preset' => self::getDefaultPreset($asset), 'presets' => self::getPresets($asset), 'attributes' => self::getAttributeBag($arguments), 'class' => $arguments['class'] ?? '', @@ -68,7 +66,7 @@ public static function getPresets(Asset $asset): array $index = 0; foreach ($configPresets as $preset => $data) { - if(!($data['w'] ?? false)) { + if (! isset($data['w'])) { continue; } @@ -89,7 +87,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') { @@ -122,6 +120,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') { @@ -133,29 +152,12 @@ protected static function getGlideUrl(Asset $asset, string $preset, string $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); - } - - if (config('queue.default') === 'redis') { - GenerateGlideImageJob::dispatch($asset, $preset, $fit, $format); - } else { - GenerateGlideImageJob::dispatchAfterResponse($asset, $preset, $fit, $format); - } + $signatureFactory = SignatureFactory::create(config('app.key')); + $params = $signatureFactory->addSignature($asset->url(), ['preset' => $preset, 'fit' => $fit, 'format' => '.'.$format]); - return null; + return route('glide-image.preset', array_merge($params, [ + 'file' => ltrim($asset->url(), '/'), + ])); } protected static function getManipulator(Asset $item, string $preset, string $fit, ?string $format = null): ImageManipulator|string @@ -171,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)); 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/Controllers/ImageControllerTest.php b/tests/Controllers/ImageControllerTest.php new file mode 100644 index 0000000..9e60c2b --- /dev/null +++ b/tests/Controllers/ImageControllerTest.php @@ -0,0 +1,223 @@ +controller = app(ImageController::class); + } + + #[Test] + public function it_gets_presets(): void + { + $asset = $this->uploadTestAsset('upload.png'); + $presets = Responsive::getPresets($asset); + + $this->assertArrayHasKey('webp', $presets); + } + + #[Test] + public function it_returns_404_for_missing_asset(): void + { + $this->expectException(\Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class); + + $this->controller->getImageByPreset( + request(), + 'xs', + 'contain', + 'dummy-signature', + 'non-existent-file.jpg', + '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'); + $storagePrefix = config('justbetter.glide-directive.storage_prefix'); + $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}"); + } + } +} 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(); } }