Skip to content

Commit 8f9b454

Browse files
authored
Merge pull request #11 from ace-of-aces/feat/signed-urls
[Feature]: Signed URLs
2 parents 17d04ff + a1377fa commit 8f9b454

17 files changed

+519
-48
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@
8383
"extra": {
8484
"laravel": {
8585
"providers": [
86-
"AceOfAces\\LaravelImageTransformUrl\\LaravelImageTransformUrlServiceProvider"
86+
"AceOfAces\\LaravelImageTransformUrl\\ImageTransformUrlServiceProvider"
8787
]
8888
}
8989
},

config/image-transform-url.php

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,18 +95,37 @@
9595
| new transformation by the path and IP address. It is recommended to
9696
| set this to a low value, e.g. 2 requests per minute, to prevent
9797
| abuse.
98+
|
9899
*/
99100

100101
'rate_limit' => [
101102
'enabled' => env('IMAGE_TRANSFORM_RATE_LIMIT_ENABLED', true),
102-
'disabled_for_environments' => [
103+
'disabled_for_environments' => env('IMAGE_TRANSFORM_RATE_LIMIT_DISABLED_FOR_ENVIRONMENTS', [
103104
'local',
104105
'testing',
105-
],
106+
]),
106107
'max_attempts' => env('IMAGE_TRANSFORM_RATE_LIMIT_MAX_REQUESTS', 2),
107108
'decay_seconds' => env('IMAGE_TRANSFORM_RATE_LIMIT_DECAY_SECONDS', 60),
108109
],
109110

