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
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@
"spatie/laravel-package-tools": "^1.16"
},
"require-dev": {
"larastan/larastan": "^2.9||^3.0",
"laravel/pint": "^1.14",
"league/flysystem-aws-s3-v3": "^3.0",
"nunomaduro/collision": "^8.1.1||^7.10.0",
"larastan/larastan": "^2.9||^3.0",
"orchestra/testbench": "^10.0.0||^9.0.0||^8.22.0",
"pestphp/pest": "^3.0",
"pestphp/pest-plugin-arch": "^3.0",
Expand Down
3 changes: 3 additions & 0 deletions config/image-transform-url.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
| Important: The public storage directory should be addressed directly via
| storage('app/public') instead of the public_path('storage') link.
|
| You can also use any Laravel Filesystem disk (e.g. S3) by providing an
| array configuration with 'disk' and an optional 'prefix'.
|
*/

'source_directories' => [
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export default defineConfig({
{ text: 'Signed URLs', link: '/signed-urls' },
{ text: 'Image Caching', link: '/image-caching' },
{ text: 'Rate Limiting', link: '/rate-limiting' },
{ text: 'S3 Usage', link: '/s3-usage' },
{ text: 'CDN Usage', link: '/cdn-usage' },
{ text: 'Error Handling', link: '/error-handling' },
]
Expand Down
24 changes: 24 additions & 0 deletions docs/pages/s3-usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Usage with S3

This guide explains how to configure this package to work with S3-compatible storage services like AWS S3 or Cloudflare R2.
This enables you to transform and serve images stored remotely without the need to store images on your local server.

1. Set up your S3 disk in your [filesystems configuration](https://laravel.com/docs/filesystem#amazon-s3-compatible-filesystems), install the [S3 package](https://laravel.com/docs/filesystem#s3-driver-configuration) and ensure you have the necessary credentials and settings for your S3 bucket. Public bucket access is not required.

2. Configure the package via `image-transform-url.php` to include your S3 disk in the `source_directories` as described in [the setup guide](/setup#configuring-remote-sources).

3. If you are using the [Image Caching](/image-caching) feature and want to store transformed images back to your S3 bucket instead of your local filesystem, you may also set the `cache.disk` option in the `image-transform-url.php` configuration file to your S3 disk.

```php
'cache' => [
//...
'disk' => env('IMAGE_TRANSFORM_CACHE_DISK', 's3'),
//...
],
```

::: warning
Having the `cache.disk` set to your S3 disk may result in higher latency and costs due to the nature of remote storage. If you are concerned about performance, consider using a local disk for caching and only use S3 for the source directories.
:::

4. You can now use the [image transformation URLs](/getting-started) as usual, and the package will handle fetching images from your S3 bucket.
18 changes: 18 additions & 0 deletions docs/pages/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ An example source directory configuration might look like this:
| Important: The public storage directory should be addressed directly via
| storage('app/public') instead of the public_path('storage') link.
|
| You can also use any Laravel Filesystem disk (e.g. S3) by providing an
| array configuration with 'disk' and an optional 'prefix'.
|
*/
'source_directories' => [
'images' => public_path('images'),
Expand All @@ -40,3 +43,18 @@ An example source directory configuration might look like this:
'default_source_directory' => env('IMAGE_TRANSFORM_DEFAULT_SOURCE_DIRECTORY', 'images'),
// ...
```

## Configuring Remote Sources
If you want to use a remote source (like AWS S3 or Cloudflare R2) as a source directory, you can configure any [Laravel Filesystem disk](https://laravel.com/docs/filesystem#configuration) in your `config/filesystems.php` file and then reference it in the `source_directories` configuration.

```php
'source_directories' => [
// Other source directories...
'remote' => [
'disk' => 's3', // Any valid Laravel Filesystem disk
'prefix' => 'images', // Optional, if you want to specify a subdirectory
],
],
```

Read the [full guide on how to use this package with S3](/s3-usage.md).
82 changes: 75 additions & 7 deletions src/Http/Controllers/ImageTransformerController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@
use AceOfAces\LaravelImageTransformUrl\Enums\AllowedOptions;
use AceOfAces\LaravelImageTransformUrl\Traits\ManagesImageCache;
use AceOfAces\LaravelImageTransformUrl\Traits\ResolvesOptions;
use AceOfAces\LaravelImageTransformUrl\ValueObjects\ImageSource;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Drivers\Gd\Encoders\WebpEncoder;
use Intervention\Image\Encoders\AutoEncoder;
Expand Down Expand Up @@ -48,7 +51,7 @@ public function transformDefault(Request $request, string $options, string $path
*/
protected function handleTransform(Request $request, ?string $pathPrefix, string $options, ?string $path = null): Response
{
$realPath = $this->handlePath($pathPrefix, $path);
$source = $this->handlePath($pathPrefix, $path);

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

Expand All @@ -75,7 +78,10 @@ protected function handleTransform(Request $request, ?string $pathPrefix, string
$this->rateLimit($request, $path);
}

$image = Image::read($realPath);
$image = match ($source->type) {
'disk' => Image::read(Storage::disk($source->disk)->get($source->path)),
default => Image::read($source->path),
};

if (Arr::hasAny($options, ['width', 'height'])) {
$image->scale(
Expand Down Expand Up @@ -116,7 +122,7 @@ protected function handleTransform(Request $request, ?string $pathPrefix, string

}

$originalMimetype = File::mimeType($realPath);
$originalMimetype = $source->mime;

$format = $this->getStringOptionValue($options, 'format', $originalMimetype);
$quality = $this->getPositiveIntOptionValue($options, 'quality', 100, 100);
Expand Down Expand Up @@ -150,7 +156,7 @@ protected function handleTransform(Request $request, ?string $pathPrefix, string
*
* @param-out string $pathPrefix
*/
protected function handlePath(?string &$pathPrefix, ?string &$path): string
protected function handlePath(?string &$pathPrefix, ?string &$path): ImageSource
{
if ($path === null) {
$path = $pathPrefix;
Expand All @@ -164,8 +170,38 @@ protected function handlePath(?string &$pathPrefix, ?string &$path): string

abort_unless(array_key_exists($pathPrefix, $allowedSourceDirectories), 404);

$basePath = $allowedSourceDirectories[$pathPrefix];
$requestedPath = $basePath.'/'.$path;
$base = $allowedSourceDirectories[$pathPrefix];

// Handle disk-based source directories
if (is_array($base) && array_key_exists('disk', $base)) {

$disk = (string) $base['disk'];
$prefix = isset($base['prefix']) ? trim((string) $base['prefix'], '/') : '';

$normalized = $this->normalizeRelativePath($path);
abort_unless(! is_null($normalized), 404);

$diskPath = trim($prefix !== '' ? $prefix.'/'.$normalized : $normalized, '/');

abort_unless(Storage::disk($disk)->exists($diskPath), 404);

/** @var FilesystemAdapter $diskAdapter */
$diskAdapter = Storage::disk($disk);
$mime = $diskAdapter->mimeType($diskPath);

abort_unless(in_array($mime, AllowedMimeTypes::all(), true), 404);

return new ImageSource(
type: 'disk',
path: $diskPath,
mime: $mime,
disk: $disk,
);
}

// Handle local filesystem paths
$basePath = (string) $base;
$requestedPath = rtrim($basePath, '/').'/'.$path;
$realPath = realpath($requestedPath);

abort_unless($realPath, 404);
Expand All @@ -177,7 +213,39 @@ protected function handlePath(?string &$pathPrefix, ?string &$path): string

abort_unless(in_array(File::mimeType($realPath), AllowedMimeTypes::all(), true), 404);

return $realPath;
return new ImageSource(
type: 'local',
path: $realPath,
mime: (string) File::mimeType($realPath),
);
}

/**
* Normalize a relative path by resolving `.` and `..` segments.
* Returns null if the path escapes above the root.
*/
protected function normalizeRelativePath(string $path): ?string
{
$path = str_replace('\\', '/', $path);
$segments = array_filter(explode('/', $path), fn ($s) => $s !== '');
$stack = [];

foreach ($segments as $segment) {
if ($segment === '.') {
continue;
}
if ($segment === '..') {
if (empty($stack)) {
return null;
}
array_pop($stack);

continue;
}
$stack[] = $segment;
}

return implode('/', $stack);
}

/**
Expand Down
22 changes: 22 additions & 0 deletions src/ValueObjects/ImageSource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace AceOfAces\LaravelImageTransformUrl\ValueObjects;

/**
* @internal
*/
readonly class ImageSource
{
public function __construct(
public readonly string $type,
public readonly string $path,
public readonly string $mime,
public readonly ?string $disk = null,
) {
if (! in_array($this->type, ['local', 'disk'], true)) {
throw new \InvalidArgumentException('Invalid image source type provided.');
}
}
}
1 change: 1 addition & 0 deletions testbench.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ workbench:
components: false
factories: true
views: false
config: true
build:
- asset-publish
- create-sqlite-db
Expand Down
54 changes: 54 additions & 0 deletions tests/Feature/S3SourceTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

use AceOfAces\LaravelImageTransformUrl\Tests\TestCase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;

beforeEach(function () {
Cache::flush();
Storage::fake(config()->string('image-transform-url.cache.disk'));
Storage::fake('s3');

// Configure S3 as a source directory using the disk driver
config()->set('image-transform-url.source_directories.s3', [
'disk' => 's3',
'prefix' => '',
]);
});

it('can serve from an s3 disk source directory', function () {
/** @var TestCase $this */
$imagePath = 'images/test.jpg';
Storage::disk('s3')->put($imagePath, file_get_contents(__DIR__.'/../../workbench/test-data/cat.jpg'));

$response = $this->get(route('image.transform', [
'options' => 'width=100',
'pathPrefix' => 's3',
'path' => $imagePath,
]));

expect($response)->toBeImage([
'width' => 100,
'mime' => 'image/jpeg',
]);
});

it('can use s3 as the default source directory', function () {
/** @var TestCase $this */
config()->set('image-transform-url.default_source_directory', 's3');

$imagePath = 'images/test.jpg';
Storage::disk('s3')->put($imagePath, file_get_contents(__DIR__.'/../../workbench/test-data/cat.jpg'));

$response = $this->get(route('image.transform.default', [
'options' => 'width=100',
'path' => $imagePath,
]));

expect($response)->toBeImage([
'width' => 100,
'mime' => 'image/jpeg',
]);
});
Loading
Loading