From 21842df25004aa4833fa0032ba22e5383ea723eb Mon Sep 17 00:00:00 2001 From: Julian Schramm Date: Fri, 8 Aug 2025 16:30:41 +0200 Subject: [PATCH 1/3] feat: add S3 source directory support --- config/image-transform-url.php | 3 + .../ImageTransformerController.php | 82 +++++++++++++++++-- src/ValueObjects/ImageSource.php | 22 +++++ tests/Feature/S3SourceTest.php | 54 ++++++++++++ 4 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 src/ValueObjects/ImageSource.php create mode 100644 tests/Feature/S3SourceTest.php diff --git a/config/image-transform-url.php b/config/image-transform-url.php index 7229030..8b51887 100644 --- a/config/image-transform-url.php +++ b/config/image-transform-url.php @@ -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' => [ diff --git a/src/Http/Controllers/ImageTransformerController.php b/src/Http/Controllers/ImageTransformerController.php index 37fa91f..f6d700f 100644 --- a/src/Http/Controllers/ImageTransformerController.php +++ b/src/Http/Controllers/ImageTransformerController.php @@ -8,6 +8,8 @@ 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; @@ -15,6 +17,7 @@ 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; @@ -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); @@ -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( @@ -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); @@ -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; @@ -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); @@ -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); } /** diff --git a/src/ValueObjects/ImageSource.php b/src/ValueObjects/ImageSource.php new file mode 100644 index 0000000..bd9b25d --- /dev/null +++ b/src/ValueObjects/ImageSource.php @@ -0,0 +1,22 @@ +type, ['local', 'disk'], true)) { + throw new \InvalidArgumentException('Invalid image source type provided.'); + } + } +} diff --git a/tests/Feature/S3SourceTest.php b/tests/Feature/S3SourceTest.php new file mode 100644 index 0000000..049e8e5 --- /dev/null +++ b/tests/Feature/S3SourceTest.php @@ -0,0 +1,54 @@ +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', + ]); +}); From 2d80e7a177483281d177e797af3b2b845511e82e Mon Sep 17 00:00:00 2001 From: Julian Schramm Date: Fri, 8 Aug 2025 16:35:36 +0200 Subject: [PATCH 2/3] chore: update workbench testing setup for S3 --- composer.json | 3 +- testbench.yaml | 1 + workbench/config/filesystems.php | 92 ++++++++++++++ workbench/config/image-transform-url.php | 147 +++++++++++++++++++++++ 4 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 workbench/config/filesystems.php create mode 100644 workbench/config/image-transform-url.php diff --git a/composer.json b/composer.json index 47ec379..b9ac7eb 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/testbench.yaml b/testbench.yaml index 81a4e4f..6fcbdfa 100644 --- a/testbench.yaml +++ b/testbench.yaml @@ -20,6 +20,7 @@ workbench: components: false factories: true views: false + config: true build: - asset-publish - create-sqlite-db diff --git a/workbench/config/filesystems.php b/workbench/config/filesystems.php new file mode 100644 index 0000000..6f925a7 --- /dev/null +++ b/workbench/config/filesystems.php @@ -0,0 +1,92 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + 'r2' => [ + 'driver' => 's3', + 'key' => env('CLOUDFLARE_R2_ACCESS_KEY'), + 'secret' => env('CLOUDFLARE_R2_SECRET_KEY'), + 'region' => 'us-east-1', + 'bucket' => env('CLOUDFLARE_R2_BUCKET'), + 'endpoint' => env('CLOUDFLARE_R2_ENDPOINT'), + 'url' => env('CLOUDFLARE_R2_URL'), + 'use_path_style_endpoint' => env('CLOUDFLARE_R2_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/workbench/config/image-transform-url.php b/workbench/config/image-transform-url.php new file mode 100644 index 0000000..60f08db --- /dev/null +++ b/workbench/config/image-transform-url.php @@ -0,0 +1,147 @@ + [ + 'images-test' => public_path('images'), + 'storage' => storage_path('app/public/images'), + 'remote' => ['disk' => 'r2'], + ], + + /* + |-------------------------------------------------------------------------- + | Default Source Directory + |-------------------------------------------------------------------------- + | + | Below you may configure the default source directory which is used when + | no specific path prefix is provided in the URL. This should be one of + | the keys from the source_directories array. + | + */ + + 'default_source_directory' => env('IMAGE_TRANSFORM_DEFAULT_SOURCE_DIRECTORY', 'images-test'), + + /* + |-------------------------------------------------------------------------- + | Route Prefix + |-------------------------------------------------------------------------- + | + | Here you may configure the route prefix of the image transformer. + | + */ + + 'route_prefix' => env('IMAGE_TRANSFORM_ROUTE_PREFIX', 'image-transform'), + + /* + |-------------------------------------------------------------------------- + | Enabled Options + |-------------------------------------------------------------------------- + | + | Here you may configure the options which are enabled for the image + | transformer. + | + */ + + 'enabled_options' => env('IMAGE_TRANSFORM_ENABLED_OPTIONS', [ + 'width', + 'height', + 'format', + 'quality', + 'flip', + 'contrast', + 'version', + 'background', + // 'blur' + ]), + + /* + |-------------------------------------------------------------------------- + | Image Cache + |-------------------------------------------------------------------------- + | + | Here you may configure the image cache settings. The cache is used to + | store the transformed images for a certain amount of time. This is + | useful to prevent reprocessing the same image multiple times. + | The cache is stored in the configured cache disk. + | + */ + + 'cache' => [ + 'enabled' => env('IMAGE_TRANSFORM_CACHE_ENABLED', true), + 'lifetime' => env('IMAGE_TRANSFORM_CACHE_LIFETIME', 60 * 24 * 7), // 7 days + 'disk' => env('IMAGE_TRANSFORM_CACHE_DISK', 'r2'), + 'max_size_mb' => env('IMAGE_TRANSFORM_CACHE_MAX_SIZE_MB', 100), // 100 MB + 'clear_to_percent' => env('IMAGE_TRANSFORM_CACHE_CLEAR_TO_PERCENT', 80), // 80% of max size + ], + + /* + |-------------------------------------------------------------------------- + | Rate Limit + |-------------------------------------------------------------------------- + | + | Below you may configure the rate limit which is applied for each image + | 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' => 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 + |-------------------------------------------------------------------------- + | + | Below you may configure the response headers which are added to the + | response. This is especially useful for controlling caching behavior + | of CDNs. + | + */ + + 'headers' => [ + 'Cache-Control' => env('IMAGE_TRANSFORM_HEADER_CACHE_CONTROL', 'immutable, public, max-age=2592000, s-maxage=2592000'), + ], +]; From a14e7c75c6d388a12bbd9d48fda2310be4ac6d3e Mon Sep 17 00:00:00 2001 From: Julian Schramm Date: Fri, 8 Aug 2025 16:36:03 +0200 Subject: [PATCH 3/3] feat: add S3 usage guide and update setup documentation for remote sources --- docs/.vitepress/config.mts | 1 + docs/pages/s3-usage.md | 24 ++++++++++++++++++++++++ docs/pages/setup.md | 18 ++++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 docs/pages/s3-usage.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 2e35011..5708d56 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -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' }, ] diff --git a/docs/pages/s3-usage.md b/docs/pages/s3-usage.md new file mode 100644 index 0000000..5344f6d --- /dev/null +++ b/docs/pages/s3-usage.md @@ -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. diff --git a/docs/pages/setup.md b/docs/pages/setup.md index 8b5bcc0..a03a800 100644 --- a/docs/pages/setup.md +++ b/docs/pages/setup.md @@ -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'), @@ -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).