111+
/*
112+
|--------------------------------------------------------------------------
113+
| Signed URLs
114+
|--------------------------------------------------------------------------
115+
|
116+
| Below you may configure signed URLs, which can be used to protect image
117+
| transformations from unauthorized access. Signature verification is
118+
| only applied to images from the for_source_directories array.
119+
|
120+
*/
121+
122+
'signed_urls' => [
123+
'enabled' => env('IMAGE_TRANSFORM_SIGNED_URLS_ENABLED', false),
124+
'for_source_directories' => env('IMAGE_TRANSFORM_SIGNED_URLS_FOR_SOURCE_DIRECTORIES', [
125+
//
126+
]),
127+
],
128+
110129
/*
111130
|--------------------------------------------------------------------------
112131
| Response Headers

docs/.vitepress/config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export default defineConfig({
5151
{
5252
text: 'Advanced',
5353
items: [
54+
{ text: 'Signed URLs', link: '/signed-urls' },
5455
{ text: 'Image Caching', link: '/image-caching' },
5556
{ text: 'Rate Limiting', link: '/rate-limiting' },
5657
{ text: 'CDN Usage', link: '/cdn-usage' },

docs/pages/error-handling.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@
22

33
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.
44

5+
## HTTP Status Codes
6+
57
This is why the route handler will return a plain `404` response if:
68

79
- a requested image does not exist at the specified path
810
- the requested image is not a valid image file
911
- the provided options are not in the correct format (`key=value`, no trailing comma, etc.)
1012

11-
The only other HTTP error that can be returned is a `429` response, which indicates that the request was rate-limited.
13+
The only two other HTTP errors that can be returned are:
14+
- a `429` response, which indicates that the request was rate-limited
15+
- a `403` response, which indicates that the request was unauthorized (e.g. when using signed URLs and the signature is invalid or expired)
16+
17+
## Invalid options
1218

1319
If parts of the given route options are invalid, the route handler will ignore them and only apply the valid options.
1420

docs/pages/signed-urls.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Signed URLs
2+
3+
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).
4+
5+
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.
6+
7+
::: info
8+
Signed URLs also ensure that the provided options cannot be modified client-side.
9+
:::
10+
11+
::: warning
12+
The Signed URL feature does not restrict access to public images.
13+
If you want to secure access to images, ensure that the source directories you want signed URLs for are not publicly accessible.
14+
:::
15+
16+
## Setup
17+
18+
To enable signed URLs, set the `signed_urls.enabled` option to `true` in your `image-transform-url.php` configuration.
19+
20+
You then need to specify the source directories for which signed URLs should apply to in the `signed_urls.source_directories` array.
21+
22+
For example:
23+
24+
```php
25+
'source_directories' => [
26+
'images' => public_path('images'),
27+
'protected' => storage_path('app/private/protected-images'),
28+
],
29+
30+
// ...
31+
32+
'signed_urls' => [
33+
'enabled' => env('IMAGE_TRANSFORM_SIGNED_URLS_ENABLED', false),
34+
'for_source_directories' => env('IMAGE_TRANSFORM_SIGNED_URLS_FOR_SOURCE_DIRECTORIES', [
35+
'protected-images',
36+
]),
37+
],
38+
```
39+
40+
## Generating Signed URLs
41+
42+
To generate a signed URL for an image, you can use the `ImageTransformUrl` facade:
43+
44+
```php
45+
use AceOfAces\LaravelImageTransformUrl\Facades\ImageTransformUrl;
46+
47+
$options = [
48+
'blur' => 50,
49+
'width' 500,
50+
];
51+
52+
$blurredImage = ImageTransformUrl::signedUrl(
53+
'example.jpg',
54+
$options,
55+
'protected'
56+
);
57+
```
58+
59+
## Temporary Signed URLs
60+
61+
If you would like to to generate a signed URL that expires after a certain time, you can use the `temporarySignedUrl` method:
62+
63+
```php
64+
use AceOfAces\LaravelImageTransformUrl\Facades\ImageTransformUrl;
65+
66+
$options = [
67+
'blur' => 50,
68+
'width' 500,
69+
];
70+
71+
$temporarySignedUrl = ImageTransformUrl::temporarySignedUrl(
72+
'example.jpg',
73+
$options,
74+
now()->addMinutes(60),
75+
'protected'
76+
);
77+
```
78+
79+
::: info
80+
You can also use the generic `signedUrl` method to generate temporary signed URLs.
81+
This method accepts an `$expiration` parameter, which defaults to `null`. If you provide a value, it will generate a temporary signed URL.
82+
:::

routes/image.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
declare(strict_types=1);
44

55
use AceOfAces\LaravelImageTransformUrl\Http\Controllers\ImageTransformerController;
6+
use AceOfAces\LaravelImageTransformUrl\Http\Middleware\SignedImageTransformMiddleware;
67
use Illuminate\Support\Facades\Route;
78

89
Route::prefix(config()->string('image-transform-url.route_prefix'))->group(function () {
@@ -11,11 +12,13 @@
1112
->where('pathPrefix', '[a-zA-Z][a-zA-Z0-9_-]*')
1213
->where('options', '([a-zA-Z]+=-?[a-zA-Z0-9]+,?)+')
1314
->where('path', '.*\..*')
14-
->name('image.transform');
15+
->name('image.transform')
16+
->middleware(SignedImageTransformMiddleware::class);
1517

1618
// Default path prefix route
1719
Route::get('{options}/{path}', [ImageTransformerController::class, 'transformDefault'])
1820
->where('options', '([a-zA-Z]+=-?[a-zA-Z0-9]+,?)+')
1921
->where('path', '.*\..*')
20-
->name('image.transform.default');
22+
->name('image.transform.default')
23+
->middleware(SignedImageTransformMiddleware::class);
2124
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AceOfAces\LaravelImageTransformUrl\Exceptions;
6+
7+
use RuntimeException;
8+
use Throwable;
9+
10+
class InvalidConfigurationException extends RuntimeException
11+
{
12+
public function __construct(string $message, $code = 0, ?Throwable $previous = null)
13+
{
14+
parent::__construct($message, $code, $previous);
15+
}
16+
}

src/Facades/ImageTransformUrl.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AceOfAces\LaravelImageTransformUrl\Facades;
6+
7+
use Illuminate\Support\Facades\Facade;
8+
9+
/**
10+
* @see \AceOfAces\LaravelImageTransformUrl\LaravelImageTransformUrl;
11+
*
12+
* @method string signedUrl(string $path, array|string $options = [], ?string $pathPrefix = null, \DateTimeInterface|\DateInterval|int|null $expiration = null, ?bool $absolute = true)
13+
* @method string temporarySignedUrl(string $path, array|string $options = [], \DateTimeInterface|\DateInterval|int $expiration, ?string $pathPrefix = null, ?bool $absolute = true)
14+
*/
15+
class ImageTransformUrl extends Facade
16+
{
17+
protected static function getFacadeAccessor(): string
18+
{
19+
return \AceOfAces\LaravelImageTransformUrl\ImageTransformUrl::class;
20+
}
21+
}

