├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── image-transform-url.php ├── routes └── image.php └── src ├── Enums ├── AllowedMimeTypes.php └── AllowedOptions.php ├── Http └── Controllers │ └── ImageTransformerController.php ├── LaravelImageTransformUrl.php ├── LaravelImageTransformUrlServiceProvider.php └── Traits └── ResolvesOptions.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `laravel-image-transform-url` will be documented in this file. 4 | 5 | ## v0.4.0 - 2025-05-30 6 | 7 | This version adds a new `background` option which can be used to set a HEX color to transparent areas of png images. 8 | 9 | ## v0.3.0 - 2025-04-21 10 | 11 | This version fixes adds checks against a possible path traversal attack vector in the ImageTransformerController. 12 | Any real possibilities of this could not be detected, but this adds an additional safeguard. 13 | 14 | ## v0.2.0 - 2025-04-20 15 | 16 | This version fixes an issue with the default configuration file using the `app()` helper, causing a binding resolution issue when published. 17 | 18 | New configuration option: 19 | 20 | - added a new `rate_limit.disabled_for_environments` configuration option in replacement to the `app()->isProduction()` helper on `rate_limit.enabled` 21 | 22 | ## v0.1.0 - 2025-04-19 23 | 24 | Initial Release 🎉 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # CONTRIBUTING 2 | 3 | Contributions are welcome, and are accepted via pull requests. 4 | Please review these guidelines before submitting any pull requests. 5 | 6 | ## Process 7 | 8 | 1. Fork the project 9 | 1. Create a new branch 10 | 1. Code, test, commit and push 11 | 1. Open a pull request detailing your changes. 12 | 13 | ## Guidelines 14 | 15 | * Please ensure the coding style running `composer lint`. 16 | * Send a coherent commit history, making sure each individual commit in your pull request is meaningful. 17 | * You may need to [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) to avoid merge conflicts. 18 | * Please remember that we follow [SemVer](http://semver.org/). 19 | 20 | ## Setup 21 | 22 | Clone your fork, then install the dev dependencies: 23 | ```bash 24 | composer install 25 | ``` 26 | 27 | 28 | Build workbench: 29 | ```bash 30 | composer build 31 | ``` 32 | 33 | ## Playground 34 | 35 | You can visit a test application via [Workbench](https://github.com/orchestral/workbench): 36 | ```bash 37 | composer serve 38 | ``` 39 | 40 | ## Lint 41 | 42 | Lint your code: 43 | ```bash 44 | composer lint 45 | ``` 46 | ## Tests 47 | 48 | Run all tests: 49 | ```bash 50 | composer test 51 | ``` 52 | 53 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Julian Schramm 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Image Transform URL 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/ace-of-aces/laravel-image-transform-url.svg?style=flat-square)](https://packagist.org/packages/ace-of-aces/laravel-image-transform-url) 4 | [![GitHub Tests Action Status](https://img.shields.io/github/actions/workflow/status/ace-of-aces/laravel-image-transform-url/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/ace-of-aces/laravel-image-transform-url/actions?query=workflow%3Arun-tests+branch%3Amain) 5 | [![GitHub Code Style Action Status](https://img.shields.io/github/actions/workflow/status/ace-of-aces/laravel-image-transform-url/fix-php-code-style-issues.yml?branch=main&label=code%20style&style=flat-square)](https://github.com/ace-of-aces/laravel-image-transform-url/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/ace-of-aces/laravel-image-transform-url.svg?style=flat-square)](https://packagist.org/packages/ace-of-aces/laravel-image-transform-url) 7 | 8 | Easy, URL-based image transformations inspired by [Cloudflare Images](https://developers.cloudflare.com/images/transform-images/transform-via-url/). 9 | 10 | **Features:** 11 | 12 | - ✈️ Use URL parameters to transform images on the fly 13 | - 🔧 Support for various transformations like resizing, compression, and format conversion 14 | - ⚡ Automatic caching of transformed images for faster loading times 15 | - 🌍 Easy integration with CDNs for even faster global delivery 16 | 17 | ## Requirements 18 | 19 | - PHP \>= 8.4 20 | - Laravel 12.x 21 | 22 | If you want to use the file caching feature (highly recommended), a configured `Storage` disk and a `Cache` driver is required. More info in the [Image Caching](#image-caching) section. 23 | 24 | > [!IMPORTANT] 25 | > It is highly recommended to set a minimum memory limit of 256MB in your `php.ini` file to avoid memory issues when images are being processed. 26 | 27 | ## Installation 28 | 29 | Install the package via composer: 30 | 31 | ```bash 32 | composer require ace-of-aces/laravel-image-transform-url 33 | ``` 34 | 35 | Publish the config file with: 36 | 37 | ```bash 38 | php artisan vendor:publish --tag="image-transform-url-config" 39 | ``` 40 | 41 | ## Usage 42 | 43 | 1. Configure the package via `image-transform-url.php` to set your [`public_path`](https://laravel.com/docs/12.x/helpers#method-public-path) directory, from where you want to transform the images. 44 | It is recommended to use a dedicated directory for your images in order to have a separation of concerns. 45 | 46 | 2. Test your first image transformation: 47 | 48 | Use the following URL format to transform your images: 49 | 50 | ``` 51 | http://///> 52 | ``` 53 | 54 | for example: 55 | 56 | ``` 57 | http://localhost:8000/image-transform/width=250,quality=80,format=webp/foo/bar/example.jpg 58 | ``` 59 | 60 | ## Options 61 | 62 | > [!NOTE] 63 | > The options are separated by commas and their values are appended with equal signs. The order of options does not matter. 64 | 65 | | Option | Description | Type | Description / Possible Values | 66 | | ------------ | ------------------------------------- | ------- | --------------------------------------------------------------------------------------- | 67 | | `width` | Set the width of the image. | integer | Values greater than the original width will be ignored. | 68 | | `height` | Set the height of the image. | integer | Values greater than the original height will be ignored. | 69 | | `quality` | Set the quality of the image. | integer | `0` to `100` | 70 | | `format` | Set the format of the image. | string | Supported formats: `jpg`, `jpeg`, `png`, `gif`, `webp`. | 71 | | `blur` | Set the blur level of the image. | integer | `0` to `100` | 72 | | `contrast` | Set the contrast level of the image. | integer | `-100` to `100` | 73 | | `background` | Set the background color of the image | string | Any valid HEX color value (without a leading `#`). Only supported for the `png` format. | 74 | | `flip` | Flip the image. | string | `h`(horizontal), `v`(vertical), `hv`(horizontal and vertical) | 75 | | `version` | Version number of the image. | integer | Any positive integer. More info in the [Image Caching](#image-caching) section. | 76 | 77 | > [!CAUTION] 78 | > The `blur` option is a resource-intensive operation and may cause memory issues if the image is too large. It is recommended to use this option with caution, or disable it in the config. 79 | 80 | ## Image Caching 81 | 82 | This package comes with the default option to automatically store and serve images statically for the requested options within the caching lifetime. 83 | 84 | > [!NOTE] 85 | > Having this feature enabled (default behavior) will help to reduce the load on your server and speed up image delivery. 86 | 87 | The processed images are stored in the `storage/app/private/_cache/image-transform-url` directory by default. You can change the disk configuration in the `image-transform-url.php` configuration file. 88 | 89 | > [!CAUTION] 90 | > When using this option, there is one caveat to be aware of: 91 | 92 | Source images are considered to be stale content by their file name and path. 93 | 94 | If the content of an original source image changes, but the file name stays the same, the cached images will not be updated automatically until the cache expires. 95 | To force a revalidation, you can either: 96 | 97 | 1. change the image's file name 98 | 2. move it into another subdirectory, which will change its path 99 | 3. change the version number (integer) in the options (e.g. `version=2`) 100 | 4. or flush the entire cache of your application using the `php artisan cache:clear` command. 101 | 102 | ## Rate Limiting 103 | 104 | Another feature of this package is the ability to limit the number of transformations that the image transformation route should process per path and IP address within a given time frame. 105 | 106 | The rate limit will come into effect for new transformation requests only, and will not affect previously cached images. 107 | 108 | By default, rate limiting is disabled for the `local` and `testing` app environements to not distract you when developing your app. You can configure the rate limit settings in the `image-transform-url.php` configuration file. 109 | 110 | ## Usage with CDNs 111 | 112 | The package is designed to work seamlessly with CDNs like Cloudflare, BunnyCDN, and others. 113 | 114 | The most important configuration is the [`Cache-Control`](https://developer.mozilla.org/de/docs/Web/HTTP/Reference/Headers/Cache-Control) header, which you can customize to your liking in the `image-transform-url.php` configuration file. 115 | 116 | ## Error Handling 117 | 118 | 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. 119 | 120 | This is why the route handler will return a `404` response if: 121 | 122 | - a requested image does not existn at the specified path 123 | - the requested image is not a valid image file 124 | - the provided options are not in the correct format (`key=value`, no trailing comma, etc.) 125 | 126 | The only other HTTP error that can be returned is a `429` response, which indicates that the request was rate-limited. 127 | 128 | If parts of the given route options are invalid, the route handler will ignore them and only apply the valid options. 129 | 130 | Example: 131 | 132 | ``` 133 | http://localhost:8000/image-transform/width=250,quality=foo,format=webp/example.jpg 134 | ``` 135 | 136 | will be processed as: 137 | 138 | ``` 139 | http://localhost:8000/image-transform/width=250,format=webp/example.jpg 140 | ``` 141 | 142 | ## Changelog 143 | 144 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 145 | 146 | ## Contributing 147 | 148 | Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 149 | 150 | ## Credits 151 | 152 | - [Aaron Francis](https://github.com/aarondfrancis) for the [original idea and foundational work](https://aaronfrancis.com/2025/a-cookieless-cache-friendly-image-proxy-in-laravel-inspired-by-cloudflare-9e95f7e0) 153 | - [Cloudflare Images](https://developers.cloudflare.com/images/transform-images/transform-via-url/) for the URL-Syntax structure 154 | - [Intervention Image](https://image.intervention.io/v3) for the underlying image processing 155 | 156 | ## License 157 | 158 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 159 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ace-of-aces/laravel-image-transform-url", 3 | "description": "Easy, URL-based image transformations inspired by Cloudflare Images.", 4 | "keywords": [ 5 | "laravel", 6 | "laravel-image-transform-url" 7 | ], 8 | "homepage": "https://github.com/ace-of-aces/laravel-image-transform-url", 9 | "license": "MIT", 10 | "authors": [ 11 | { 12 | "name": "Julian Schramm", 13 | "email": "hi@julian.center" 14 | } 15 | ], 16 | "require": { 17 | "php": "^8.4", 18 | "illuminate/contracts": "^12.0", 19 | "intervention/image-laravel": "^1.5", 20 | "spatie/laravel-package-tools": "^1.16" 21 | }, 22 | "require-dev": { 23 | "laravel/pint": "^1.14", 24 | "nunomaduro/collision": "^8.1.1||^7.10.0", 25 | "larastan/larastan": "^2.9||^3.0", 26 | "orchestra/testbench": "^10.0.0||^9.0.0||^8.22.0", 27 | "pestphp/pest": "^3.0", 28 | "pestphp/pest-plugin-arch": "^3.0", 29 | "pestphp/pest-plugin-laravel": "^3.0", 30 | "phpstan/extension-installer": "^1.3||^2.0", 31 | "phpstan/phpstan-deprecation-rules": "^1.1||^2.0", 32 | "phpstan/phpstan-phpunit": "^1.3||^2.0" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "AceOfAces\\LaravelImageTransformUrl\\": "src/", 37 | "AceOfAces\\LaravelImageTransformUrl\\Database\\Factories\\": "database/factories/" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "AceOfAces\\LaravelImageTransformUrl\\Tests\\": "tests/", 43 | "Workbench\\App\\": "workbench/app/", 44 | "Workbench\\Database\\Factories\\": "workbench/database/factories/", 45 | "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" 46 | } 47 | }, 48 | "scripts": { 49 | "post-autoload-dump": [ 50 | "@clear", 51 | "@prepare", 52 | "@composer run prepare" 53 | ], 54 | "prepare": "@php vendor/bin/testbench package:discover --ansi", 55 | "analyse": "vendor/bin/phpstan analyse", 56 | "test": "vendor/bin/pest", 57 | "test-coverage": "vendor/bin/pest --coverage", 58 | "format": "vendor/bin/pint", 59 | "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", 60 | "build": "@php vendor/bin/testbench workbench:build --ansi", 61 | "serve": [ 62 | "Composer\\Config::disableProcessTimeout", 63 | "@build", 64 | "@php vendor/bin/testbench serve --ansi" 65 | ], 66 | "lint": [ 67 | "@php vendor/bin/pint --ansi", 68 | "@php vendor/bin/phpstan analyse --verbose --ansi" 69 | ] 70 | }, 71 | "config": { 72 | "sort-packages": true, 73 | "allow-plugins": { 74 | "pestphp/pest-plugin": true, 75 | "phpstan/extension-installer": true 76 | } 77 | }, 78 | "extra": { 79 | "laravel": { 80 | "providers": [ 81 | "AceOfAces\\LaravelImageTransformUrl\\LaravelImageTransformUrlServiceProvider" 82 | ] 83 | } 84 | }, 85 | "minimum-stability": "dev", 86 | "prefer-stable": true 87 | } 88 | -------------------------------------------------------------------------------- /config/image-transform-url.php: -------------------------------------------------------------------------------- 1 | env('IMAGE_TRANSFORM_PUBLIC_PATH', 'images'), 16 | 17 | /* 18 | |-------------------------------------------------------------------------- 19 | | Route Prefix 20 | |-------------------------------------------------------------------------- 21 | | 22 | | Here you may configure the route prefix of the image transformer. 23 | | 24 | */ 25 | 26 | 'route_prefix' => env('IMAGE_TRANSFORM_ROUTE_PREFIX', 'image-transform'), 27 | 28 | /* 29 | |-------------------------------------------------------------------------- 30 | | Enabled Options 31 | |-------------------------------------------------------------------------- 32 | | 33 | | Here you may configure the options which are enabled for the image 34 | | transformer. 35 | | 36 | */ 37 | 38 | 'enabled_options' => env('IMAGE_TRANSFORM_ENABLED_OPTIONS', [ 39 | 'width', 40 | 'height', 41 | 'format', 42 | 'quality', 43 | 'flip', 44 | 'contrast', 45 | 'version', 46 | 'background', 47 | // 'blur' 48 | ]), 49 | 50 | /* 51 | |-------------------------------------------------------------------------- 52 | | Image Cache 53 | |-------------------------------------------------------------------------- 54 | | 55 | | Here you may configure the image cache settings. The cache is used to 56 | | store the transformed images for a certain amount of time. This is 57 | | useful to prevent reprocessing the same image multiple times. 58 | | The cache is stored in the configured cache disk. 59 | | 60 | */ 61 | 62 | 'cache' => [ 63 | 'enabled' => env('IMAGE_TRANSFORM_CACHE_ENABLED', true), 64 | 'lifetime' => env('IMAGE_TRANSFORM_CACHE_LIFETIME', 60 * 24 * 7), // 7 days 65 | 'disk' => env('IMAGE_TRANSFORM_CACHE_DISK', 'local'), 66 | ], 67 | 68 | /* 69 | |-------------------------------------------------------------------------- 70 | | Rate Limit 71 | |-------------------------------------------------------------------------- 72 | | 73 | | Below you may configure the rate limit which is applied for each image 74 | | new transformation by the path and IP address. It is recommended to 75 | | set this to a low value, e.g. 2 requests per minute, to prevent 76 | | abuse. 77 | */ 78 | 79 | 'rate_limit' => [ 80 | 'enabled' => env('IMAGE_TRANSFORM_RATE_LIMIT_ENABLED', true), 81 | 'disabled_for_environments' => [ 82 | 'local', 83 | 'testing', 84 | ], 85 | 'max_attempts' => env('IMAGE_TRANSFORM_RATE_LIMIT_MAX_REQUESTS', 2), 86 | 'decay_seconds' => env('IMAGE_TRANSFORM_RATE_LIMIT_DECAY_SECONDS', 60), 87 | ], 88 | 89 | /* 90 | |-------------------------------------------------------------------------- 91 | | Response Headers 92 | |-------------------------------------------------------------------------- 93 | | 94 | | Below you may configure the response headers which are added to the 95 | | response. This is especially useful for controlling caching behavior 96 | | of CDNs. 97 | | 98 | */ 99 | 100 | 'headers' => [ 101 | 'Cache-Control' => env('IMAGE_TRANSFORM_HEADER_CACHE_CONTROL', 'immutable, public, max-age=2592000, s-maxage=2592000'), 102 | ], 103 | ]; 104 | -------------------------------------------------------------------------------- /routes/image.php: -------------------------------------------------------------------------------- 1 | string('image-transform-url.route_prefix'))->group(function () { 9 | Route::get('{options}/{path}', ImageTransformerController::class) 10 | ->where('options', '([a-zA-Z]+=-?[a-zA-Z0-9]+,?)+') 11 | ->where('path', '.*\..*') 12 | ->name('image.transform'); 13 | }); 14 | -------------------------------------------------------------------------------- /src/Enums/AllowedMimeTypes.php: -------------------------------------------------------------------------------- 1 | $mimeType->value, self::cases()); 17 | } 18 | 19 | public static function withExtension(): array 20 | { 21 | return array_map( 22 | fn (self $mimeType) => [ 23 | 'mime' => $mimeType->value, 24 | 'extension' => match ($mimeType) { 25 | self::Jpeg => 'jpg', 26 | self::Png => 'png', 27 | self::Webp => 'webp', 28 | self::Gif => 'gif', 29 | }, 30 | ], 31 | self::cases() 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Enums/AllowedOptions.php: -------------------------------------------------------------------------------- 1 | $option->value, self::cases()); 22 | } 23 | 24 | public static function withTypes(): array 25 | { 26 | return [ 27 | self::Width->value => 'integer', 28 | self::Height->value => 'integer', 29 | self::Format->value => 'string', 30 | self::Quality->value => 'integer', 31 | self::Blur->value => 'integer', 32 | self::Contrast->value => 'integer', 33 | self::Flip->value => 'string', 34 | self::Version->value => 'integer', 35 | self::Background->value => 'string', 36 | ]; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Http/Controllers/ImageTransformerController.php: -------------------------------------------------------------------------------- 1 | string('image-transform-url.public_path'); 33 | 34 | $publicPath = realpath(public_path($pathPrefix.'/'.$path)); 35 | 36 | abort_unless($publicPath, 404); 37 | 38 | abort_unless(Str::startsWith($publicPath, public_path($pathPrefix)), 404); 39 | 40 | abort_unless(in_array(File::mimeType($publicPath), AllowedMimeTypes::all(), true), 404); 41 | 42 | $options = $this->parseOptions($options); 43 | 44 | // Check cache 45 | if (config()->boolean('image-transform-url.cache.enabled')) { 46 | $cachePath = $this->getCachePath($path, $options); 47 | 48 | if (File::exists($cachePath)) { 49 | if (Cache::has('image-transform-url:'.$cachePath)) { 50 | // serve file from storage 51 | return $this->imageResponse( 52 | imageContent: File::get($cachePath), 53 | mimeType: File::mimeType($cachePath), 54 | cacheHit: true 55 | ); 56 | } else { 57 | // Cache expired, delete the cache file and continue 58 | File::delete($cachePath); 59 | } 60 | } 61 | } 62 | 63 | if ( 64 | config()->boolean('image-transform-url.rate_limit.enabled') && 65 | ! in_array(App::environment(), config()->array('image-transform-url.rate_limit.disabled_for_environments'))) { 66 | $this->rateLimit($request, $path); 67 | } 68 | 69 | $image = Image::read($publicPath); 70 | 71 | if (Arr::hasAny($options, ['width', 'height'])) { 72 | $image->scale( 73 | $this->getPositiveIntOptionValue($options, 'width', $image->width() * 2), 74 | $this->getPositiveIntOptionValue($options, 'height', $image->height() * 2), 75 | ); 76 | } 77 | 78 | if (Arr::has($options, 'blur')) { 79 | $image->blur($this->getPositiveIntOptionValue($options, 'blur', 100)); 80 | } 81 | 82 | if (Arr::has($options, 'contrast')) { 83 | $image->contrast($this->getUnsignedIntOptionValue($options, 'contrast', 0, -100, 100)); 84 | } 85 | 86 | if (Arr::has($options, 'flip')) { 87 | $flip = $this->getSelectOptionValue($options, 'flip', ['h', 'v', 'hv'], 'h'); 88 | 89 | match ($flip) { 90 | 'h' => $image->flip(), 91 | 'v' => $image->flop(), 92 | 'hv' => $image->flip()->flop(), 93 | default => null, 94 | }; 95 | } 96 | 97 | if (Arr::has($options, 'background')) { 98 | $backgroundColor = $this->getStringOptionValue($options, 'background', 'ffffff'); 99 | 100 | if (! preg_match('/^([a-f0-9]{6}|[a-f0-9]{3})$/', $backgroundColor)) { 101 | $backgroundColor = null; 102 | } 103 | 104 | if ($backgroundColor) { 105 | $image->blendTransparency($backgroundColor); 106 | } 107 | 108 | } 109 | 110 | // We use the mime type instead of the extension to determine the format, because this is more reliable. 111 | $originalMimetype = File::mimeType($publicPath); 112 | 113 | $format = $this->getStringOptionValue($options, 'format', $originalMimetype); 114 | $quality = $this->getPositiveIntOptionValue($options, 'quality', 100, 100); 115 | 116 | $encoder = match ($format) { 117 | 'png', 'image/png' => new PngEncoder, 118 | 'webp', 'image/webp' => new WebpEncoder($quality), 119 | 'jpeg', 'jpg', 'image/jpeg' => new JpegEncoder($quality), 120 | 'gif', 'image/gif' => new GifEncoder, 121 | default => new AutoEncoder(quality: $quality), 122 | }; 123 | 124 | $encoded = $image->encode($encoder); 125 | 126 | if (config()->boolean('image-transform-url.cache.enabled')) { 127 | defer(function () use ($path, $options, $encoded) { 128 | 129 | $cachePath = $this->getCachePath($path, $options); 130 | 131 | $cacheDir = dirname($cachePath); 132 | 133 | File::ensureDirectoryExists($cacheDir); 134 | File::put($cachePath, $encoded->toString()); 135 | 136 | Cache::put( 137 | key: 'image-transform-url:'.$cachePath, 138 | value: true, 139 | ttl: config()->integer('image-transform-url.cache.lifetime'), 140 | ); 141 | }); 142 | } 143 | 144 | return $this->imageResponse( 145 | imageContent: $encoded->toString(), 146 | mimeType: $encoded->mimetype(), 147 | cacheHit: false 148 | ); 149 | 150 | } 151 | 152 | /** 153 | * Rate limit the request. 154 | */ 155 | protected function rateLimit(Request $request, string $path): void 156 | { 157 | $key = 'image-transform-url:'.$request->ip().':'.$path; 158 | 159 | $passed = RateLimiter::attempt( 160 | key: $key, 161 | maxAttempts: config()->integer('image-transform-url.rate_limit.max_attempts'), 162 | callback: fn () => true, 163 | decaySeconds: config()->integer('image-transform-url.rate_limit.decay_seconds'), 164 | ); 165 | 166 | abort_unless($passed, 429, 'Too many requests. Please try again later.'); 167 | } 168 | 169 | /** 170 | * Parse the given options. 171 | * 172 | * @return array 173 | */ 174 | protected static function parseOptions(string $options): array 175 | { 176 | /** 177 | * The allowed options and their PHP types. 178 | * 179 | * @var array 180 | */ 181 | $allowedOptions = AllowedOptions::withTypes(); 182 | 183 | $options = explode(',', $options); 184 | 185 | return collect($options) 186 | ->mapWithKeys(function ($option) { 187 | [$key] = explode('=', $option); 188 | 189 | $value = explode('=', $option)[1] ?? null; 190 | 191 | $value = is_numeric($value) ? (int) $value : $value; 192 | 193 | return [$key => $value]; 194 | }) 195 | ->filter(function ($value, $key) use ($allowedOptions) { 196 | return array_key_exists($key, $allowedOptions) && gettype($value) === $allowedOptions[$key]; 197 | })->filter(function ($value, $key) { 198 | return in_array($key, config()->array('image-transform-url.enabled_options'), true); 199 | })->toArray(); 200 | } 201 | 202 | /** 203 | * Get the cache path for the given path and options. 204 | */ 205 | protected static function getCachePath(string $path, array $options): string 206 | { 207 | $pathPrefix = config()->string('image-transform-url.public_path'); 208 | 209 | $optionsHash = md5(json_encode($options)); 210 | 211 | return Storage::disk(config()->string('image-transform-url.cache.disk'))->path('_cache/image-transform-url/'.$pathPrefix.'/'.$optionsHash.'_'.$path); 212 | } 213 | 214 | /** 215 | * Respond with the image content. 216 | */ 217 | protected static function imageResponse(string $imageContent, string $mimeType, bool $cacheHit = false): Response 218 | { 219 | return response($imageContent, 200, [ 220 | 'Content-Type' => $mimeType, 221 | ...(config()->boolean('image-transform-url.cache.enabled') ? [ 222 | 'X-Cache' => $cacheHit ? 'HIT' : 'MISS', 223 | ] : []), 224 | ...(config()->array('image-transform-url.headers')), 225 | ]); 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/LaravelImageTransformUrl.php: -------------------------------------------------------------------------------- 1 | name('laravel-image-transform-url') 19 | ->hasConfigFile() 20 | ->hasRoute('image'); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Traits/ResolvesOptions.php: -------------------------------------------------------------------------------- 1 | 0 ? $value : null; 22 | } 23 | 24 | /** 25 | * Get the unsigned int value of the given option. 26 | */ 27 | protected static function getUnsignedIntOptionValue(array $options, string $option, ?int $fallback = null, ?int $min = null, ?int $max = null): ?int 28 | { 29 | return min( 30 | max( 31 | Arr::get($options, $option, $fallback), 32 | $min ?? PHP_INT_MIN, 33 | ), 34 | $max ?? PHP_INT_MAX, 35 | ); 36 | } 37 | 38 | /** 39 | * Get the string value of the given option. 40 | */ 41 | protected static function getStringOptionValue(array $options, string $option, ?string $default = null): ?string 42 | { 43 | return Arr::get($options, $option, $default); 44 | 45 | } 46 | 47 | /** 48 | * Get the select option value of the given option. 49 | * 50 | * @param array $allowedValues 51 | */ 52 | protected static function getSelectOptionValue(array $options, string $option, array $allowedValues, ?string $default = null): ?string 53 | { 54 | $value = Arr::get($options, $option, $default); 55 | 56 | return in_array($value, $allowedValues, true) ? $value : null; 57 | } 58 | } 59 | --------------------------------------------------------------------------------