Skip to content

Commit 232ccf8

Browse files
authored
Merge pull request #13 from ace-of-aces/feat/s3
[Feat]: S3 Support
2 parents ac52a7f + a14e7c7 commit 232ccf8

File tree

11 files changed

+439
-8
lines changed

11 files changed

+439
-8
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@
2020
"spatie/laravel-package-tools": "^1.16"
2121
},
2222
"require-dev": {
23+
"larastan/larastan": "^2.9||^3.0",
2324
"laravel/pint": "^1.14",
25+
"league/flysystem-aws-s3-v3": "^3.0",
2426
"nunomaduro/collision": "^8.1.1||^7.10.0",
25-
"larastan/larastan": "^2.9||^3.0",
2627
"orchestra/testbench": "^10.0.0||^9.0.0||^8.22.0",
2728
"pestphp/pest": "^3.0",
2829
"pestphp/pest-plugin-arch": "^3.0",

config/image-transform-url.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
| Important: The public storage directory should be addressed directly via
1414
| storage('app/public') instead of the public_path('storage') link.
1515
|
16+
| You can also use any Laravel Filesystem disk (e.g. S3) by providing an
17+
| array configuration with 'disk' and an optional 'prefix'.
18+
|
1619
*/
1720

1821
'source_directories' => [

docs/.vitepress/config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export default defineConfig({
5454
{ text: 'Signed URLs', link: '/signed-urls' },
5555
{ text: 'Image Caching', link: '/image-caching' },
5656
{ text: 'Rate Limiting', link: '/rate-limiting' },
57+
{ text: 'S3 Usage', link: '/s3-usage' },
5758
{ text: 'CDN Usage', link: '/cdn-usage' },
5859
{ text: 'Error Handling', link: '/error-handling' },
5960
]

docs/pages/s3-usage.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Usage with S3
2+
3+
This guide explains how to configure this package to work with S3-compatible storage services like AWS S3 or Cloudflare R2.
4+
This enables you to transform and serve images stored remotely without the need to store images on your local server.
5+
6+
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.
7+
8+
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).
9+
10+
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.
11+
12+
```php
13+
'cache' => [
14+
//...
15+
'disk' => env('IMAGE_TRANSFORM_CACHE_DISK', 's3'),
16+
//...
17+
],
18+
```
19+
20+
::: warning
21+
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.
22+
:::
23+
24+
4. You can now use the [image transformation URLs](/getting-started) as usual, and the package will handle fetching images from your S3 bucket.

docs/pages/setup.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ An example source directory configuration might look like this:
2222
| Important: The public storage directory should be addressed directly via
2323
| storage('app/public') instead of the public_path('storage') link.
2424
|
25+
| You can also use any Laravel Filesystem disk (e.g. S3) by providing an
26+
| array configuration with 'disk' and an optional 'prefix'.
27+
|
2528
*/
2629
'source_directories' => [
2730
'images' => public_path('images'),
@@ -40,3 +43,18 @@ An example source directory configuration might look like this:
4043
'default_source_directory' => env('IMAGE_TRANSFORM_DEFAULT_SOURCE_DIRECTORY', 'images'),
4144
// ...
4245
```
46+
47+
## Configuring Remote Sources
48+
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.
49+
50+
```php
51+
'source_directories' => [
52+
// Other source directories...
53+
'remote' => [
54+
'disk' => 's3', // Any valid Laravel Filesystem disk
55+
'prefix' => 'images', // Optional, if you want to specify a subdirectory
56+
],
57+
],
58+
```
59+
60+
Read the [full guide on how to use this package with S3](/s3-usage.md).

src/Http/Controllers/ImageTransformerController.php

Lines changed: 75 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@
88
use AceOfAces\LaravelImageTransformUrl\Enums\AllowedOptions;
99
use AceOfAces\LaravelImageTransformUrl\Traits\ManagesImageCache;
1010
use AceOfAces\LaravelImageTransformUrl\Traits\ResolvesOptions;
11+
use AceOfAces\LaravelImageTransformUrl\ValueObjects\ImageSource;
12+
use Illuminate\Filesystem\FilesystemAdapter;
1113
use Illuminate\Http\Request;
1214
use Illuminate\Http\Response;
1315
use Illuminate\Support\Arr;
1416
use Illuminate\Support\Facades\App;
1517
use Illuminate\Support\Facades\Cache;
1618
use Illuminate\Support\Facades\File;
1719
use Illuminate\Support\Facades\RateLimiter;
20+
use Illuminate\Support\Facades\Storage;
1821
use Illuminate\Support\Str;
1922
use Intervention\Image\Drivers\Gd\Encoders\WebpEncoder;
2023
use Intervention\Image\Encoders\AutoEncoder;
@@ -48,7 +51,7 @@ public function transformDefault(Request $request, string $options, string $path
4851
*/
4952
protected function handleTransform(Request $request, ?string $pathPrefix, string $options, ?string $path = null): Response
5053
{
51-
$realPath = $this->handlePath($pathPrefix, $path);
54+
$source = $this->handlePath($pathPrefix, $path);
5255

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

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

78-
$image = Image::read($realPath);
81+
$image = match ($source->type) {
82+
'disk' => Image::read(Storage::disk($source->disk)->get($source->path)),
83+
default => Image::read($source->path),
84+
};
7985

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

117123
}
118124

119-
$originalMimetype = File::mimeType($realPath);
125+
$originalMimetype = $source->mime;
120126

121127
$format = $this->getStringOptionValue($options, 'format', $originalMimetype);
122128
$quality = $this->getPositiveIntOptionValue($options, 'quality', 100, 100);
@@ -150,7 +156,7 @@ protected function handleTransform(Request $request, ?string $pathPrefix, string
150156
*
151157
* @param-out string $pathPrefix
152158
*/
153-
protected function handlePath(?string &$pathPrefix, ?string &$path): string
159+
protected function handlePath(?string &$pathPrefix, ?string &$path): ImageSource
154160
{
155161
if ($path === null) {
156162
$path = $pathPrefix;
@@ -164,8 +170,38 @@ protected function handlePath(?string &$pathPrefix, ?string &$path): string
164170

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

167-
$basePath = $allowedSourceDirectories[$pathPrefix];
168-
$requestedPath = $basePath.'/'.$path;
173+
$base = $allowedSourceDirectories[$pathPrefix];
174+
175+
// Handle disk-based source directories
176+
if (is_array($base) && array_key_exists('disk', $base)) {
177+
178+
$disk = (string) $base['disk'];
179+
$prefix = isset($base['prefix']) ? trim((string) $base['prefix'], '/') : '';
180+
181+
$normalized = $this->normalizeRelativePath($path);
182+
abort_unless(! is_null($normalized), 404);
183+
184+
$diskPath = trim($prefix !== '' ? $prefix.'/'.$normalized : $normalized, '/');
185+
186+
abort_unless(Storage::disk($disk)->exists($diskPath), 404);
187+
188+
/** @var FilesystemAdapter $diskAdapter */
189+
$diskAdapter = Storage::disk($disk);
190+
$mime = $diskAdapter->mimeType($diskPath);
191+
192+
abort_unless(in_array($mime, AllowedMimeTypes::all(), true), 404);
193+
194+
return new ImageSource(
195+
type: 'disk',
196+
path: $diskPath,
197+
mime: $mime,
198+
disk: $disk,
199+
);
200+
}
201+
202+
// Handle local filesystem paths
203+
$basePath = (string) $base;
204+
$requestedPath = rtrim($basePath, '/').'/'.$path;
169205
$realPath = realpath($requestedPath);
170206

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

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

180-
return $realPath;
216+
return new ImageSource(
217+
type: 'local',
218+
path: $realPath,
219+
mime: (string) File::mimeType($realPath),
220+
);
221+
}
222+
223+
/**
224+
* Normalize a relative path by resolving `.` and `..` segments.
225+
* Returns null if the path escapes above the root.
226+
*/
227+
protected function normalizeRelativePath(string $path): ?string
228+
{
229+
$path = str_replace('\\', '/', $path);
230+
$segments = array_filter(explode('/', $path), fn ($s) => $s !== '');
231+
$stack = [];
232+
233+
foreach ($segments as $segment) {
234+
if ($segment === '.') {
235+
continue;
236+
}
237+
if ($segment === '..') {
238+
if (empty($stack)) {
239+
return null;
240+
}
241+
array_pop($stack);
242+
243+
continue;
244+
}
245+
$stack[] = $segment;
246+
}
247+
248+
return implode('/', $stack);
181249
}
182250

183251
/**

src/ValueObjects/ImageSource.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace AceOfAces\LaravelImageTransformUrl\ValueObjects;
6+
7+
/**
8+
* @internal
9+
*/
10+
readonly class ImageSource
11+
{
12+
public function __construct(
13+
public readonly string $type,
14+
public readonly string $path,
15+
public readonly string $mime,
16+
public readonly ?string $disk = null,
17+
) {
18+
if (! in_array($this->type, ['local', 'disk'], true)) {
19+
throw new \InvalidArgumentException('Invalid image source type provided.');
20+
}
21+
}
22+
}

testbench.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ workbench:
2020
components: false
2121
factories: true
2222
views: false
23+
config: true
2324
build:
2425
- asset-publish
2526
- create-sqlite-db

tests/Feature/S3SourceTest.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use AceOfAces\LaravelImageTransformUrl\Tests\TestCase;
6+
use Illuminate\Support\Facades\Cache;
7+
use Illuminate\Support\Facades\Storage;
8+
9+
beforeEach(function () {
10+
Cache::flush();
11+
Storage::fake(config()->string('image-transform-url.cache.disk'));
12+
Storage::fake('s3');
13+
14+
// Configure S3 as a source directory using the disk driver
15+
config()->set('image-transform-url.source_directories.s3', [
16+
'disk' => 's3',
17+
'prefix' => '',
18+
]);
19+
});
20+
21+
it('can serve from an s3 disk source directory', function () {
22+
/** @var TestCase $this */
23+
$imagePath = 'images/test.jpg';
24+
Storage::disk('s3')->put($imagePath, file_get_contents(__DIR__.'/../../workbench/test-data/cat.jpg'));
25+
26+
$response = $this->get(route('image.transform', [
27+
'options' => 'width=100',
28+
'pathPrefix' => 's3',
29+
'path' => $imagePath,
30+
]));
31+
32+
expect($response)->toBeImage([
33+
'width' => 100,
34+
'mime' => 'image/jpeg',
35+
]);
36+
});
37+
38+
it('can use s3 as the default source directory', function () {
39+
/** @var TestCase $this */
40+
config()->set('image-transform-url.default_source_directory', 's3');
41+
42+
$imagePath = 'images/test.jpg';
43+
Storage::disk('s3')->put($imagePath, file_get_contents(__DIR__.'/../../workbench/test-data/cat.jpg'));
44+
45+
$response = $this->get(route('image.transform.default', [
46+
'options' => 'width=100',
47+
'path' => $imagePath,
48+
]));
49+
50+
expect($response)->toBeImage([
51+
'width' => 100,
52+
'mime' => 'image/jpeg',
53+
]);
54+
});

0 commit comments

Comments
 (0)