Skip to content

Commit 21842df

Browse files
committed
feat: add S3 source directory support
1 parent fee10fb commit 21842df

File tree

4 files changed

+154
-7
lines changed

4 files changed

+154
-7
lines changed

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' => [

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+
}

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)