Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
"extra": {
"laravel": {
"providers": [
"AceOfAces\\LaravelImageTransformUrl\\LaravelImageTransformUrlServiceProvider"
"AceOfAces\\LaravelImageTransformUrl\\ImageTransformUrlServiceProvider"
]
}
},
Expand Down
23 changes: 21 additions & 2 deletions config/image-transform-url.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,37 @@
| 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),
],

/*
|--------------------------------------------------------------------------
| 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
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
8 changes: 7 additions & 1 deletion docs/pages/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
82 changes: 82 additions & 0 deletions docs/pages/signed-urls.md
Original file line number Diff line number Diff line change
@@ -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.
:::
7 changes: 5 additions & 2 deletions routes/image.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand All @@ -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);
});
16 changes: 16 additions & 0 deletions src/Exceptions/InvalidConfigurationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace AceOfAces\LaravelImageTransformUrl\Exceptions;

use RuntimeException;
use Throwable;

class InvalidConfigurationException extends RuntimeException
{
public function __construct(string $message, $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}
21 changes: 21 additions & 0 deletions src/Facades/ImageTransformUrl.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace AceOfAces\LaravelImageTransformUrl\Facades;

use Illuminate\Support\Facades\Facade;

/**
* @see \AceOfAces\LaravelImageTransformUrl\LaravelImageTransformUrl;
*
* @method string signedUrl(string $path, array|string $options = [], ?string $pathPrefix = null, \DateTimeInterface|\DateInterval|int|null $expiration = null, ?bool $absolute = true)
* @method string temporarySignedUrl(string $path, array|string $options = [], \DateTimeInterface|\DateInterval|int $expiration, ?string $pathPrefix = null, ?bool $absolute = true)
*/
class ImageTransformUrl extends Facade
{
protected static function getFacadeAccessor(): string
{
return \AceOfAces\LaravelImageTransformUrl\ImageTransformUrl::class;
}
}
33 changes: 20 additions & 13 deletions src/Http/Controllers/ImageTransformerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,44 +27,51 @@ 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);
}
}
}

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);
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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];
Expand Down
64 changes: 64 additions & 0 deletions src/Http/Middleware/SignedImageTransformMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace AceOfAces\LaravelImageTransformUrl\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Routing\Exceptions\InvalidSignatureException;
use Illuminate\Routing\Middleware\ValidateSignature;
use Symfony\Component\HttpFoundation\Response;

class SignedImageTransformMiddleware
{
/**
* Handle an incoming request and conditionally apply signature verification.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$pathPrefix = $request->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;
}
}
}
Loading