src/Http/Controllers/ImageTransformerController.php

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,44 +27,51 @@ class ImageTransformerController extends \Illuminate\Routing\Controller
2727
{
2828
use ManagesImageCache, ResolvesOptions;
2929

30-
public function transformWithPrefix(Request $request, string $pathPrefix, string $options, string $path)
30+
/**
31+
* Transform an image with a specified path prefix and custom options.
32+
*/
33+
public function transformWithPrefix(Request $request, string $pathPrefix, string $options, string $path): Response
3134
{
3235
return $this->handleTransform($request, $pathPrefix, $options, $path);
3336
}
3437

35-
public function transformDefault(Request $request, string $options, string $path)
38+
/**
39+
* Transform an image with the default path prefix and custom options.
40+
*/
41+
public function transformDefault(Request $request, string $options, string $path): Response
3642
{
3743
return $this->handleTransform($request, null, $options, $path);
3844
}
3945

40-
protected function handleTransform(Request $request, ?string $pathPrefix, string $options, ?string $path = null)
46+
/**
47+
* Handle the image transformation logic.
48+
*/
49+
protected function handleTransform(Request $request, ?string $pathPrefix, string $options, ?string $path = null): Response
4150
{
4251
$realPath = $this->handlePath($pathPrefix, $path);
4352

4453
$options = $this->parseOptions($options);
4554

46-
// Check cache
4755
if (config()->boolean('image-transform-url.cache.enabled')) {
4856
$cachePath = $this->getCachePath($pathPrefix, $path, $options);
4957

5058
if (File::exists($cachePath)) {
5159
if (Cache::has('image-transform-url:'.$cachePath)) {
52-
// serve file from storage
5360
return $this->imageResponse(
5461
imageContent: File::get($cachePath),
5562
mimeType: File::mimeType($cachePath),
5663
cacheHit: true
5764
);
5865
} else {
59-
// Cache expired, delete the cache file and continue
6066
File::delete($cachePath);
6167
}
6268
}
6369
}
6470

6571
if (
6672
config()->boolean('image-transform-url.rate_limit.enabled') &&
67-
! in_array(App::environment(), config()->array('image-transform-url.rate_limit.disabled_for_environments'))) {
73+
! in_array(App::environment(), config()->array('image-transform-url.rate_limit.disabled_for_environments'))
74+
) {
6875
$this->rateLimit($request, $path);
6976
}
7077

@@ -109,7 +116,6 @@ protected function handleTransform(Request $request, ?string $pathPrefix, string
109116

110117
}
111118

112-
// We use the mime type instead of the extension to determine the format, because this is more reliable.
113119
$originalMimetype = File::mimeType($realPath);
114120

115121
$format = $this->getStringOptionValue($options, 'format', $originalMimetype);
@@ -141,20 +147,21 @@ protected function handleTransform(Request $request, ?string $pathPrefix, string
141147

142148
/**
143149
* Handle the path and ensure it is valid.
150+
*
151+
* @param-out string $pathPrefix
144152
*/
145153
protected function handlePath(?string &$pathPrefix, ?string &$path): string
146154
{
147155
if ($path === null) {
148156
$path = $pathPrefix;
149-
$pathPrefix = null;
150157
}
151158

152-
$allowedSourceDirectories = config('image-transform-url.source_directories', []);
153-
154-
if (! $pathPrefix) {
155-
$pathPrefix = config('image-transform-url.default_source_directory') ?? array_key_first($allowedSourceDirectories);
159+
if (is_null($pathPrefix)) {
160+
$pathPrefix = config()->string('image-transform-url.default_source_directory', (string) array_key_first(config()->array('image-transform-url.source_directories')));
156161
}
157162

163+
$allowedSourceDirectories = config()->array('image-transform-url.source_directories', []);
164+
158165
abort_unless(array_key_exists($pathPrefix, $allowedSourceDirectories), 404);
159166

160167
$basePath = $allowedSourceDirectories[$pathPrefix];
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AceOfAces\LaravelImageTransformUrl\Http\Middleware;
6+
7+
use Closure;
8+
use Illuminate\Http\Request;
9+
use Illuminate\Routing\Exceptions\InvalidSignatureException;
10+
use Illuminate\Routing\Middleware\ValidateSignature;
11+
use Symfony\Component\HttpFoundation\Response;
12+
13+
class SignedImageTransformMiddleware
14+
{
15+
/**
16+
* Handle an incoming request and conditionally apply signature verification.
17+
*
18+
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
19+
*/
20+
public function handle(Request $request, Closure $next): Response
21+
{
22+
$pathPrefix = $request->route('pathPrefix');
23+
24+
if (is_null($pathPrefix)) {
25+
$pathPrefix = config()->string('image-transform-url.default_source_directory');
26+
}
27+
28+
if ($this->requiresSignatureVerification($pathPrefix)) {
29+
return $this->validateSignature($request, $next);
30+
}
31+
32+
return $next($request);
33+
}
34+
35+
/**
36+
* Determine if signature verification is required for the given path prefix.
37+
*/
38+
protected function requiresSignatureVerification(string $pathPrefix): bool
39+
{
40+
if (! config()->boolean('image-transform-url.signed_urls.enabled')) {
41+
return false;
42+
}
43+
44+
$protectedDirectories = config()->array('image-transform-url.signed_urls.for_source_directories');
45+
46+
return in_array($pathPrefix, $protectedDirectories, true);
47+
}
48+
49+
/**
50+
* Validate the signature of the request.
51+
*
52+
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
53+
*/
54+
protected function validateSignature(Request $request, Closure $next): Response
55+
{
56+
$validator = new ValidateSignature;
57+
58+
try {
59+
return $validator->handle($request, $next);
60+
} catch (InvalidSignatureException $e) {
61+
throw $e;
62+
}
63+
}
64+
}

0 commit comments

Comments
 (0)