From 3b3a4db75c31ae3255e6014d9d91a08b14f82405 Mon Sep 17 00:00:00 2001 From: Julian Schramm Date: Sun, 20 Jul 2025 16:41:42 +0200 Subject: [PATCH 1/5] fix: pathPrefix handling, improve comments --- .../ImageTransformerController.php | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/Http/Controllers/ImageTransformerController.php b/src/Http/Controllers/ImageTransformerController.php index 462ec9a..37fa91f 100644 --- a/src/Http/Controllers/ImageTransformerController.php +++ b/src/Http/Controllers/ImageTransformerController.php @@ -27,36 +27,42 @@ class ImageTransformerController extends \Illuminate\Routing\Controller { use ManagesImageCache, ResolvesOptions; - public function transformWithPrefix(Request $request, string $pathPrefix, string $options, string $path) + /** + * Transform an image with a specified path prefix and custom options. + */ + public function transformWithPrefix(Request $request, string $pathPrefix, string $options, string $path): Response { return $this->handleTransform($request, $pathPrefix, $options, $path); } - public function transformDefault(Request $request, string $options, string $path) + /** + * Transform an image with the default path prefix and custom options. + */ + public function transformDefault(Request $request, string $options, string $path): Response { return $this->handleTransform($request, null, $options, $path); } - protected function handleTransform(Request $request, ?string $pathPrefix, string $options, ?string $path = null) + /** + * Handle the image transformation logic. + */ + protected function handleTransform(Request $request, ?string $pathPrefix, string $options, ?string $path = null): Response { $realPath = $this->handlePath($pathPrefix, $path); $options = $this->parseOptions($options); - // Check cache if (config()->boolean('image-transform-url.cache.enabled')) { $cachePath = $this->getCachePath($pathPrefix, $path, $options); if (File::exists($cachePath)) { if (Cache::has('image-transform-url:'.$cachePath)) { - // serve file from storage return $this->imageResponse( imageContent: File::get($cachePath), mimeType: File::mimeType($cachePath), cacheHit: true ); } else { - // Cache expired, delete the cache file and continue File::delete($cachePath); } } @@ -64,7 +70,8 @@ protected function handleTransform(Request $request, ?string $pathPrefix, string if ( config()->boolean('image-transform-url.rate_limit.enabled') && - ! in_array(App::environment(), config()->array('image-transform-url.rate_limit.disabled_for_environments'))) { + ! in_array(App::environment(), config()->array('image-transform-url.rate_limit.disabled_for_environments')) + ) { $this->rateLimit($request, $path); } @@ -109,7 +116,6 @@ protected function handleTransform(Request $request, ?string $pathPrefix, string } - // We use the mime type instead of the extension to determine the format, because this is more reliable. $originalMimetype = File::mimeType($realPath); $format = $this->getStringOptionValue($options, 'format', $originalMimetype); @@ -141,20 +147,21 @@ protected function handleTransform(Request $request, ?string $pathPrefix, string /** * Handle the path and ensure it is valid. + * + * @param-out string $pathPrefix */ protected function handlePath(?string &$pathPrefix, ?string &$path): string { if ($path === null) { $path = $pathPrefix; - $pathPrefix = null; } - $allowedSourceDirectories = config('image-transform-url.source_directories', []); - - if (! $pathPrefix) { - $pathPrefix = config('image-transform-url.default_source_directory') ?? array_key_first($allowedSourceDirectories); + if (is_null($pathPrefix)) { + $pathPrefix = config()->string('image-transform-url.default_source_directory', (string) array_key_first(config()->array('image-transform-url.source_directories'))); } + $allowedSourceDirectories = config()->array('image-transform-url.source_directories', []); + abort_unless(array_key_exists($pathPrefix, $allowedSourceDirectories), 404); $basePath = $allowedSourceDirectories[$pathPrefix]; From b8d312bbc8478f4cee44ab488a13b6917015294b Mon Sep 17 00:00:00 2001 From: Julian Schramm Date: Sun, 20 Jul 2025 16:42:59 +0200 Subject: [PATCH 2/5] feat: add env override option to rate_limit.disabled_for_environments config key --- config/image-transform-url.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/config/image-transform-url.php b/config/image-transform-url.php index 7d8ad20..ab8c073 100644 --- a/config/image-transform-url.php +++ b/config/image-transform-url.php @@ -95,14 +95,15 @@ | new transformation by the path and IP address. It is recommended to | set this to a low value, e.g. 2 requests per minute, to prevent | abuse. + | */ 'rate_limit' => [ 'enabled' => env('IMAGE_TRANSFORM_RATE_LIMIT_ENABLED', true), - 'disabled_for_environments' => [ + 'disabled_for_environments' => env('IMAGE_TRANSFORM_RATE_LIMIT_DISABLED_FOR_ENVIRONMENTS', [ 'local', 'testing', - ], + ]), 'max_attempts' => env('IMAGE_TRANSFORM_RATE_LIMIT_MAX_REQUESTS', 2), 'decay_seconds' => env('IMAGE_TRANSFORM_RATE_LIMIT_DECAY_SECONDS', 60), ], From 683e4d6d15a97b1d713a472dade8de67554ccc9a Mon Sep 17 00:00:00 2001 From: Julian Schramm Date: Sun, 20 Jul 2025 16:47:27 +0200 Subject: [PATCH 3/5] feat!: add signed URLs, rename package Service Provider and Facade --- composer.json | 2 +- config/image-transform-url.php | 18 +++++ routes/image.php | 7 +- .../InvalidConfigurationException.php | 16 +++++ src/Facades/ImageTransformUrl.php | 21 ++++++ .../SignedImageTransformMiddleware.php | 64 +++++++++++++++++ src/ImageTransformUrl.php | 70 +++++++++++++++++++ src/ImageTransformUrlServiceProvider.php | 41 +++++++++++ src/LaravelImageTransformUrl.php | 5 -- ...aravelImageTransformUrlServiceProvider.php | 22 ------ tests/TestCase.php | 4 +- 11 files changed, 238 insertions(+), 32 deletions(-) create mode 100644 src/Exceptions/InvalidConfigurationException.php create mode 100644 src/Facades/ImageTransformUrl.php create mode 100644 src/Http/Middleware/SignedImageTransformMiddleware.php create mode 100755 src/ImageTransformUrl.php create mode 100644 src/ImageTransformUrlServiceProvider.php delete mode 100755 src/LaravelImageTransformUrl.php delete mode 100644 src/LaravelImageTransformUrlServiceProvider.php diff --git a/composer.json b/composer.json index 1aa3180..47ec379 100644 --- a/composer.json +++ b/composer.json @@ -83,7 +83,7 @@ "extra": { "laravel": { "providers": [ - "AceOfAces\\LaravelImageTransformUrl\\LaravelImageTransformUrlServiceProvider" + "AceOfAces\\LaravelImageTransformUrl\\ImageTransformUrlServiceProvider" ] } }, diff --git a/config/image-transform-url.php b/config/image-transform-url.php index ab8c073..7229030 100644 --- a/config/image-transform-url.php +++ b/config/image-transform-url.php @@ -108,6 +108,24 @@ 'decay_seconds' => env('IMAGE_TRANSFORM_RATE_LIMIT_DECAY_SECONDS', 60), ], + /* + |-------------------------------------------------------------------------- + | Signed URLs + |-------------------------------------------------------------------------- + | + | Below you may configure signed URLs, which can be used to protect image + | transformations from unauthorized access. Signature verification is + | only applied to images from the for_source_directories array. + | + */ + + 'signed_urls' => [ + 'enabled' => env('IMAGE_TRANSFORM_SIGNED_URLS_ENABLED', false), + 'for_source_directories' => env('IMAGE_TRANSFORM_SIGNED_URLS_FOR_SOURCE_DIRECTORIES', [ + // + ]), + ], + /* |-------------------------------------------------------------------------- | Response Headers diff --git a/routes/image.php b/routes/image.php index 3f59b57..b788742 100644 --- a/routes/image.php +++ b/routes/image.php @@ -3,6 +3,7 @@ declare(strict_types=1); use AceOfAces\LaravelImageTransformUrl\Http\Controllers\ImageTransformerController; +use AceOfAces\LaravelImageTransformUrl\Http\Middleware\SignedImageTransformMiddleware; use Illuminate\Support\Facades\Route; Route::prefix(config()->string('image-transform-url.route_prefix'))->group(function () { @@ -11,11 +12,13 @@ ->where('pathPrefix', '[a-zA-Z][a-zA-Z0-9_-]*') ->where('options', '([a-zA-Z]+=-?[a-zA-Z0-9]+,?)+') ->where('path', '.*\..*') - ->name('image.transform'); + ->name('image.transform') + ->middleware(SignedImageTransformMiddleware::class); // Default path prefix route Route::get('{options}/{path}', [ImageTransformerController::class, 'transformDefault']) ->where('options', '([a-zA-Z]+=-?[a-zA-Z0-9]+,?)+') ->where('path', '.*\..*') - ->name('image.transform.default'); + ->name('image.transform.default') + ->middleware(SignedImageTransformMiddleware::class); }); diff --git a/src/Exceptions/InvalidConfigurationException.php b/src/Exceptions/InvalidConfigurationException.php new file mode 100644 index 0000000..2e32181 --- /dev/null +++ b/src/Exceptions/InvalidConfigurationException.php @@ -0,0 +1,16 @@ +route('pathPrefix'); + + if (is_null($pathPrefix)) { + $pathPrefix = config()->string('image-transform-url.default_source_directory'); + } + + if ($this->requiresSignatureVerification($pathPrefix)) { + return $this->validateSignature($request, $next); + } + + return $next($request); + } + + /** + * Determine if signature verification is required for the given path prefix. + */ + protected function requiresSignatureVerification(string $pathPrefix): bool + { + if (! config()->boolean('image-transform-url.signed_urls.enabled')) { + return false; + } + + $protectedDirectories = config()->array('image-transform-url.signed_urls.for_source_directories'); + + return in_array($pathPrefix, $protectedDirectories, true); + } + + /** + * Validate the signature of the request. + * + * @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next + */ + protected function validateSignature(Request $request, Closure $next): Response + { + $validator = new ValidateSignature; + + try { + return $validator->handle($request, $next); + } catch (InvalidSignatureException $e) { + throw $e; + } + } +} diff --git a/src/ImageTransformUrl.php b/src/ImageTransformUrl.php new file mode 100755 index 0000000..42fdd39 --- /dev/null +++ b/src/ImageTransformUrl.php @@ -0,0 +1,70 @@ +boolean('image-transform-url.signed_urls.enabled')) { + throw new InvalidConfigurationException('Signed URLs are not enabled. Please check your configuration.'); + } + + if (is_array($options)) { + $options = collect($options) + ->map(fn ($value, $key) => "$key=$value") + ->implode(','); + } + + if (empty($pathPrefix)) { + return URL::signedRoute( + 'image.transform.default', + ['options' => $options, 'path' => $path], + $expiration, + $absolute + ); + } + + return URL::signedRoute( + 'image.transform', + ['pathPrefix' => $pathPrefix, 'options' => $options, 'path' => $path], + $expiration, + $absolute + ); + } + + /** + * Create a temporary signed URL for the image transformation. + * + * @param string $path The path to the image. + * @param array|string $options The transformation options. + * @param DateTimeInterface|DateInterval|int $expiration The expiration time for the signed URL. + * @param string|null $pathPrefix The path prefix to use. Defaults to the default path prefix. + * @param bool|null $absolute Whether the URL should be absolute. Defaults to true. + * @return string The temporary signed URL. + * + * @throws InvalidConfigurationException If signed URLs are not enabled in the configuration. + */ + public function temporarySignedUrl(string $path, array|string $options, DateTimeInterface|DateInterval|int $expiration, ?string $pathPrefix, ?bool $absolute = true): string + { + return $this->signedUrl($path, $options, $pathPrefix, $expiration, $absolute); + } +} diff --git a/src/ImageTransformUrlServiceProvider.php b/src/ImageTransformUrlServiceProvider.php new file mode 100644 index 0000000..5db7463 --- /dev/null +++ b/src/ImageTransformUrlServiceProvider.php @@ -0,0 +1,41 @@ +name('laravel-image-transform-url') + ->hasConfigFile() + ->hasRoute('image'); + } + + public function packageBooted(): void + { + $this->registerMiddleware(); + } + + /** + * Register the custom middleware. + */ + protected function registerMiddleware(): void + { + $router = $this->app->make(Router::class); + + $router->aliasMiddleware('signed-image-transform', SignedImageTransformMiddleware::class); + } +} diff --git a/src/LaravelImageTransformUrl.php b/src/LaravelImageTransformUrl.php deleted file mode 100755 index 5118de3..0000000 --- a/src/LaravelImageTransformUrl.php +++ /dev/null @@ -1,5 +0,0 @@ -name('laravel-image-transform-url') - ->hasConfigFile() - ->hasRoute('image'); - } -} diff --git a/tests/TestCase.php b/tests/TestCase.php index c33e23d..208ca40 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -3,7 +3,7 @@ namespace AceOfAces\LaravelImageTransformUrl\Tests; use AceOfAces\LaravelImageTransformUrl\Enums\AllowedOptions; -use AceOfAces\LaravelImageTransformUrl\LaravelImageTransformUrlServiceProvider; +use AceOfAces\LaravelImageTransformUrl\ImageTransformUrlServiceProvider; use AceOfAces\LaravelImageTransformUrl\Traits\ManagesImageCache; use Illuminate\Contracts\Config\Repository; use Illuminate\Support\Facades\Storage; @@ -25,7 +25,7 @@ protected function setUp(): void protected function getPackageProviders($app) { return [ - LaravelImageTransformUrlServiceProvider::class, + ImageTransformUrlServiceProvider::class, ]; } From baf21fe8c3efc9b6d46c9ab5fb2f8ad3c05e5fad Mon Sep 17 00:00:00 2001 From: Julian Schramm Date: Sun, 20 Jul 2025 16:48:43 +0200 Subject: [PATCH 4/5] test: add tests for signed URL feature and exception handling --- tests/Feature/FacadeTest.php | 35 +++++++++ tests/Feature/SignedUrlTest.php | 133 ++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 tests/Feature/FacadeTest.php create mode 100644 tests/Feature/SignedUrlTest.php diff --git a/tests/Feature/FacadeTest.php b/tests/Feature/FacadeTest.php new file mode 100644 index 0000000..8e397ce --- /dev/null +++ b/tests/Feature/FacadeTest.php @@ -0,0 +1,35 @@ +set('image-transform-url.signed_urls.enabled', true); + + $signedUrlOne = ImageTransformUrl::signedUrl( + 'path/to/image.jpg', + ['width' => 100, 'height' => 200], + 'images', + now()->addMinutes(60) + ); + + $signedUrlTwo = ImageTransformUrl::temporarySignedUrl( + 'path/to/image.jpg', + ['width' => 100, 'height' => 200], + now()->addMinutes(60), + 'images' + ); + + expect($signedUrlOne)->toBe($signedUrlTwo); +}); + +it('throws an exception when signed URLs are not enabled', function () { + /** @var TestCase $this */ + config()->set('image-transform-url.signed_urls.enabled', false); + + ImageTransformUrl::signedUrl('path/to/image.jpg', ['width' => 100, 'height' => 200]); +})->throws(InvalidConfigurationException::class, 'Signed URLs are not enabled. Please check your configuration.'); diff --git a/tests/Feature/SignedUrlTest.php b/tests/Feature/SignedUrlTest.php new file mode 100644 index 0000000..4467175 --- /dev/null +++ b/tests/Feature/SignedUrlTest.php @@ -0,0 +1,133 @@ +string('image-transform-url.cache.disk')); +}); + +function configureTestEnvironment(): void +{ + config()->set('image-transform-url.signed_urls.enabled', true); + config()->set('image-transform-url.signed_urls.for_source_directories', ['protected']); + config()->set('image-transform-url.source_directories', [ + 'test-data' => public_path('test-data'), + 'protected' => Storage::fake('local')->path('protected'), + ]); +} + +it('can protect a route with a signed URL', function () { + /** @var TestCase $this */ + configureTestEnvironment(); + + Storage::disk('local')->put('protected/cat.jpg', file_get_contents(public_path('test-data/cat.jpg'))); + + assert(Storage::disk('local')->exists('protected/cat.jpg')); + + $response = $this->get(route('image.transform', [ + 'pathPrefix' => 'protected', + 'options' => 'width=100', + 'path' => 'cat.jpg', + ])); + + $response->assertStatus(403); + + $signedUrl = ImageTransformUrl::signedUrl('cat.jpg', [ + 'width' => 100, + ], 'protected'); + + $secondResponse = $this->get($signedUrl); + + expect($secondResponse)->toBeImage([ + 'mime' => 'image/jpeg', + ]); +}); + +it('can protect a route with a temporary signed URL that expires', function () { + /** @var TestCase $this */ + configureTestEnvironment(); + + Storage::disk('local')->put('protected/cat.jpg', file_get_contents(public_path('test-data/cat.jpg'))); + + assert(Storage::disk('local')->exists('protected/cat.jpg')); + + $signedUrl = ImageTransformUrl::signedUrl( + 'cat.jpg', + ['width' => 100], + 'protected', + now()->addMinutes(60), + ); + + $response = $this->get($signedUrl); + + expect($response)->toBeImage([ + 'mime' => 'image/jpeg', + ]); + + $this->travel(61)->minutes(); + + $expiredResponse = $this->get($signedUrl); + $expiredResponse->assertStatus(403); +}); + +it('cannot manipulate signatures to access images', function () { + /** @var TestCase $this */ + configureTestEnvironment(); + + Storage::disk('local')->put('protected/cat.jpg', file_get_contents(public_path('test-data/cat.jpg'))); + + assert(Storage::disk('local')->exists('protected/cat.jpg')); + + $signedUrl = ImageTransformUrl::signedUrl('cat.jpg', [ + 'width' => 100, + ], 'protected'); + + $manipulatedOptionsUrl = str_replace('width=100', 'width=500', $signedUrl); + $manipulatedResponse = $this->get($manipulatedOptionsUrl); + $manipulatedResponse->assertStatus(403); + + $manipulatedSignatureUrl = substr($signedUrl, 0, -5).'12345'; + $manipulatedSignatureResponse = $this->get($manipulatedSignatureUrl); + $manipulatedSignatureResponse->assertStatus(403); +}); + +it('can use a protected directory as default source directory', function () { + /** @var TestCase $this */ + configureTestEnvironment(); + + config()->set('image-transform-url.default_source_directory', 'protected'); + + Storage::disk('local')->put('protected/cat.jpg', file_get_contents(public_path('test-data/cat.jpg'))); + + assert(Storage::disk('local')->exists('protected/cat.jpg')); + + $signedUrl = ImageTransformUrl::signedUrl('cat.jpg', [ + 'width' => 100, + ]); + + $response = $this->get($signedUrl); + + expect($response)->toBeImage([ + 'mime' => 'image/jpeg', + ]); +}); + +it('can still access an unprotected source directory without signed URLs', function () { + /** @var TestCase $this */ + configureTestEnvironment(); + + $response = $this->get(route('image.transform.default', [ + 'options' => 'width=100', + 'path' => 'cat.jpg', + ])); + + expect($response)->toBeImage([ + 'mime' => 'image/jpeg', + ]); +}); From a1377fa916e8d64f62f5f245555e9f084f6a94de Mon Sep 17 00:00:00 2001 From: Julian Schramm Date: Sun, 20 Jul 2025 17:34:21 +0200 Subject: [PATCH 5/5] docs: document signed URLs, update error handling --- docs/.vitepress/config.mts | 1 + docs/pages/error-handling.md | 8 +++- docs/pages/signed-urls.md | 82 ++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 docs/pages/signed-urls.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 560f33f..2e35011 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -51,6 +51,7 @@ export default defineConfig({ { text: 'Advanced', items: [ + { text: 'Signed URLs', link: '/signed-urls' }, { text: 'Image Caching', link: '/image-caching' }, { text: 'Rate Limiting', link: '/rate-limiting' }, { text: 'CDN Usage', link: '/cdn-usage' }, diff --git a/docs/pages/error-handling.md b/docs/pages/error-handling.md index 2550341..fbeb68a 100644 --- a/docs/pages/error-handling.md +++ b/docs/pages/error-handling.md @@ -2,13 +2,19 @@ The route handler of this package is designed to be robust against invalid options, paths and file names, while also not exposing additional information of your applications public directory structure. +## HTTP Status Codes + This is why the route handler will return a plain `404` response if: - a requested image does not exist at the specified path - the requested image is not a valid image file - the provided options are not in the correct format (`key=value`, no trailing comma, etc.) -The only other HTTP error that can be returned is a `429` response, which indicates that the request was rate-limited. +The only two other HTTP errors that can be returned are: +- a `429` response, which indicates that the request was rate-limited +- a `403` response, which indicates that the request was unauthorized (e.g. when using signed URLs and the signature is invalid or expired) + +## Invalid options If parts of the given route options are invalid, the route handler will ignore them and only apply the valid options. diff --git a/docs/pages/signed-urls.md b/docs/pages/signed-urls.md new file mode 100644 index 0000000..f6e7126 --- /dev/null +++ b/docs/pages/signed-urls.md @@ -0,0 +1,82 @@ +# Signed URLs + +This package provides the option to generate signed URLs for images from specific source directories powered by [Laravel's URL signing feature](https://laravel.com/docs/urls#signed-urls). + +This can be useful for securing access to images that should not be publicly accessible without proper authorization or only in a scaled down version. + +::: info +Signed URLs also ensure that the provided options cannot be modified client-side. +::: + +::: warning +The Signed URL feature does not restrict access to public images. +If you want to secure access to images, ensure that the source directories you want signed URLs for are not publicly accessible. +::: + +## Setup + +To enable signed URLs, set the `signed_urls.enabled` option to `true` in your `image-transform-url.php` configuration. + +You then need to specify the source directories for which signed URLs should apply to in the `signed_urls.source_directories` array. + +For example: + +```php +'source_directories' => [ + 'images' => public_path('images'), + 'protected' => storage_path('app/private/protected-images'), +], + +// ... + +'signed_urls' => [ + 'enabled' => env('IMAGE_TRANSFORM_SIGNED_URLS_ENABLED', false), + 'for_source_directories' => env('IMAGE_TRANSFORM_SIGNED_URLS_FOR_SOURCE_DIRECTORIES', [ + 'protected-images', + ]), +], +``` + +## Generating Signed URLs + +To generate a signed URL for an image, you can use the `ImageTransformUrl` facade: + +```php +use AceOfAces\LaravelImageTransformUrl\Facades\ImageTransformUrl; + +$options = [ + 'blur' => 50, + 'width' 500, +]; + +$blurredImage = ImageTransformUrl::signedUrl( + 'example.jpg', + $options, + 'protected' +); +``` + +## Temporary Signed URLs + +If you would like to to generate a signed URL that expires after a certain time, you can use the `temporarySignedUrl` method: + +```php +use AceOfAces\LaravelImageTransformUrl\Facades\ImageTransformUrl; + +$options = [ + 'blur' => 50, + 'width' 500, +]; + +$temporarySignedUrl = ImageTransformUrl::temporarySignedUrl( + 'example.jpg', + $options, + now()->addMinutes(60), + 'protected' +); +``` + +::: info +You can also use the generic `signedUrl` method to generate temporary signed URLs. +This method accepts an `$expiration` parameter, which defaults to `null`. If you provide a value, it will generate a temporary signed URL. +:::