├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── unit-tests.yml ├── .gitignore ├── LICENSE ├── build └── .gitkeep ├── composer.json ├── config └── config.php ├── database └── migrations │ ├── 2016_01_01_000000_create_guided_images_table.php │ └── 2016_01_01_000001_create_guided_imageables_table.php ├── docs ├── examples │ ├── Image.php │ └── ImageController.php └── images │ └── logo.png ├── grumphp.yml ├── phpunit.xml ├── pint.json ├── readme.md ├── routes └── web.php ├── src ├── Concern │ ├── Guide.php │ ├── Guided.php │ └── GuidedRepository.php ├── Console │ └── Command │ │ └── ClearSkimDirectories.php ├── Contract │ ├── ConfigProvider.php │ ├── FileHelper.php │ ├── GuidedImage.php │ ├── ImageDemand.php │ ├── ImageDispenser.php │ ├── ImageGuide.php │ ├── ImageManager.php │ └── ImageUploader.php ├── Demand │ ├── Dummy.php │ ├── ExistingImage.php │ ├── Image.php │ ├── Resize.php │ └── Thumbnail.php ├── Exception │ ├── BadImplementation.php │ └── UrlUploadFailed.php ├── Helper │ └── FileHelper.php ├── Model │ └── UploadedImage.php ├── Result.php ├── Service │ ├── ConfigProvider.php │ ├── ImageDispenser.php │ ├── ImageManager.php │ └── ImageUploader.php └── ServiceProvider.php └── tests ├── Feature └── .gitkeep ├── Fixtures └── Model │ └── GuidedImage.php ├── TestCase.php ├── Unit ├── Demand │ ├── DummyTest.php │ ├── ExistingImageTest.php │ ├── ImageTest.php │ ├── ResizeTest.php │ ├── TestCase.php │ └── ThumbnailTest.php ├── Service │ ├── ImageDispenserTest.php │ └── ImageUploaderTest.php └── TestCase.php └── bootstrap.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: reliq 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement, request 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | run: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | max-parallel: 10 10 | matrix: 11 | laravel-version: ['^11.1', '^12.1'] 12 | preference: ['stable'] 13 | php-version: ['8.2', '8.3', '8.4'] 14 | exclude: 15 | - laravel-version: ^12.1 16 | php-version: 8.2 17 | name: Laravel ${{ matrix.laravel-version }} (${{ matrix.preference }}) on PHP ${{ matrix.php-version }} 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v2 21 | - name: Setup PHP 22 | uses: shivammathur/setup-php@v2 23 | with: 24 | php-version: ${{ matrix.php-version }} 25 | extensions: mbstring, xdebug 26 | coverage: xdebug 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | - name: Install dependencies 30 | run: | 31 | composer require --no-update --no-interaction "illuminate/support:${{ matrix.laravel-version }}" 32 | composer update --prefer-${{ matrix.preference }} --no-interaction --prefer-dist --no-suggest --no-scripts --optimize-autoloader 33 | - name: Lint composer.json 34 | run: composer validate 35 | - name: Run Tests 36 | run: composer test:ci 37 | - name: Upload Coverage 38 | uses: codecov/codecov-action@v1 39 | with: 40 | token: ${{ secrets.CODECOV_TOKEN }} #required 41 | file: ./build/coverage.xml #optional 42 | flags: unittests #optional 43 | name: codecov-umbrella #optional 44 | fail_ci_if_error: true #optional (default = false) 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | bld/ 21 | [Bb]in/ 22 | [Oo]bj/ 23 | [Ll]og/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | artifacts/ 46 | 47 | *_i.c 48 | *_p.c 49 | *_i.h 50 | *.ilk 51 | *.meta 52 | *.obj 53 | *.pch 54 | *.pdb 55 | *.pgc 56 | *.pgd 57 | *.rsp 58 | *.sbr 59 | *.tlb 60 | *.tli 61 | *.tlh 62 | *.tmp 63 | *.tmp_proj 64 | *.log 65 | *.vspscc 66 | *.vssscc 67 | .builds 68 | *.pidb 69 | *.svclog 70 | *.scc 71 | 72 | # Chutzpah Test files 73 | _Chutzpah* 74 | 75 | # Visual C++ cache files 76 | ipch/ 77 | *.aps 78 | *.ncb 79 | *.opendb 80 | *.opensdf 81 | *.sdf 82 | *.cachefile 83 | *.VC.db 84 | *.VC.VC.opendb 85 | 86 | # Visual Studio profiler 87 | *.psess 88 | *.vsp 89 | *.vspx 90 | *.sap 91 | 92 | # TFS 2012 Local Workspace 93 | $tf/ 94 | 95 | # Guidance Automation Toolkit 96 | *.gpState 97 | 98 | # ReSharper is a .NET coding add-in 99 | _ReSharper*/ 100 | *.[Rr]e[Ss]harper 101 | *.DotSettings.user 102 | 103 | # JustCode is a .NET coding add-in 104 | .JustCode 105 | 106 | # TeamCity is a build add-in 107 | _TeamCity* 108 | 109 | # DotCover is a Code Coverage Tool 110 | *.dotCover 111 | 112 | # NCrunch 113 | _NCrunch_* 114 | .*crunch*.local.xml 115 | nCrunchTemp_* 116 | 117 | # MightyMoose 118 | *.mm.* 119 | AutoTest.Net/ 120 | 121 | # Web workbench (sass) 122 | .sass-cache/ 123 | 124 | # Installshield output folder 125 | [Ee]xpress/ 126 | 127 | # DocProject is a documentation generator add-in 128 | DocProject/buildhelp/ 129 | DocProject/Help/*.HxT 130 | DocProject/Help/*.HxC 131 | DocProject/Help/*.hhc 132 | DocProject/Help/*.hhk 133 | DocProject/Help/*.hhp 134 | DocProject/Help/Html2 135 | DocProject/Help/html 136 | 137 | # Click-Once directory 138 | publish/ 139 | 140 | # Publish Web Output 141 | *.[Pp]ublish.xml 142 | *.azurePubxml 143 | # TODO: Comment the next line if you want to checkin your web deploy settings 144 | # but database connection strings (with potential passwords) will be unencrypted 145 | *.pubxml 146 | *.publishproj 147 | 148 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 149 | # checkin your Azure Web App publish settings, but sensitive information contained 150 | # in these scripts will be unencrypted 151 | PublishScripts/ 152 | 153 | # NuGet Packages 154 | *.nupkg 155 | # The packages folder can be ignored because of Package Restore 156 | **/packages/* 157 | # except build/, which is used as an MSBuild target. 158 | !**/packages/build/ 159 | # Uncomment if necessary however generally it will be regenerated when needed 160 | #!**/packages/repositories.config 161 | # NuGet v3's project.json files produces more ignoreable files 162 | *.nuget.props 163 | *.nuget.targets 164 | 165 | # Microsoft Azure Build Output 166 | csx/ 167 | *.build.csdef 168 | 169 | # Microsoft Azure Emulator 170 | ecf/ 171 | rcf/ 172 | 173 | # Windows Store app package directories and files 174 | AppPackages/ 175 | BundleArtifacts/ 176 | Package.StoreAssociation.xml 177 | _pkginfo.txt 178 | 179 | # Visual Studio cache files 180 | # files ending in .cache can be ignored 181 | *.[Cc]ache 182 | # but keep track of directories ending in .cache 183 | !*.[Cc]ache/ 184 | 185 | # Others 186 | ClientBin/ 187 | ~$* 188 | *~ 189 | *.dbmdl 190 | *.dbproj.schemaview 191 | *.pfx 192 | *.publishsettings 193 | node_modules/ 194 | orleans.codegen.cs 195 | 196 | # Since there are multiple workflows, uncomment next line to ignore bower_components 197 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 198 | #bower_components/ 199 | 200 | # RIA/Silverlight projects 201 | Generated_Code/ 202 | 203 | # Backup & report files from converting an old project file 204 | # to a newer Visual Studio version. Backup files are not needed, 205 | # because we have git ;-) 206 | _UpgradeReport_Files/ 207 | Backup*/ 208 | UpgradeLog*.XML 209 | UpgradeLog*.htm 210 | 211 | # SQL Server files 212 | *.mdf 213 | *.ldf 214 | 215 | # Business Intelligence projects 216 | *.rdl.data 217 | *.bim.layout 218 | *.bim_*.settings 219 | 220 | # Microsoft Fakes 221 | FakesAssemblies/ 222 | 223 | # GhostDoc plugin setting file 224 | *.GhostDoc.xml 225 | 226 | # Node.js Tools for Visual Studio 227 | .ntvs_analysis.dat 228 | 229 | # Visual Studio 6 build log 230 | *.plg 231 | 232 | # Visual Studio 6 workspace options file 233 | *.opt 234 | 235 | # Visual Studio LightSwitch build output 236 | **/*.HTMLClient/GeneratedArtifacts 237 | **/*.DesktopClient/GeneratedArtifacts 238 | **/*.DesktopClient/ModelManifest.xml 239 | **/*.Server/GeneratedArtifacts 240 | **/*.Server/ModelManifest.xml 241 | _Pvt_Extensions 242 | 243 | # Paket dependency manager 244 | .paket/paket.exe 245 | paket-files/ 246 | 247 | # FAKE - F# Make 248 | .fake/ 249 | 250 | # JetBrains Rider 251 | .idea/ 252 | *.sln.iml 253 | 254 | /build/* 255 | vendor 256 | composer.lock 257 | !.gitkeep 258 | .vscode 259 | .DS_Store 260 | *cache 261 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Reliq Arts 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /build/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reliqarts/laravel-guided-image/3f3d19dd06cd1d9e4c5f800fcf62d5a7380e1071/build/.gitkeep -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reliqarts/laravel-guided-image", 3 | "description": "Simplified and ready image manipulation for Laravel via intervention image.", 4 | "keywords": [ 5 | "image", 6 | "route", 7 | "generation", 8 | "laravel", 9 | "photo", 10 | "laravel5", 11 | "resize", 12 | "thumb", 13 | "dummy", 14 | "crop" 15 | ], 16 | "type": "library", 17 | "license": "MIT", 18 | "authors": [ 19 | { 20 | "name": "reliq", 21 | "email": "reliq@reliqarts.com" 22 | } 23 | ], 24 | "require": { 25 | "php": "^8.2", 26 | "illuminate/support": "^11.1 || ^12.1", 27 | "intervention/image": "^3.7", 28 | "reliqarts/laravel-common": "^8.0", 29 | "ext-json": "*", 30 | "ext-fileinfo": "*", 31 | "anhskohbo/no-captcha": "@dev" 32 | }, 33 | "require-dev": { 34 | "laravel/pint": "^1.15", 35 | "orchestra/testbench": "^9.0 || ^10.0", 36 | "phpro/grumphp": "^2.5", 37 | "phpspec/prophecy-phpunit": "^2.0", 38 | "phpunit/phpunit": "^11.0", 39 | "yieldstudio/grumphp-laravel-pint": "^1.0" 40 | }, 41 | "autoload": { 42 | "psr-4": { 43 | "ReliqArts\\GuidedImage\\": "src/", 44 | "ReliqArts\\GuidedImage\\Tests\\": "tests/" 45 | } 46 | }, 47 | "scripts": { 48 | "test": "phpunit", 49 | "test:ci": "phpunit --colors=auto --coverage-clover=build/coverage.xml", 50 | "test:unit": "phpunit --testsuite=Unit --verbose --coverage-clover=build/coverage.xml" 51 | }, 52 | "config": { 53 | "sort-packages": true, 54 | "allow-plugins": { 55 | "phpro/grumphp": true 56 | } 57 | }, 58 | "extra": { 59 | "laravel": { 60 | "providers": [ 61 | "ReliqArts\\GuidedImage\\ServiceProvider" 62 | ] 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | false, 6 | 7 | // Set the model to be guided. 8 | 'model' => env('GUIDED_IMAGE_MODEL', 'Image'), 9 | 10 | // Set the guided model namespace. 11 | 'model_namespace' => env('GUIDED_IMAGE_MODEL_NAMESPACE', 'App\\Models\\'), 12 | 13 | // Set the model to be guided. 14 | 'database' => [ 15 | // Guided image table. 16 | 'image_table' => env('GUIDED_IMAGE_TABLE', 'images'), 17 | 18 | // Guided imageables table. 19 | 'imageables_table' => env('GUIDED_IMAGEABLES_TABLE', 'imageables'), 20 | ], 21 | 22 | // image encoding @see: http://image.intervention.io/api/encode 23 | 'encoding' => [ 24 | 'mime_type' => env('GUIDED_IMAGE_ENCODING_MIME_TYPE', 'image/png'), 25 | 'quality' => env('GUIDED_IMAGE_ENCODING_QUALITY', 90), 26 | ], 27 | 28 | // Route related options. 29 | 'routes' => [ 30 | // Define controllers here which guided routes should be added onto: 31 | 'controllers' => [ 32 | env('GUIDED_IMAGE_CONTROLLER', 'ImageController'), 33 | ], 34 | 35 | // Set the prefix that should be used for routes 36 | 'prefix' => env('GUIDED_IMAGE_ROUTE_PREFIX', 'image'), 37 | 38 | // Set the bindings for guided routes. 39 | 'bindings' => [ 40 | // public 41 | 'public' => [ 42 | 'middleware' => 'web', 43 | ], 44 | 45 | // admin 46 | 'admin' => [ 47 | // 'middleware' => 'admin', 48 | ], 49 | ], 50 | ], 51 | 52 | // allowed extensions 53 | 'allowed_extensions' => ['gif', 'jpg', 'jpeg', 'png'], 54 | 55 | // image rules for validation 56 | 'rules' => 'required|mimes:png,gif,jpeg|max:2048', 57 | 58 | // storage 59 | 'storage' => [ 60 | // disk for in-built caching mechanism; MUST BE A LOCAL DISK, cloud disks such as s3 are not supported here. 61 | 'cache_disk' => env('GUIDED_IMAGE_CACHE_DISK', 'local'), 62 | 63 | // upload disk 64 | 'upload_disk' => env('GUIDED_IMAGE_UPLOAD_DISK', 'public'), 65 | 66 | // Where uploaded images should be stored. This is relative to the application's public directory. 67 | 'upload_dir' => env('GUIDED_IMAGE_UPLOAD_DIR', 'uploads/images'), 68 | 69 | // generate upload sub directories (e.g. 2019/05) 70 | 'generate_upload_date_sub_directories' => env('GUIDED_IMAGE_GENERATE_UPLOAD_DATE_SUB_DIRECTORIES', false), 71 | 72 | // Temporary storage directory for images already generated. 73 | // This directory will live inside your application's storage directory. 74 | 'cache_dir' => env('GUIDED_IMAGE_CACHE_DIR', 'images'), 75 | 76 | // Generated thumbnails will be temporarily kept inside this directory. 77 | 'cache_sub_dir_thumbs' => env('GUIDED_IMAGE_CACHE_SUB_DIR_THUMBS', '.thumb'), 78 | 79 | // Generated resized images will be temporarily kept inside this directory. 80 | 'cache_sub_dir_resized' => env('GUIDED_IMAGE_CACHE_SUB_DIR_RESIZED', '.resized'), 81 | ], 82 | 83 | // headers 84 | 'headers' => [ 85 | // cache days 86 | 'cache_days' => env('GUIDED_IMAGE_CACHE_DAYS', 366), 87 | 88 | // any additional headers for guided images 89 | 'additional' => [], 90 | ], 91 | 92 | 'dispenser' => [ 93 | // whether raw image should be served as fallback if NotReadableException occurs 94 | 'raw_image_fallback_enabled' => env('GUIDED_IMAGE_DISPENSER_RAW_IMAGE_FALLBACK_ENABLED', false), 95 | ], 96 | ]; 97 | -------------------------------------------------------------------------------- /database/migrations/2016_01_01_000000_create_guided_images_table.php: -------------------------------------------------------------------------------- 1 | configProvider = resolve(ConfigProvider::class); 21 | } 22 | 23 | /** 24 | * Run the migrations. 25 | */ 26 | public function up() 27 | { 28 | $tableName = $this->configProvider->getImagesTableName(); 29 | 30 | if (!Schema::hasTable($tableName)) { 31 | Schema::create($tableName, function (Blueprint $table) { 32 | $table->increments('id'); 33 | $table->string('name', 191); 34 | $table->string('mime_type', 20); 35 | $table->string('extension', 10); 36 | $table->integer('size'); 37 | $table->integer('height'); 38 | $table->integer('width'); 39 | $table->string('location'); 40 | $table->string('full_path'); 41 | $table->timestamps(); 42 | }); 43 | } 44 | } 45 | 46 | /** 47 | * Reverse the migrations. 48 | */ 49 | public function down() 50 | { 51 | $tableName = $this->configProvider->getImagesTableName(); 52 | 53 | Schema::dropIfExists($tableName); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /database/migrations/2016_01_01_000001_create_guided_imageables_table.php: -------------------------------------------------------------------------------- 1 | configProvider = resolve(ConfigProvider::class); 21 | } 22 | 23 | /** 24 | * Run the migrations. 25 | */ 26 | public function up() 27 | { 28 | $tableName = $this->configProvider->getImageablesTableName(); 29 | $imagesTableName = $this->configProvider->getImagesTableName(); 30 | 31 | if (Schema::hasTable($tableName)) { 32 | return; 33 | } 34 | 35 | Schema::create($tableName, function (Blueprint $table) use ($imagesTableName) { 36 | $table->increments('id'); 37 | $table->integer('image_id')->unsigned(); 38 | $table->foreign('image_id') 39 | ->references('id') 40 | ->on($imagesTableName) 41 | ->onDelete('CASCADE'); 42 | $table->integer('imageable_id'); 43 | $table->string('imageable_type'); 44 | }); 45 | } 46 | 47 | /** 48 | * Reverse the migrations. 49 | */ 50 | public function down() 51 | { 52 | $tableName = $this->configProvider->getImageablesTableName(); 53 | 54 | Schema::dropIfExists($tableName); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /docs/examples/Image.php: -------------------------------------------------------------------------------- 1 | morphedByMany('App\Post', 'imageable'); 37 | } 38 | 39 | /** 40 | * {@inheritdoc} 41 | */ 42 | public function isSafeForDelete(int $safeAmount = 1): bool 43 | { 44 | /** @noinspection PhpUndefinedClassInspection */ 45 | $posts = Post::withTrashed()->where('image_id', $this->id)->get(); 46 | $posts = $this->posts->merge($posts); 47 | $usage = $posts->count(); 48 | 49 | return $usage <= $safeAmount; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /docs/examples/ImageController.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | tests/Feature 12 | 13 | 14 | tests/Unit 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | src 30 | 31 | 32 | src/Concerns 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "preset": "psr12" 3 | } 4 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Laravel Guided Image 2 | 3 | Guided Image is an image utility package for Laravel based on Intervention Image. 4 | 5 | [![Built For Laravel](https://img.shields.io/badge/built%20for-laravel-red.svg?style=flat-square)](http://laravel.com) 6 | [![Build Status (all)](https://img.shields.io/travis/com/reliqarts/laravel-guided-image?style=flat-square)](https://travis-ci.com/reliqarts/laravel-guided-image) 7 | [![Scrutinizer](https://img.shields.io/scrutinizer/g/reliqarts/laravel-guided-image.svg?style=flat-square)](https://scrutinizer-ci.com/g/reliqarts/laravel-guided-image/) 8 | [![Codecov](https://img.shields.io/codecov/c/github/reliqarts/laravel-guided-image.svg?style=flat-square)](https://codecov.io/gh/reliqarts/laravel-guided-image) 9 | [![Maintainability](https://api.codeclimate.com/v1/badges/6ac0ef615e2e97909984/maintainability)](https://codeclimate.com/github/reliqarts/laravel-guided-image/maintainability) 10 | [![License](https://poser.pugx.org/reliqarts/laravel-guided-image/license?format=flat-square)](https://packagist.org/packages/reliqarts/laravel-guided-image) 11 | [![Latest Stable Version](https://poser.pugx.org/reliqarts/laravel-guided-image/version?format=flat-square)](https://packagist.org/packages/reliqarts/laravel-guided-image) 12 | [![Latest Unstable Version](https://poser.pugx.org/reliqarts/laravel-guided-image/v/unstable?format=flat-square)](//packagist.org/packages/reliqarts/laravel-guided-image) 13 | 14 |   15 | 16 | [![Guided Image for Laravel](https://raw.githubusercontent.com/reliqarts/laravel-guided-image/main/docs/images/logo.png)](#) 17 | 18 | ## Key Features 19 | 20 | - On-the-fly image resizing 21 | - On-the-fly thumbnail generation 22 | - Image uploading 23 | - Smart image reuse; mitigating against double uploads and space resource waste. 24 | 25 | Guided Image can be integrated seamlessly with your existing image model. 26 | 27 | ### Guided Routes 28 | 29 | The package provides routes for generating resized/cropped/dummy images. 30 | - Routes are configurable you you may set any middleware and prefix you want. 31 | - Generated images are *cached to disk* to avoid regenerating frequently accessed images and reduce overhead. 32 | 33 | ### Image file reuse 34 | 35 | For situations where different instances of models use the same image. 36 | - The package provides a safe removal feature which allows images to be detached and only deleted from disk if not being used elsewhere. 37 | - An overridable method is used to determine when an image should be considered *safe* to delete. 38 | 39 | ## Installation & Usage 40 | 41 | ### Installation 42 | 43 | Install via composer; in console: 44 | ``` 45 | composer require reliqarts/laravel-guided-image 46 | ``` 47 | or require in *composer.json*: 48 | ```json 49 | { 50 | "require": { 51 | "reliqarts/laravel-guided-image": "^5.0" 52 | } 53 | } 54 | ``` 55 | then run `composer update` in your terminal to pull it in. 56 | 57 | Finally, publish package resources and configuration: 58 | 59 | ``` 60 | php artisan vendor:publish --provider="ReliqArts\GuidedImage\ServiceProvider" 61 | ``` 62 | 63 | You may opt to publish only configuration by using the `guidedimage-config` tag: 64 | 65 | ``` 66 | php artisan vendor:publish --provider="ReliqArts\GuidedImage\ServiceProvider" --tag="guidedimage-config" 67 | ``` 68 | 69 | ### Setup 70 | 71 | Set the desired environment variables so the package knows your image model, controller(s), etc. 72 | 73 | Example environment config: 74 | ``` 75 | GUIDED_IMAGE_MODEL=Image 76 | GUIDED_IMAGE_CONTROLLER=ImageController 77 | GUIDED_IMAGE_ROUTE_PREFIX=image 78 | GUIDED_IMAGE_SKIM_DIR=images 79 | ``` 80 | 81 | These variables, and more are explained within the [config](https://github.com/ReliqArts/laravel-guided-image/blob/master/config/config.php) file. 82 | 83 | And... it's ready! :ok_hand: 84 | 85 | ### Usage 86 | 87 | To *use* Guided Image you must do just that from your *Image* model. :smirk: 88 | 89 | Implement the `ReliqArts\GuidedImage\Contract\GuidedImage` contract and use the `ReliqArts\GuidedImage\Concern\Guided` trait, e.g: 90 | 91 | ```php 92 | use Illuminate\Database\Eloquent\Model; 93 | use ReliqArts\GuidedImage\Concern\Guided; 94 | use ReliqArts\GuidedImage\Contract\GuidedImage; 95 | 96 | class Image extends Model implements GuidedImage 97 | { 98 | use Guided; 99 | 100 | // ... properties and methods 101 | } 102 | ``` 103 | See example [here](https://github.com/ReliQArts/laravel-guided-image/blob/master/docs/examples/Image.php). 104 | 105 | Implement the `ReliqArts\GuidedImage\Contract\ImageGuide` contract and use the `ReliqArts\GuidedImage\Concern\Guide` trait from your *ImageController*, e.g: 106 | 107 | ```php 108 | use ReliqArts\GuidedImage\Contract\ImageGuide; 109 | use ReliqArts\GuidedImage\Concern\Guide; 110 | 111 | class ImageController extends Controller implements ImageGuide 112 | { 113 | use Guide; 114 | } 115 | ``` 116 | See example [here](https://github.com/ReliQArts/laravel-guided-image/blob/master/docs/examples/ImageController.php). 117 | 118 | #### Features 119 | 120 | ##### Safely Remove Image (dissociate & conditionally delete the image) 121 | 122 | An guided image instance is removed by calling the *remove* method. e.g: 123 | 124 | ```php 125 | $oldImage->remove($force); 126 | ``` 127 | `$force` is optional and is `false` by default. 128 | 129 | ##### Link Generation 130 | 131 | You may retrieve guided links to resized or cropped images like so: 132 | 133 | ```php 134 | // resized image: 135 | $linkToImage = $image->routeResized([ 136 | '550', // width 137 | '_', // height, 'null' is OK 138 | '_', // keep aspect ratio? true by default, 'null' is OK 139 | '_', // allow upsize? false by default, 'null' is OK 140 | ]); 141 | 142 | // thumbnail: 143 | $linkToImage = $image->routeThumbnail([ 144 | 'crop', // method: crop|fit 145 | '550', // width 146 | '_', // height 147 | ]); 148 | ``` 149 | **NB:** In the above example `_` resolves to `null`. 150 | 151 | Have a look at the [GuidedImage contract](https://github.com/ReliQArts/laravel-guided-image/blob/master/src/Contract/GuidedImage.php) for more info on model functions. 152 | 153 | For more info on controller functions see the [ImageGuide contract](https://github.com/reliqarts/laravel-guided-image/blob/master/src/Contract/ImageGuide.php). 154 | 155 | ##### Routes 156 | 157 | Your actually routes will depend heavily on your custom configuration. Here is an example of what the routes may look like: 158 | 159 | ``` 160 | || GET|HEAD | image/.dum//{width}-{height}/{color?}/{fill?} | image.dummy | App\Http\Controllers\ImageController@dummy | web | 161 | || GET|HEAD | image/.res/{image}//{width}-{height}/{aspect?}/{upSize?}| image.resize | App\Http\Controllers\ImageController@resized | web | 162 | || GET|HEAD | image/.tmb/{image}//m.{method}/{width}-{height} | image.thumb | App\Http\Controllers\ImageController@thumb | web | 163 | || GET|HEAD | image/empty-cache | image.empty-cache | App\Http\Controllers\ImageController@emptyCache | web | 164 | 165 | ``` 166 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | getGuidedModelName(true); 19 | 20 | // get controllers for routes and create the routes for each 21 | foreach ($configProvider->getControllersForRoutes() as $controllerName) { 22 | // if controller name's empty skip 23 | if (! $controllerName) { 24 | continue; 25 | } 26 | 27 | // if controller name doesn't contain namespace, add it 28 | if (! str_contains($controllerName, '\\')) { 29 | $controllerName = sprintf('App\\Http\\Controllers\\%s', $controllerName); 30 | } 31 | 32 | // the public route group 33 | Route::group( 34 | $configProvider->getRouteGroupBindings(), 35 | static function () use ($configProvider, $controllerName, $modelName) { 36 | // $guidedModel thumbnail 37 | Route::get( 38 | sprintf('.tmb/{%s}//m.{method}/{width}-{height}', $modelName), 39 | sprintf('%s@thumb', $controllerName) 40 | )->name(sprintf('%s.thumb', $modelName)); 41 | 42 | // Resized $guidedModel 43 | Route::get( 44 | sprintf('.res/{%s}//{width}-{height}/{aspect?}/{upSize?}', $modelName), 45 | sprintf('%s@resized', $controllerName) 46 | )->name(sprintf('%s.resize', $modelName)); 47 | 48 | // admin route group 49 | Route::group( 50 | $configProvider->getRouteGroupBindings([], 'admin'), 51 | static function () use ($controllerName, $modelName) { 52 | // Used to empty directory photo cache (skimDir) 53 | Route::get( 54 | 'empty-cache', 55 | sprintf('%s@emptyCache', $controllerName) 56 | )->name(sprintf('%s.empty-cache', $modelName)); 57 | } 58 | ); 59 | } 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/Concern/Guide.php: -------------------------------------------------------------------------------- 1 | emptyCache()) { 28 | return response()->json( 29 | new Result(true, '', ['Cache successfully cleared.']) 30 | ); 31 | } 32 | 33 | return response()->json( 34 | new Result(false, $errorMessage, [$errorMessage]) 35 | ); 36 | } 37 | 38 | public function resized( 39 | ImageDispenser $imageDispenser, 40 | Request $request, 41 | GuidedImage $guidedImage, 42 | mixed $width, 43 | mixed $height, 44 | mixed $aspect = true, 45 | mixed $upSize = false 46 | ): Response { 47 | $demand = new Resize($request, $guidedImage, $width, $height, $aspect, $upSize); 48 | 49 | return $imageDispenser->getResizedImage($demand); 50 | } 51 | 52 | public function thumb( 53 | ImageDispenser $imageDispenser, 54 | Request $request, 55 | GuidedImage $guidedImage, 56 | mixed $method, 57 | mixed $width, 58 | mixed $height 59 | ): Response { 60 | $demand = new Thumbnail($request, $guidedImage, $method, $width, $height); 61 | 62 | return $imageDispenser->getImageThumbnail($demand); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Concern/Guided.php: -------------------------------------------------------------------------------- 1 | getUrl(); 53 | } 54 | 55 | /** @var ConfigProvider $configProvider */ 56 | $configProvider = resolve(ConfigProvider::class); 57 | $guidedModelName = $configProvider->getGuidedModelName(true); 58 | 59 | array_unshift($params, $this->id); 60 | 61 | return route(sprintf('%s.%s', $guidedModelName, $type), $params); 62 | } 63 | 64 | /** 65 | * Whether image is safe for deleting. 66 | * Since a single image may be re-used this method is used to determine 67 | * when an image can be safely deleted from disk. 68 | * 69 | * @param int $safeAmount a photo is safe to delete if it is used by $safe_num amount of records 70 | * 71 | * @return bool whether image is safe for delete 72 | */ 73 | public function isSafeForDelete(int $safeAmount = 1): bool 74 | { 75 | return $safeAmount === 1; 76 | } 77 | 78 | /** 79 | * Get link to resized photo. 80 | * 81 | * @param array $params parameters to pass to route 82 | */ 83 | public function routeResized(array $params = []): string 84 | { 85 | return $this->getRoutedUrl(Resize::ROUTE_TYPE_NAME, $params); 86 | } 87 | 88 | /** 89 | * Get link to photo thumbnail. 90 | * 91 | * @param array $params parameters to pass to route 92 | */ 93 | public function routeThumbnail(array $params = []): string 94 | { 95 | return $this->getRoutedUrl(Thumbnail::ROUTE_TYPE_NAME, $params); 96 | } 97 | 98 | /** 99 | * Get class. 100 | */ 101 | public function getClassName(): string 102 | { 103 | return get_class($this); 104 | } 105 | 106 | /** 107 | * Get URL/path to image. 108 | * 109 | * @param bool $diskRelative whether to return `full path` (relative to disk), 110 | * hence skipping call to Storage facade 111 | * 112 | * @uses \Illuminate\Support\Facades\Storage 113 | */ 114 | public function getUrl(bool $diskRelative = false): string 115 | { 116 | $path = urldecode($this->getFullPath()); 117 | 118 | if ($diskRelative) { 119 | return $path; 120 | } 121 | 122 | /** @var ConfigProvider $configProvider */ 123 | $configProvider = resolve(ConfigProvider::class); 124 | $diskName = $configProvider->getUploadDiskName(); 125 | 126 | return Storage::disk($diskName)->url($path); 127 | } 128 | 129 | /** 130 | * Get ready image title. 131 | */ 132 | public function getTitle(): string 133 | { 134 | return Str::title(preg_replace('/[\\-_]/', ' ', $this->getName())); 135 | } 136 | 137 | /** 138 | * Get full path. 139 | */ 140 | public function getFullPath(): string 141 | { 142 | return $this->full_path ?? ''; 143 | } 144 | 145 | /** 146 | * Get name. 147 | */ 148 | public function getName(): string 149 | { 150 | return $this->name ?? ''; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/Concern/GuidedRepository.php: -------------------------------------------------------------------------------- 1 | upload($file); 25 | } 26 | 27 | /** 28 | * Removes image from database, and disk, if not in use. 29 | * 30 | * @param bool $force override safety constraints 31 | * 32 | * @throws Exception 33 | */ 34 | public function remove(bool $force = false): Result 35 | { 36 | /** @var ConfigProvider $configProvider */ 37 | $configProvider = resolve(ConfigProvider::class); 38 | $diskName = $configProvider->getUploadDiskName(); 39 | $path = urldecode($this->getFullPath()); 40 | 41 | if (!($force || $this->isSafeForDelete())) { 42 | return new Result( 43 | false, 44 | 'Not safe to delete, hence file not removed.' 45 | ); 46 | } 47 | 48 | if (Storage::disk($diskName)->delete($path)) { 49 | $this->delete(); 50 | } 51 | 52 | return new Result(true); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Console/Command/ClearSkimDirectories.php: -------------------------------------------------------------------------------- 1 | emptyCache()) { 32 | $this->line(PHP_EOL . ' Success! Guided Image cache cleared.'); 33 | 34 | return true; 35 | } 36 | 37 | $this->line(PHP_EOL . ' Couldn\'t clear Guided Image cache.'); 38 | 39 | return false; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Contract/ConfigProvider.php: -------------------------------------------------------------------------------- 1 | file('image'); 86 | */ 87 | public static function upload(UploadedFile $imageFile): Result; 88 | } 89 | -------------------------------------------------------------------------------- /src/Contract/ImageDemand.php: -------------------------------------------------------------------------------- 1 | isValueConsideredNull($this->color) ? self::DEFAULT_COLOR : $this->color; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Demand/ExistingImage.php: -------------------------------------------------------------------------------- 1 | request; 24 | } 25 | 26 | final public function getGuidedImage(): GuidedImage 27 | { 28 | return $this->guidedImage; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Demand/Image.php: -------------------------------------------------------------------------------- 1 | isValueConsideredNull($this->width) ? null : (int) $this->width; 18 | } 19 | 20 | final public function getHeight(): ?int 21 | { 22 | return $this->isValueConsideredNull($this->height) ? null : (int) $this->height; 23 | } 24 | 25 | final public function isValueConsideredNull(mixed $value): bool 26 | { 27 | return in_array($value, static::NULLS, true); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Demand/Resize.php: -------------------------------------------------------------------------------- 1 | isValueConsideredNull($this->maintainAspectRatio); 29 | } 30 | 31 | public function allowUpSizing(): bool 32 | { 33 | return ! $this->isValueConsideredNull($this->allowUpSizing); 34 | } 35 | 36 | public function returnObject(): bool 37 | { 38 | return ! $this->isValueConsideredNull($this->returnObject); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Demand/Thumbnail.php: -------------------------------------------------------------------------------- 1 | method === self::METHOD_FIT) { 41 | return self::METHOD_COVER; 42 | } 43 | 44 | return $this->method; 45 | } 46 | 47 | public function isValid(): bool 48 | { 49 | return in_array($this->method, self::METHODS, true); 50 | } 51 | 52 | public function returnObject(): bool 53 | { 54 | return $this->returnObject; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Exception/BadImplementation.php: -------------------------------------------------------------------------------- 1 | getMessage()), 20 | $previousException->getCode(), 21 | $previousException 22 | ); 23 | $instance->url = $url; 24 | 25 | return $instance; 26 | } 27 | 28 | public function getUrl(): string 29 | { 30 | return $this->url; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Helper/FileHelper.php: -------------------------------------------------------------------------------- 1 | '3g2', 72 | 'video/3gp' => '3gp', 73 | 'video/3gpp' => '3gp', 74 | 'application/x-compressed' => '7zip', 75 | 'audio/x-acc' => 'aac', 76 | 'audio/ac3' => 'ac3', 77 | 'application/postscript' => 'ai', 78 | 'audio/x-aiff' => 'aif', 79 | 'audio/aiff' => 'aif', 80 | 'audio/x-au' => 'au', 81 | 'video/x-msvideo' => 'avi', 82 | 'video/msvideo' => 'avi', 83 | 'video/avi' => 'avi', 84 | 'application/x-troff-msvideo' => 'avi', 85 | 'application/macbinary' => 'bin', 86 | 'application/mac-binary' => 'bin', 87 | 'application/x-binary' => 'bin', 88 | 'application/x-macbinary' => 'bin', 89 | 'image/bmp' => 'bmp', 90 | 'image/x-bmp' => 'bmp', 91 | 'image/x-bitmap' => 'bmp', 92 | 'image/x-xbitmap' => 'bmp', 93 | 'image/x-win-bitmap' => 'bmp', 94 | 'image/x-windows-bmp' => 'bmp', 95 | 'image/ms-bmp' => 'bmp', 96 | 'image/x-ms-bmp' => 'bmp', 97 | 'application/bmp' => 'bmp', 98 | 'application/x-bmp' => 'bmp', 99 | 'application/x-win-bitmap' => 'bmp', 100 | 'application/cdr' => 'cdr', 101 | 'application/coreldraw' => 'cdr', 102 | 'application/x-cdr' => 'cdr', 103 | 'application/x-coreldraw' => 'cdr', 104 | 'image/cdr' => 'cdr', 105 | 'image/x-cdr' => 'cdr', 106 | 'zz-application/zz-winassoc-cdr' => 'cdr', 107 | 'application/mac-compactpro' => 'cpt', 108 | 'application/pkix-crl' => 'crl', 109 | 'application/pkcs-crl' => 'crl', 110 | 'application/x-x509-ca-cert' => 'crt', 111 | 'application/pkix-cert' => 'crt', 112 | 'text/css' => 'css', 113 | 'text/x-comma-separated-values' => 'csv', 114 | 'text/comma-separated-values' => 'csv', 115 | 'application/vnd.msexcel' => 'csv', 116 | 'application/x-director' => 'dcr', 117 | 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx', 118 | 'application/x-dvi' => 'dvi', 119 | 'message/rfc822' => 'eml', 120 | 'application/x-msdownload' => 'exe', 121 | 'video/x-f4v' => 'f4v', 122 | 'audio/x-flac' => 'flac', 123 | 'video/x-flv' => 'flv', 124 | 'image/gif' => 'gif', 125 | 'application/gpg-keys' => 'gpg', 126 | 'application/x-gtar' => 'gtar', 127 | 'application/x-gzip' => 'gzip', 128 | 'application/mac-binhex40' => 'hqx', 129 | 'application/mac-binhex' => 'hqx', 130 | 'application/x-binhex40' => 'hqx', 131 | 'application/x-mac-binhex40' => 'hqx', 132 | 'text/html' => 'html', 133 | 'image/x-icon' => 'ico', 134 | 'image/x-ico' => 'ico', 135 | 'image/vnd.microsoft.icon' => 'ico', 136 | 'text/calendar' => 'ics', 137 | 'application/java-archive' => 'jar', 138 | 'application/x-java-application' => 'jar', 139 | 'application/x-jar' => 'jar', 140 | 'image/jp2' => 'jp2', 141 | 'video/mj2' => 'jp2', 142 | 'image/jpx' => 'jp2', 143 | 'image/jpm' => 'jp2', 144 | 'image/jpeg' => 'jpeg', 145 | 'image/pjpeg' => 'jpeg', 146 | 'application/x-javascript' => 'js', 147 | 'application/json' => 'json', 148 | 'text/json' => 'json', 149 | 'application/vnd.google-earth.kml+xml' => 'kml', 150 | 'application/vnd.google-earth.kmz' => 'kmz', 151 | 'text/x-log' => 'log', 152 | 'audio/x-m4a' => 'm4a', 153 | 'audio/mp4' => 'm4a', 154 | 'application/vnd.mpegurl' => 'm4u', 155 | 'audio/midi' => 'mid', 156 | 'application/vnd.mif' => 'mif', 157 | 'video/quicktime' => 'mov', 158 | 'video/x-sgi-movie' => 'movie', 159 | 'audio/mpeg' => 'mp3', 160 | 'audio/mpg' => 'mp3', 161 | 'audio/mpeg3' => 'mp3', 162 | 'audio/mp3' => 'mp3', 163 | 'video/mp4' => 'mp4', 164 | 'video/mpeg' => 'mpeg', 165 | 'application/oda' => 'oda', 166 | 'audio/ogg' => 'ogg', 167 | 'video/ogg' => 'ogg', 168 | 'application/ogg' => 'ogg', 169 | 'font/otf' => 'otf', 170 | 'application/x-pkcs10' => 'p10', 171 | 'application/pkcs10' => 'p10', 172 | 'application/x-pkcs12' => 'p12', 173 | 'application/x-pkcs7-signature' => 'p7a', 174 | 'application/pkcs7-mime' => 'p7c', 175 | 'application/x-pkcs7-mime' => 'p7c', 176 | 'application/x-pkcs7-certreqresp' => 'p7r', 177 | 'application/pkcs7-signature' => 'p7s', 178 | 'application/pdf' => 'pdf', 179 | 'application/octet-stream' => 'pdf', 180 | 'application/x-x509-user-cert' => 'pem', 181 | 'application/x-pem-file' => 'pem', 182 | 'application/pgp' => 'pgp', 183 | 'application/x-httpd-php' => 'php', 184 | 'application/php' => 'php', 185 | 'application/x-php' => 'php', 186 | 'text/php' => 'php', 187 | 'text/x-php' => 'php', 188 | 'application/x-httpd-php-source' => 'php', 189 | 'image/png' => 'png', 190 | 'image/x-png' => 'png', 191 | 'application/powerpoint' => 'ppt', 192 | 'application/vnd.ms-powerpoint' => 'ppt', 193 | 'application/vnd.ms-office' => 'ppt', 194 | 'application/msword' => 'doc', 195 | 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx', 196 | 'application/x-photoshop' => 'psd', 197 | 'image/vnd.adobe.photoshop' => 'psd', 198 | 'audio/x-realaudio' => 'ra', 199 | 'audio/x-pn-realaudio' => 'ram', 200 | 'application/x-rar' => 'rar', 201 | 'application/rar' => 'rar', 202 | 'application/x-rar-compressed' => 'rar', 203 | 'audio/x-pn-realaudio-plugin' => 'rpm', 204 | 'application/x-pkcs7' => 'rsa', 205 | 'text/rtf' => 'rtf', 206 | 'text/richtext' => 'rtx', 207 | 'video/vnd.rn-realvideo' => 'rv', 208 | 'application/x-stuffit' => 'sit', 209 | 'application/smil' => 'smil', 210 | 'text/srt' => 'srt', 211 | 'image/svg+xml' => 'svg', 212 | 'application/x-shockwave-flash' => 'swf', 213 | 'application/x-tar' => 'tar', 214 | 'application/x-gzip-compressed' => 'tgz', 215 | 'image/tiff' => 'tiff', 216 | 'font/ttf' => 'ttf', 217 | 'text/plain' => 'txt', 218 | 'text/x-vcard' => 'vcf', 219 | 'application/videolan' => 'vlc', 220 | 'text/vtt' => 'vtt', 221 | 'audio/x-wav' => 'wav', 222 | 'audio/wave' => 'wav', 223 | 'audio/wav' => 'wav', 224 | 'application/wbxml' => 'wbxml', 225 | 'video/webm' => 'webm', 226 | 'image/webp' => 'webp', 227 | 'audio/x-ms-wma' => 'wma', 228 | 'application/wmlc' => 'wmlc', 229 | 'video/x-ms-wmv' => 'wmv', 230 | 'video/x-ms-asf' => 'wmv', 231 | 'font/woff' => 'woff', 232 | 'font/woff2' => 'woff2', 233 | 'application/xhtml+xml' => 'xhtml', 234 | 'application/excel' => 'xl', 235 | 'application/msexcel' => 'xls', 236 | 'application/x-msexcel' => 'xls', 237 | 'application/x-ms-excel' => 'xls', 238 | 'application/x-excel' => 'xls', 239 | 'application/x-dos_ms_excel' => 'xls', 240 | 'application/xls' => 'xls', 241 | 'application/x-xls' => 'xls', 242 | 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx', 243 | 'application/vnd.ms-excel' => 'xlsx', 244 | 'application/xml' => 'xml', 245 | 'text/xml' => 'xml', 246 | 'text/xsl' => 'xsl', 247 | 'application/xspf+xml' => 'xspf', 248 | 'application/x-compress' => 'z', 249 | 'application/x-zip' => 'zip', 250 | 'application/zip' => 'zip', 251 | 'application/x-zip-compressed' => 'zip', 252 | 'application/s-compressed' => 'zip', 253 | 'multipart/x-zip' => 'zip', 254 | 'text/x-scriptzsh' => 'zsh', 255 | ]; 256 | 257 | return $mimeMap[$mime] ?? null; 258 | } 259 | 260 | /** 261 | * @throws FileException 262 | * @throws FileNotFoundException 263 | */ 264 | public function createUploadedFile(string $tempFile, string $originalName, string $mimeType): UploadedFile 265 | { 266 | return new UploadedFile($tempFile, $originalName, $mimeType); 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/Model/UploadedImage.php: -------------------------------------------------------------------------------- 1 | fileHelper = $fileHelper; 32 | $this->file = $file; 33 | $this->destination = $destination; 34 | } 35 | 36 | public function getFile(): UploadedFile 37 | { 38 | return $this->file; 39 | } 40 | 41 | public function getDestination(): string 42 | { 43 | return $this->destination; 44 | } 45 | 46 | public function getFilename(): string 47 | { 48 | return str_replace(' ', '_', $this->file->getClientOriginalName()); 49 | } 50 | 51 | public function getSize(): int 52 | { 53 | return $this->file->getSize(); 54 | } 55 | 56 | /** 57 | * Get the instance as an array. 58 | */ 59 | public function toArray(): array 60 | { 61 | $image = [ 62 | self::KEY_SIZE => $this->getSize(), 63 | self::KEY_NAME => $this->getFilename(), 64 | self::KEY_MIME_TYPE => $this->file->getMimeType(), 65 | self::KEY_EXTENSION => $this->file->getClientOriginalExtension(), 66 | self::KEY_LOCATION => $this->getDestination(), 67 | ]; 68 | $image[self::KEY_FULL_PATH] = urlencode( 69 | sprintf('%s/%s', $this->getDestination(), $this->getFilename()) 70 | ); 71 | [$image[self::KEY_WIDTH], $image[self::KEY_HEIGHT]] = $this->fileHelper->getImageSize( 72 | $this->file->getRealPath() 73 | ); 74 | 75 | return $image; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Result.php: -------------------------------------------------------------------------------- 1 | configAccessor = $configAccessor; 103 | } 104 | 105 | public function getAllowedExtensions(): array 106 | { 107 | return $this->configAccessor->get(self::CONFIG_KEY_ALLOWED_EXTENSIONS, self::DEFAULT_ALLOWED_EXTENSIONS); 108 | } 109 | 110 | /** 111 | * {@inheritdoc} 112 | * 113 | * @return array of controller FQCNs for route binding 114 | */ 115 | public function getControllersForRoutes(): array 116 | { 117 | return $this->configAccessor->get(self::CONFIG_KEY_ROUTES_CONTROLLERS, []); 118 | } 119 | 120 | /** 121 | * {@inheritdoc} 122 | * 123 | * @return string prefix 124 | */ 125 | public function getRoutePrefix(): string 126 | { 127 | return $this->configAccessor->get(self::CONFIG_KEY_ROUTES_PREFIX, self::DEFAULT_ROUTES_PREFIX); 128 | } 129 | 130 | /** 131 | * {@inheritdoc} 132 | * 133 | * @return string model name 134 | */ 135 | public function getGuidedModelName(bool $lowered = false): string 136 | { 137 | $model = $this->configAccessor->get(self::CONFIG_KEY_GUIDED_MODEL, self::DEFAULT_GUIDED_MODEL); 138 | 139 | return $lowered ? strtolower($model) : $model; 140 | } 141 | 142 | /** 143 | * {@inheritdoc} 144 | * 145 | * @return string model namespace 146 | */ 147 | public function getGuidedModelNamespace(bool $lowered = false): string 148 | { 149 | $modelNamespace = $this->configAccessor->get( 150 | self::CONFIG_KEY_GUIDED_MODEL_NAMESPACE, 151 | self::DEFAULT_GUIDED_MODEL_NAMESPACE 152 | ); 153 | 154 | return $lowered ? strtolower($modelNamespace) : $modelNamespace; 155 | } 156 | 157 | /** 158 | * {@inheritdoc} 159 | */ 160 | public function getRouteGroupBindings(array $bindings = [], string $groupKey = self::ROUTE_GROUP_KEY_PUBLIC): array 161 | { 162 | $defaults = $groupKey === self::ROUTE_GROUP_KEY_PUBLIC ? [self::KEY_PREFIX => $this->getRoutePrefix()] : []; 163 | 164 | $bindings = array_merge( 165 | $this->configAccessor->get(sprintf(self::CONFIG_KEY_ROUTES_BINDINGS_WITH_GROUP, $groupKey), []), 166 | $bindings 167 | ); 168 | 169 | return array_merge($defaults, $bindings); 170 | } 171 | 172 | public function getImageRules(): string 173 | { 174 | return $this->configAccessor->get(self::CONFIG_KEY_IMAGE_RULES, self::DEFAULT_IMAGE_RULES); 175 | } 176 | 177 | /** 178 | * {@inheritdoc} 179 | */ 180 | public function getImagesTableName(): string 181 | { 182 | return $this->configAccessor->get(self::CONFIG_KEY_IMAGES_TABLE, self::DEFAULT_IMAGES_TABLE); 183 | } 184 | 185 | /** 186 | * {@inheritdoc} 187 | */ 188 | public function getImageablesTableName(): string 189 | { 190 | return $this->configAccessor->get(self::CONFIG_KEY_IMAGEABLES_TABLE, self::DEFAULT_IMAGEABLES_TABLE); 191 | } 192 | 193 | public function getUploadDirectory(): string 194 | { 195 | return $this->configAccessor->get(self::CONFIG_KEY_STORAGE_UPLOAD_DIRECTORY, self::DEFAULT_UPLOAD_DIRECTORY); 196 | } 197 | 198 | public function generateUploadDateSubDirectories(): bool 199 | { 200 | return (bool) $this->configAccessor->get( 201 | self::CONFIG_KEY_STORAGE_GENERATE_UPLOAD_DATE_SUB_DIRECTORIES, 202 | self::DEFAULT_STORAGE_GENERATE_UPLOAD_DATE_SUB_DIRECTORIES 203 | ); 204 | } 205 | 206 | public function getResizedCachePath(): string 207 | { 208 | $cacheDir = $this->getCacheDirectory(); 209 | $cacheResizedSubDir = $this->configAccessor->get( 210 | self::CONFIG_KEY_STORAGE_CACHE_SUB_DIR_RESIZED, 211 | self::DEFAULT_STORAGE_CACHE_SUB_DIR_RESIZED 212 | ); 213 | 214 | return sprintf('%s/%s', $cacheDir, $cacheResizedSubDir); 215 | } 216 | 217 | public function getThumbsCachePath(): string 218 | { 219 | $cacheDir = $this->getCacheDirectory(); 220 | $cacheThumbsSubDir = $this->configAccessor->get( 221 | self::CONFIG_KEY_STORAGE_CACHE_SUB_DIR_THUMBS, 222 | self::DEFAULT_STORAGE_CACHE_SUB_DIR_THUMBS 223 | ); 224 | 225 | return sprintf('%s/%s', $cacheDir, $cacheThumbsSubDir); 226 | } 227 | 228 | public function getCacheDaysHeader(): int 229 | { 230 | return (int) $this->configAccessor->get(self::CONFIG_KEY_HEADERS_CACHE_DAYS, self::DEFAULT_HEADER_CACHE_DAYS); 231 | } 232 | 233 | public function getAdditionalHeaders(): array 234 | { 235 | return $this->configAccessor->get(self::CONFIG_KEY_HEADERS_ADDITIONAL, self::DEFAULT_ADDITIONAL_HEADERS); 236 | } 237 | 238 | public function getCacheDirectory(): string 239 | { 240 | return $this->configAccessor->get( 241 | self::CONFIG_KEY_STORAGE_CACHE_DIR, 242 | self::DEFAULT_STORAGE_CACHE_DIR 243 | ); 244 | } 245 | 246 | public function getImageEncodingMimeType(): string 247 | { 248 | return $this->configAccessor->get( 249 | self::CONFIG_KEY_IMAGE_ENCODING_MIME_TYPE, 250 | self::DEFAULT_IMAGE_ENCODING_MIME_TYPE 251 | ); 252 | } 253 | 254 | public function getImageEncodingQuality(): int 255 | { 256 | return (int) $this->configAccessor->get( 257 | self::CONFIG_KEY_IMAGE_ENCODING_QUALITY, 258 | self::DEFAULT_IMAGE_ENCODING_QUALITY 259 | ); 260 | } 261 | 262 | public function getCacheDiskName(): string 263 | { 264 | return $this->configAccessor->get(self::CONFIG_KEY_STORAGE_CACHE_DISK, self::DEFAULT_STORAGE_CACHE_DISK); 265 | } 266 | 267 | public function getUploadDiskName(): string 268 | { 269 | return $this->configAccessor->get(self::CONFIG_KEY_STORAGE_UPLOAD_DISK, self::DEFAULT_STORAGE_UPLOAD_DISK); 270 | } 271 | 272 | public function isRawImageFallbackEnabled(): bool 273 | { 274 | return $this->configAccessor->get( 275 | self::CONFIG_KEY_DISPENSER_RAW_IMAGE_FALLBACK_ENABLED, 276 | self::DEFAULT_DISPENSER_RAW_IMAGE_FALLBACK_ENABLED 277 | ); 278 | } 279 | } 280 | -------------------------------------------------------------------------------- /src/Service/ImageDispenser.php: -------------------------------------------------------------------------------- 1 | cacheDisk = $filesystemManager->disk($configProvider->getCacheDiskName()); 64 | $this->uploadDisk = $filesystemManager->disk($configProvider->getUploadDiskName()); 65 | $this->imageEncodingMimeType = $configProvider->getImageEncodingMimeType(); 66 | $this->imageEncodingQuality = $configProvider->getImageEncodingQuality(); 67 | 68 | $this->prepCacheDirectories(); 69 | } 70 | 71 | /** 72 | * {@inheritdoc} 73 | * 74 | * @throws RuntimeException 75 | */ 76 | public function getDummyImage(Dummy $demand): ImageInterface 77 | { 78 | return $this->imageManager->create( 79 | $demand->getWidth(), 80 | $demand->getHeight() 81 | )->fill($demand->getColor()); 82 | } 83 | 84 | /** 85 | * {@inheritdoc} 86 | * 87 | * @return ImageInterface|SymfonyResponse|void 88 | * 89 | * @throws InvalidArgumentException 90 | * @throws RuntimeException 91 | */ 92 | public function getResizedImage(Resize $demand) 93 | { 94 | $guidedImage = $demand->getGuidedImage(); 95 | $width = $demand->getWidth(); 96 | $height = $demand->getHeight(); 97 | $cacheFilePath = sprintf( 98 | '%s/%d-%d-_-%d_%d_%s', 99 | $this->resizedCachePath, 100 | $width, 101 | $height, 102 | $demand->maintainAspectRatio() ? 1 : 0, 103 | $demand->allowUpSizing() ? 1 : 0, 104 | $guidedImage->getName() 105 | ); 106 | 107 | try { 108 | if ($this->cacheDisk->exists($cacheFilePath)) { 109 | $image = $this->makeImageWithEncoding($this->cacheDisk->path($cacheFilePath)); 110 | } else { 111 | $image = $this->makeImageWithEncoding($this->getImageFullPath($guidedImage)); 112 | $sizingMethod = $demand->allowUpSizing() ? 'resize' : 'resizeDown'; 113 | if ($demand->maintainAspectRatio()) { 114 | $sizingMethod = $demand->allowUpSizing() ? 'scale' : 'scaleDown'; 115 | } 116 | 117 | $image->{$sizingMethod}($width, $height); 118 | $image->save($this->cacheDisk->path($cacheFilePath)); 119 | } 120 | 121 | if ($demand->returnObject()) { 122 | return $image; 123 | } 124 | 125 | return new Response( 126 | $this->cacheDisk->get($cacheFilePath), 127 | self::RESPONSE_HTTP_OK, 128 | $this->getImageHeaders($cacheFilePath, $demand, $image) ?: [] 129 | ); 130 | } catch (RuntimeException $exception) { 131 | return $this->handleRuntimeException($exception, $guidedImage); 132 | } catch (FileNotFoundException $exception) { 133 | $this->logger->error( 134 | sprintf( 135 | 'Exception was encountered while building resized image; %s', 136 | $exception->getMessage() 137 | ), 138 | [ 139 | self::KEY_IMAGE_URL => $guidedImage->getUrl(), 140 | self::KEY_CACHE_FILE => $cacheFilePath, 141 | ] 142 | ); 143 | 144 | abort(self::RESPONSE_HTTP_NOT_FOUND); 145 | } 146 | } 147 | 148 | /** 149 | * {@inheritdoc} 150 | * 151 | * @return ImageInterface|SymfonyResponse|never 152 | * 153 | * @throws InvalidArgumentException 154 | * @throws RuntimeException 155 | * 156 | * @noinspection PhpVoidFunctionResultUsedInspection 157 | */ 158 | public function getImageThumbnail(Thumbnail $demand) 159 | { 160 | if (! $demand->isValid()) { 161 | $this->logger->warning( 162 | sprintf('Invalid demand for thumbnail image. Method: %s', $demand->getMethod()), 163 | [ 164 | 'method' => $demand->getMethod(), 165 | ] 166 | ); 167 | 168 | return abort(self::RESPONSE_HTTP_NOT_FOUND); 169 | } 170 | 171 | $guidedImage = $demand->getGuidedImage(); 172 | $width = $demand->getWidth(); 173 | $height = $demand->getHeight(); 174 | $method = $demand->getMethod(); 175 | $cacheFilePath = sprintf( 176 | '%s/%d-%d-_-%s_%s', 177 | $this->thumbsCachePath, 178 | $width, 179 | $height, 180 | $method, 181 | $guidedImage->getName() 182 | ); 183 | 184 | try { 185 | if ($this->cacheDisk->exists($cacheFilePath)) { 186 | $image = $this->makeImageWithEncoding($this->cacheDisk->path($cacheFilePath)); 187 | } else { 188 | /** @var ImageInterface $image */ 189 | $image = $this->imageManager 190 | ->read($this->getImageFullPath($guidedImage)) 191 | ->{$method}( 192 | $width, 193 | $height 194 | ); 195 | 196 | $image->save($this->cacheDisk->path($cacheFilePath)); 197 | } 198 | 199 | if ($demand->returnObject()) { 200 | return $image; 201 | } 202 | 203 | return new Response( 204 | $this->cacheDisk->get($cacheFilePath), 205 | self::RESPONSE_HTTP_OK, 206 | $this->getImageHeaders($cacheFilePath, $demand, $image) ?: [] 207 | ); 208 | } catch (RuntimeException $exception) { 209 | return $this->handleRuntimeException($exception, $guidedImage); 210 | } catch (FileNotFoundException $exception) { 211 | $this->logger->error( 212 | sprintf( 213 | 'Exception was encountered while building thumbnail; %s', 214 | $exception->getMessage() 215 | ), 216 | [ 217 | self::KEY_IMAGE_URL => $guidedImage->getUrl(), 218 | self::KEY_CACHE_FILE => $cacheFilePath, 219 | ] 220 | ); 221 | 222 | abort(self::RESPONSE_HTTP_NOT_FOUND); 223 | } 224 | } 225 | 226 | public function emptyCache(): bool 227 | { 228 | return $this->cacheDisk->deleteDirectory($this->resizedCachePath) 229 | && $this->cacheDisk->deleteDirectory($this->thumbsCachePath); 230 | } 231 | 232 | /** 233 | * Get image headers. Improved caching 234 | * If the image has not been modified say 304 Not Modified. 235 | * 236 | * @return array image headers 237 | */ 238 | private function getImageHeaders(string $relativeCacheFilePath, ExistingImage $demand, ImageInterface $image): array 239 | { 240 | $request = $demand->getRequest(); 241 | $fullCacheFilePath = $this->cacheDisk->path($relativeCacheFilePath); 242 | $lastModified = $this->cacheDisk->lastModified($relativeCacheFilePath); 243 | $modifiedSince = $request->header('If-Modified-Since', ''); 244 | $etagHeader = trim($request->header('If-None-Match', '')); 245 | $etagFile = $this->fileHelper->hashFile($fullCacheFilePath); 246 | $originalImageRelativePath = $demand->getGuidedImage()->getUrl(true); 247 | 248 | // check if image hasn't changed 249 | if ($etagFile === $etagHeader || strtotime($modifiedSince) === $lastModified) { 250 | // Say not modified and kill script 251 | header('HTTP/1.1 304 Not Modified'); 252 | header(sprintf('ETag: %s', $etagFile)); 253 | exit(); 254 | } 255 | 256 | // adjust headers and return 257 | return array_merge( 258 | $this->getDefaultHeaders(), 259 | [ 260 | 'Content-Type' => $image->origin()->mediaType(), 261 | 'Content-Disposition' => sprintf('inline; filename=%s', basename($originalImageRelativePath)), 262 | 'Last-Modified' => date(DATE_RFC822, $lastModified), 263 | 'Etag' => $etagFile, 264 | ] 265 | ); 266 | } 267 | 268 | private function prepCacheDirectories(): void 269 | { 270 | $this->resizedCachePath = $this->configProvider->getResizedCachePath(); 271 | $this->thumbsCachePath = $this->configProvider->getThumbsCachePath(); 272 | 273 | if (! $this->cacheDisk->exists($this->thumbsCachePath)) { 274 | $this->cacheDisk->makeDirectory($this->thumbsCachePath); 275 | } 276 | 277 | if (! $this->cacheDisk->exists($this->resizedCachePath)) { 278 | $this->cacheDisk->makeDirectory($this->resizedCachePath); 279 | } 280 | } 281 | 282 | private function getDefaultHeaders(): array 283 | { 284 | $maxAge = self::ONE_DAY_IN_SECONDS * $this->configProvider->getCacheDaysHeader(); 285 | 286 | return array_merge( 287 | [ 288 | 'X-Guided-Image' => true, 289 | 'Cache-Control' => sprintf('public, max-age=%s', $maxAge), 290 | ], 291 | $this->configProvider->getAdditionalHeaders() 292 | ); 293 | } 294 | 295 | /** 296 | * @throws RuntimeException 297 | */ 298 | private function makeImageWithEncoding(mixed $data): ImageInterface 299 | { 300 | $encoder = new AutoEncoder( 301 | $this->imageEncodingMimeType ?: self::DEFAULT_IMAGE_ENCODING_MIME_TYPE, 302 | quality: $this->imageEncodingQuality ?: self::DEFAULT_IMAGE_ENCODING_QUALITY, 303 | ); 304 | 305 | $encodedImage = $this->imageManager 306 | ->read($data) 307 | ->encode($encoder); 308 | 309 | return $this->imageManager->read($encodedImage->toFilePointer()); 310 | } 311 | 312 | private function getImageFullPath(GuidedImage $guidedImage): string 313 | { 314 | return $this->uploadDisk->path($guidedImage->getUrl(true)); 315 | } 316 | 317 | /** 318 | * @throws RuntimeException 319 | */ 320 | private function handleRuntimeException( 321 | RuntimeException $exception, 322 | GuidedImage $guidedImage 323 | ): BinaryFileResponse { 324 | $errorMessage = 'Intervention image creation failed with RuntimeException;'; 325 | $context = ['imageUrl' => $this->getImageFullPath($guidedImage)]; 326 | 327 | if (! $this->configProvider->isRawImageFallbackEnabled()) { 328 | $this->logger->error( 329 | sprintf('%s %s. Raw image fallback is disabled.', $errorMessage, $exception->getMessage()), 330 | $context 331 | ); 332 | 333 | abort(self::RESPONSE_HTTP_NOT_FOUND); 334 | } 335 | 336 | return response()->file( 337 | $this->uploadDisk->path($guidedImage->getUrl(true)), 338 | array_merge( 339 | $this->getDefaultHeaders(), 340 | ['X-Guided-Image-Fallback' => true] 341 | ) 342 | ); 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /src/Service/ImageManager.php: -------------------------------------------------------------------------------- 1 | manager = $manager ?? InterventionImageManager::gd(); 23 | } 24 | 25 | /** 26 | * @throws RuntimeException 27 | */ 28 | public function create(int $width, int $height): ImageInterface 29 | { 30 | return $this->manager->create($width, $height); 31 | } 32 | 33 | /** 34 | * @throws RuntimeException 35 | */ 36 | public function read(mixed $input, array|string|DecoderInterface $decoders = []): ImageInterface 37 | { 38 | return $this->manager->read($input, $decoders); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Service/ImageUploader.php: -------------------------------------------------------------------------------- 1 | configProvider = $configProvider; 61 | $this->uploadDisk = $filesystemManager->disk($configProvider->getUploadDiskName()); 62 | $this->fileHelper = $fileHelper; 63 | $this->validationFactory = $validationFactory; 64 | $this->guidedImage = $guidedImage; 65 | $this->logger = $logger; 66 | } 67 | 68 | /** 69 | * {@inheritdoc} 70 | * 71 | * @noinspection StaticInvocationViaThisInspection 72 | */ 73 | public function upload(UploadedFile $imageFile, bool $isUrlUpload = false): Result 74 | { 75 | if (! $this->validate($imageFile, $isUrlUpload)) { 76 | return new Result(false, self::ERROR_INVALID_IMAGE); 77 | } 78 | 79 | $uploadedImage = new UploadedImage($this->fileHelper, $imageFile, $this->getUploadDestination()); 80 | 81 | $existing = $this->guidedImage 82 | ->where(UploadedImage::KEY_NAME, $uploadedImage->getFilename()) 83 | ->where(UploadedImage::KEY_SIZE, $uploadedImage->getSize()) 84 | ->first(); 85 | 86 | if (! empty($existing)) { 87 | $result = new Result(true); 88 | 89 | return $result 90 | ->addMessage(self::MESSAGE_IMAGE_REUSED) 91 | ->setExtra($existing); 92 | } 93 | 94 | try { 95 | $this->uploadDisk->putFileAs( 96 | $uploadedImage->getDestination(), 97 | $uploadedImage->getFile(), 98 | $uploadedImage->getFilename(), 99 | self::UPLOAD_VISIBILITY 100 | ); 101 | 102 | $this->guidedImage->unguard(); 103 | $instance = $this->guidedImage->create($uploadedImage->toArray()); 104 | $this->guidedImage->reguard(); 105 | 106 | $result = new Result(true, '', [], $instance); 107 | } catch (Exception $exception) { 108 | $this->logger->error( 109 | $exception->getMessage(), 110 | [ 111 | 'uploaded image' => $uploadedImage->toArray(), 112 | 'trace' => $exception->getTraceAsString(), 113 | ] 114 | ); 115 | 116 | $result = new Result(false, $exception->getMessage()); 117 | } 118 | 119 | return $result; 120 | } 121 | 122 | /** 123 | * @throws UrlUploadFailed 124 | */ 125 | public function uploadFromUrl(string $url): Result 126 | { 127 | try { 128 | $filenameForbiddenChars = ['?', '&', '%', '=']; 129 | $tempFile = $this->fileHelper->tempName( 130 | $this->fileHelper->getSystemTempDirectory(), 131 | self::TEMP_FILE_PREFIX 132 | ); 133 | $imageContents = $this->fileHelper->getContents($url); 134 | 135 | $this->fileHelper->putContents($tempFile, $imageContents); 136 | 137 | $mimeType = $this->fileHelper->getMimeType($tempFile); 138 | $originalName = sprintf( 139 | '%s.%s', 140 | str_replace($filenameForbiddenChars, '', basename($url)), 141 | $this->fileHelper->mime2Ext($mimeType) 142 | ); 143 | $uploadedFile = $this->fileHelper->createUploadedFile($tempFile, $originalName, $mimeType); 144 | $result = $this->upload($uploadedFile, true); 145 | 146 | $this->fileHelper->unlink($tempFile); 147 | 148 | return $result; 149 | } catch (FileNotFoundException|FileException $exception) { 150 | throw UrlUploadFailed::forUrl($url, $exception); 151 | } 152 | } 153 | 154 | private function validate(UploadedFile $imageFile, bool $isUrlUpload): bool 155 | { 156 | if ($isUrlUpload) { 157 | return $this->validateFileExtension($imageFile); 158 | } 159 | 160 | return $this->validatePostUpload($imageFile); 161 | } 162 | 163 | private function validatePostUpload(UploadedFile $imageFile): bool 164 | { 165 | $validator = $this->validationFactory->make( 166 | [self::KEY_FILE => $imageFile], 167 | [self::KEY_FILE => $this->configProvider->getImageRules()] 168 | ); 169 | 170 | return $this->validateFileExtension($imageFile) && ! $validator->fails(); 171 | } 172 | 173 | private function validateFileExtension(UploadedFile $imageFile): bool 174 | { 175 | return in_array( 176 | strtolower($imageFile->getClientOriginalExtension()), 177 | $this->configProvider->getAllowedExtensions(), 178 | true 179 | ); 180 | } 181 | 182 | private function getUploadDestination(): string 183 | { 184 | $destination = $this->configProvider->getUploadDirectory(); 185 | 186 | if (! $this->configProvider->generateUploadDateSubDirectories()) { 187 | return $destination; 188 | } 189 | 190 | $uploadSubDirectories = date(self::UPLOAD_DATE_SUB_DIRECTORIES_PATTERN); 191 | $destination = sprintf('%s/%s', $destination, $uploadSubDirectories); 192 | 193 | return str_replace('//', '/', $destination); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | handleRoutes(); 59 | $this->handleConfig(); 60 | $this->handleCommands(); 61 | $this->handleMigrations(); 62 | } 63 | 64 | /** 65 | * @throws InvalidArgumentException 66 | */ 67 | public function register(): void 68 | { 69 | $this->configProvider = new ConfigProvider( 70 | new ReliqArtsConfigProvider( 71 | resolve(ConfigRepository::class), 72 | $this->getConfigKey() 73 | ) 74 | ); 75 | 76 | $guidedModelFQCN = $this->configProvider->getGuidedModelNamespace() 77 | .$this->configProvider->getGuidedModelName(); 78 | 79 | $this->app->bind(GuidedImage::class, $guidedModelFQCN); 80 | 81 | $this->app->singleton( 82 | ConfigProviderContract::class, 83 | function (): ConfigProviderContract { 84 | return $this->configProvider; 85 | } 86 | ); 87 | 88 | $this->app->singleton( 89 | ImageManagerContract::class, 90 | ImageManager::class 91 | ); 92 | 93 | $this->app->singleton( 94 | FileHelperContract::class, 95 | FileHelper::class 96 | ); 97 | 98 | $logger = $this->createLogger(); 99 | 100 | $this->app->singleton( 101 | ImageUploaderContract::class, 102 | fn (): ImageUploaderContract => new ImageUploader( 103 | $this->configProvider, 104 | resolve(FilesystemManager::class), 105 | resolve(FileHelperContract::class), 106 | resolve(ValidationFactory::class), 107 | resolve(GuidedImage::class), 108 | $logger, 109 | ) 110 | ); 111 | 112 | $this->app->singleton( 113 | ImageDispenserContract::class, 114 | fn (): ImageDispenserContract => new ImageDispenser( 115 | $this->configProvider, 116 | resolve(FilesystemManager::class), 117 | resolve(ImageManagerContract::class), 118 | $logger, 119 | resolve(FileHelperContract::class) 120 | ) 121 | ); 122 | } 123 | 124 | public function provides(): array 125 | { 126 | return array_merge( 127 | $this->commands, 128 | [ 129 | GuidedImage::class, 130 | ] 131 | ); 132 | } 133 | 134 | protected function handleConfig(): void 135 | { 136 | $configFile = sprintf('%s/config/config.php', $this->getAssetDirectory()); 137 | $configKey = $this->getConfigKey(); 138 | 139 | $this->mergeConfigFrom($configFile, $configKey); 140 | 141 | $this->publishes( 142 | [$configFile => config_path(sprintf('%s.php', $configKey))], 143 | sprintf('%s-config', $configKey) 144 | ); 145 | } 146 | 147 | /** 148 | * Command files. 149 | */ 150 | private function handleCommands(): void 151 | { 152 | if ($this->app->runningInConsole()) { 153 | $this->commands($this->commands); 154 | } 155 | } 156 | 157 | /** 158 | * @throws BindingResolutionException 159 | */ 160 | private function handleRoutes(): void 161 | { 162 | $router = $this->app->make('router'); 163 | $modelName = $this->configProvider->getGuidedModelName(); 164 | 165 | if (! $this->app->routesAreCached()) { 166 | $router->model(strtolower($modelName), $this->configProvider->getGuidedModelNamespace().$modelName); 167 | 168 | require_once sprintf('%s/routes/web.php', $this->getAssetDirectory()); 169 | } 170 | } 171 | 172 | private function handleMigrations(): void 173 | { 174 | $this->loadMigrationsFrom(sprintf('%s/database/migrations', $this->getAssetDirectory())); 175 | } 176 | 177 | /** 178 | * @throws InvalidArgumentException 179 | */ 180 | private function createLogger(): LoggerContract 181 | { 182 | /** @var LoggerFactory $loggerFactory */ 183 | $loggerFactory = resolve(LoggerFactory::class); 184 | 185 | return $loggerFactory->create($this->getLoggerName(), $this->getLogFileBasename()); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /tests/Feature/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reliqarts/laravel-guided-image/3f3d19dd06cd1d9e4c5f800fcf62d5a7380e1071/tests/Feature/.gitkeep -------------------------------------------------------------------------------- /tests/Fixtures/Model/GuidedImage.php: -------------------------------------------------------------------------------- 1 | setBasePath(__DIR__ . '/..'); 20 | 21 | // set app config 22 | $app['config']->set('database.default', 'testing'); 23 | } 24 | 25 | /** 26 | * @param Application $app 27 | * 28 | * @return array 29 | */ 30 | protected function getPackageProviders($app) 31 | { 32 | return [ 33 | ServiceProvider::class, 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Unit/Demand/DummyTest.php: -------------------------------------------------------------------------------- 1 | getColor()); 31 | } 32 | 33 | public static function colorDataProvider(): array 34 | { 35 | return [ 36 | ['0f0', '0f0'], 37 | ['n', Dummy::DEFAULT_COLOR], 38 | ['_', Dummy::DEFAULT_COLOR], 39 | ['false', Dummy::DEFAULT_COLOR], 40 | ['null', Dummy::DEFAULT_COLOR], 41 | [false, Dummy::DEFAULT_COLOR], 42 | [null, Dummy::DEFAULT_COLOR], 43 | ]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Unit/Demand/ExistingImageTest.php: -------------------------------------------------------------------------------- 1 | request->reveal(), 25 | $this->getExistingImageDemand()->getRequest() 26 | ); 27 | } 28 | 29 | /** 30 | * @throws Exception 31 | */ 32 | public function testGetGuidedImage(): void 33 | { 34 | self::assertSame( 35 | $this->guidedImage->reveal(), 36 | $this->getExistingImageDemand() 37 | ->getGuidedImage() 38 | ); 39 | } 40 | 41 | /** 42 | * @throws Exception 43 | */ 44 | private function getExistingImageDemand(): ExistingImage 45 | { 46 | return new Thumbnail( 47 | $this->request->reveal(), 48 | $this->guidedImage->reveal(), 49 | 'crop', 50 | self::DIMENSION, 51 | self::DIMENSION 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/Unit/Demand/ImageTest.php: -------------------------------------------------------------------------------- 1 | getImageDemand($width, self::DIMENSION); 26 | 27 | self::assertSame($expectedResult, $demand->getWidth()); 28 | } 29 | 30 | /** 31 | * @throws Exception 32 | */ 33 | #[DataProvider('widthAndHeightDataProvider')] 34 | public function testGetHeight($height, ?int $expectedResult): void 35 | { 36 | $demand = $this->getImageDemand(self::DIMENSION, $height); 37 | 38 | self::assertSame($expectedResult, $demand->getHeight()); 39 | } 40 | 41 | public static function widthAndHeightDataProvider(): array 42 | { 43 | return [ 44 | [200, 200], 45 | [false, null], 46 | [null, null], 47 | ['null', null], 48 | ['false', null], 49 | ['_', null], 50 | ['n', null], 51 | ['0', null], 52 | ]; 53 | } 54 | 55 | private function getImageDemand($width, $height): Image 56 | { 57 | return new Dummy($width, $height); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/Unit/Demand/ResizeTest.php: -------------------------------------------------------------------------------- 1 | request->reveal(), 28 | $this->guidedImage->reveal(), 29 | $width, 30 | self::DIMENSION 31 | ); 32 | 33 | self::assertSame($expectedResult, $demand->getWidth()); 34 | } 35 | 36 | /** 37 | * @throws Exception 38 | */ 39 | #[DataProvider('resizeDimensionDataProvider')] 40 | public function testGetHeight($height, ?int $expectedResult): void 41 | { 42 | $demand = new Resize( 43 | $this->request->reveal(), 44 | $this->guidedImage->reveal(), 45 | self::DIMENSION, 46 | $height 47 | ); 48 | 49 | self::assertSame($expectedResult, $demand->getHeight()); 50 | } 51 | 52 | /** 53 | * @throws Exception 54 | */ 55 | #[DataProvider('resizeFlagDataProvider')] 56 | public function testMaintainAspectRatio($maintainAspectRatio, bool $expectedResult): void 57 | { 58 | $demand = new Resize( 59 | $this->request->reveal(), 60 | $this->guidedImage->reveal(), 61 | self::DIMENSION, 62 | self::DIMENSION, 63 | $maintainAspectRatio 64 | ); 65 | 66 | self::assertSame($expectedResult, $demand->maintainAspectRatio()); 67 | } 68 | 69 | /** 70 | * @throws Exception 71 | */ 72 | #[DataProvider('resizeFlagDataProvider')] 73 | public function testAllowUpSizing($upSize, bool $expectedResult): void 74 | { 75 | $demand = new Resize( 76 | $this->request->reveal(), 77 | $this->guidedImage->reveal(), 78 | self::DIMENSION, 79 | self::DIMENSION, 80 | true, 81 | $upSize 82 | ); 83 | 84 | self::assertSame($expectedResult, $demand->allowUpSizing()); 85 | } 86 | 87 | /** 88 | * @throws Exception 89 | */ 90 | #[DataProvider('resizeFlagDataProvider')] 91 | public function testReturnObject($returnObject, bool $expectedResult): void 92 | { 93 | $demand = new Resize( 94 | $this->request->reveal(), 95 | $this->guidedImage->reveal(), 96 | self::DIMENSION, 97 | self::DIMENSION, 98 | true, 99 | null, 100 | $returnObject 101 | ); 102 | 103 | self::assertSame($expectedResult, $demand->returnObject()); 104 | } 105 | 106 | public static function resizeDimensionDataProvider(): array 107 | { 108 | return [ 109 | [self::DIMENSION, self::DIMENSION], 110 | ['n', null], 111 | ['_', null], 112 | ['false', null], 113 | ['null', null], 114 | [false, null], 115 | [null, null], 116 | ]; 117 | } 118 | 119 | public static function resizeFlagDataProvider(): array 120 | { 121 | return [ 122 | [true, true], 123 | ['n', false], 124 | ['_', false], 125 | ['false', false], 126 | ['null', false], 127 | [false, false], 128 | [null, false], 129 | ]; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /tests/Unit/Demand/TestCase.php: -------------------------------------------------------------------------------- 1 | request = $this->prophesize(Request::class); 31 | $this->guidedImage = $this->prophesize(GuidedImage::class); 32 | } 33 | 34 | public function nullValueProvider(): array 35 | { 36 | return [ 37 | ['_'], 38 | ['n'], 39 | ['null'], 40 | [false], 41 | [null], 42 | ]; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/Unit/Demand/ThumbnailTest.php: -------------------------------------------------------------------------------- 1 | request->reveal(), 26 | $this->guidedImage->reveal(), 27 | $method, 28 | self::DIMENSION, 29 | self::DIMENSION 30 | ); 31 | 32 | self::assertSame($expectedResult, $demand->isValid()); 33 | } 34 | 35 | public static function isValidDataProvider(): array 36 | { 37 | return [ 38 | ['crop', true], 39 | ['cover', true], 40 | ['fit', true], 41 | ['grab', false], 42 | ['spook', false], 43 | ]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Unit/Service/ImageDispenserTest.php: -------------------------------------------------------------------------------- 1 | cacheDisk = $this->prophesize(FilesystemAdapter::class); 115 | $this->imageManager = $this->prophesize(ImageManager::class); 116 | $this->logger = $this->prophesize(Logger::class); 117 | $this->request = $this->prophesize(Request::class); 118 | $this->guidedImage = $this->prophesize(GuidedImage::class); 119 | $this->cacheResized = self::CACHE_RESIZED_SUB_DIRECTORY; 120 | $this->cacheThumbs = self::CACHE_THUMBS_SUB_DIRECTORY; 121 | 122 | $configProvider = $this->prophesize(ConfigProvider::class); 123 | $fileHelper = $this->prophesize(FileHelper::class); 124 | $filesystemManager = $this->prophesize(FilesystemManager::class); 125 | $uploadDisk = $this->prophesize(FilesystemAdapter::class); 126 | 127 | $configProvider 128 | ->getCacheDiskName() 129 | ->shouldBeCalledTimes(1) 130 | ->willReturn(self::CACHE_DISK_NAME); 131 | $configProvider 132 | ->getUploadDiskName() 133 | ->shouldBeCalledTimes(1) 134 | ->willReturn(self::UPLOAD_DISK_NAME); 135 | $configProvider 136 | ->getResizedCachePath() 137 | ->shouldBeCalledTimes(1) 138 | ->willReturn(self::CACHE_RESIZED_SUB_DIRECTORY); 139 | $configProvider 140 | ->getThumbsCachePath() 141 | ->shouldBeCalledTimes(1) 142 | ->willReturn(self::CACHE_THUMBS_SUB_DIRECTORY); 143 | $configProvider 144 | ->getCacheDaysHeader() 145 | ->willReturn(2); 146 | $configProvider 147 | ->getAdditionalHeaders() 148 | ->willReturn([]); 149 | $configProvider 150 | ->getImageEncodingMimeType() 151 | ->willReturn(self::IMAGE_ENCODING_FORMAT); 152 | $configProvider 153 | ->getImageEncodingQuality() 154 | ->willReturn(self::IMAGE_ENCODING_QUALITY); 155 | $configProvider 156 | ->isRawImageFallbackEnabled() 157 | ->willReturn(false); 158 | 159 | $filesystemManager 160 | ->disk(self::CACHE_DISK_NAME) 161 | ->shouldBeCalledTimes(1) 162 | ->willReturn($this->cacheDisk); 163 | $filesystemManager 164 | ->disk(self::UPLOAD_DISK_NAME) 165 | ->shouldBeCalledTimes(1) 166 | ->willReturn($uploadDisk); 167 | 168 | $this->cacheDisk 169 | ->exists($this->cacheResized) 170 | ->shouldBeCalledTimes(1) 171 | ->willReturn(false); 172 | $this->cacheDisk 173 | ->makeDirectory($this->cacheResized) 174 | ->shouldBeCalledTimes(1) 175 | ->willReturn(true); 176 | $this->cacheDisk 177 | ->exists($this->cacheThumbs) 178 | ->shouldBeCalledTimes(1) 179 | ->willReturn(false); 180 | $this->cacheDisk 181 | ->makeDirectory($this->cacheThumbs) 182 | ->shouldBeCalledTimes(1) 183 | ->willReturn(true); 184 | $this->cacheDisk 185 | ->lastModified(Argument::type('string')) 186 | ->willReturn(self::LAST_MODIFIED); 187 | 188 | $uploadDisk 189 | ->path(self::IMAGE_PATH) 190 | ->willReturn(self::IMAGE_PATH); 191 | 192 | $fileHelper 193 | ->hashFile(Argument::type('string')) 194 | ->willReturn(self::FILE_HASH); 195 | 196 | $this->request 197 | ->header(Argument::cetera()) 198 | ->willReturn(''); 199 | 200 | $this->guidedImage 201 | ->getName() 202 | ->willReturn(self::IMAGE_NAME); 203 | $this->guidedImage 204 | ->getUrl(true) 205 | ->willReturn(self::IMAGE_PATH); 206 | 207 | $this->subject = new ImageDispenser( 208 | $configProvider->reveal(), 209 | $filesystemManager->reveal(), 210 | $this->imageManager->reveal(), 211 | $this->logger->reveal(), 212 | $fileHelper->reveal() 213 | ); 214 | } 215 | 216 | /** 217 | * @throws RuntimeException 218 | */ 219 | public function testEmptyCache(): void 220 | { 221 | $this->cacheDisk 222 | ->deleteDirectory($this->cacheResized) 223 | ->shouldBeCalledTimes(1) 224 | ->willReturn(true); 225 | $this->cacheDisk 226 | ->deleteDirectory($this->cacheThumbs) 227 | ->shouldBeCalledTimes(1) 228 | ->willReturn(true); 229 | 230 | $result = $this->subject->emptyCache(); 231 | 232 | self::assertTrue($result); 233 | } 234 | 235 | /** 236 | * @throws RuntimeException 237 | */ 238 | public function testGetDummyImage(): void 239 | { 240 | $width = self::IMAGE_WIDTH; 241 | $height = self::IMAGE_HEIGHT; 242 | $color = 'fee'; 243 | $image = $this->getImageMock(); 244 | 245 | $this->imageManager 246 | ->create($width, $height) 247 | ->shouldBeCalledTimes(1) 248 | ->willReturn($image); 249 | 250 | $result = $this->subject->getDummyImage( 251 | new Dummy($width, $height, $color) 252 | ); 253 | 254 | self::assertSame($image, $result); 255 | } 256 | 257 | /** 258 | * @throws RuntimeException 259 | * @throws InvalidArgumentException 260 | */ 261 | public function testGetResizedImage(): void 262 | { 263 | $width = self::IMAGE_WIDTH; 264 | $height = self::IMAGE_HEIGHT; 265 | $image = $this->getImageMock(); 266 | $demand = new Resize( 267 | $this->request->reveal(), 268 | $this->guidedImage->reveal(), 269 | $width, 270 | $height 271 | ); 272 | $cacheFile = $this->getCacheFilename($width, $height); 273 | $imageContent = self::IMAGE_CONTENT_RAW; 274 | 275 | $this->cacheDisk 276 | ->exists($cacheFile) 277 | ->shouldBeCalledTimes(1) 278 | ->willReturn(false); 279 | $this->cacheDisk 280 | ->path($cacheFile) 281 | ->shouldBeCalledTimes(2) 282 | ->willReturn($cacheFile); 283 | $this->cacheDisk 284 | ->get($cacheFile) 285 | ->shouldBeCalledTimes(1) 286 | ->willReturn($imageContent); 287 | 288 | $this->imageManager 289 | ->read($cacheFile) 290 | ->shouldNotBeCalled(); 291 | $this->imageManager 292 | ->read(Argument::in([self::IMAGE_PATH, self::FOO_RESOURCE])) 293 | ->shouldBeCalledTimes(2) 294 | ->willReturn($image); 295 | 296 | $result = $this->subject->getResizedImage($demand); 297 | 298 | self::assertInstanceOf(Response::class, $result); 299 | self::assertSame(self::RESPONSE_HTTP_OK, $result->getStatusCode()); 300 | self::assertSame($imageContent, $result->getOriginalContent()); 301 | } 302 | 303 | /** 304 | * @throws RuntimeException|InvalidArgumentException 305 | */ 306 | public function testGetResizedImageWhenImageInstanceIsExpected(): void 307 | { 308 | $width = self::IMAGE_WIDTH; 309 | $height = self::IMAGE_HEIGHT; 310 | $image = $this->getImageMock(); 311 | $demand = new Resize( 312 | $this->request->reveal(), 313 | $this->guidedImage->reveal(), 314 | $width, 315 | $height, 316 | returnObject: true 317 | ); 318 | $cacheFile = $this->getCacheFilename($width, $height); 319 | 320 | $this->cacheDisk 321 | ->exists($cacheFile) 322 | ->shouldBeCalledTimes(1) 323 | ->willReturn(false); 324 | $this->cacheDisk 325 | ->path($cacheFile) 326 | ->shouldBeCalledTimes(1) 327 | ->willReturn($cacheFile); 328 | $this->cacheDisk 329 | ->get($cacheFile) 330 | ->shouldNotBeCalled(); 331 | 332 | $this->imageManager 333 | ->read($cacheFile) 334 | ->shouldNotBeCalled(); 335 | $this->imageManager 336 | ->read(Argument::in([self::IMAGE_PATH, self::FOO_RESOURCE])) 337 | ->shouldBeCalledTimes(2) 338 | ->willReturn($image); 339 | 340 | $result = $this->subject->getResizedImage($demand); 341 | 342 | self::assertSame($image, $result); 343 | } 344 | 345 | /** 346 | * @throws Exception 347 | */ 348 | public function testGetResizedImageWhenCacheFileExists(): void 349 | { 350 | $width = self::IMAGE_WIDTH; 351 | $height = self::IMAGE_HEIGHT; 352 | $image = $this->getImageMock(); 353 | $cacheFile = $this->getCacheFilename($width, $height); 354 | 355 | $this->cacheDisk 356 | ->exists($cacheFile) 357 | ->shouldBeCalledTimes(1) 358 | ->willReturn(true); 359 | $this->cacheDisk 360 | ->path($cacheFile) 361 | ->shouldBeCalledTimes(2) 362 | ->willReturn($cacheFile); 363 | $this->cacheDisk 364 | ->get($cacheFile) 365 | ->shouldBeCalledTimes(1) 366 | ->willReturn(self::IMAGE_CONTENT_RAW); 367 | 368 | $this->imageManager 369 | ->read(Argument::in([$cacheFile, self::FOO_RESOURCE])) 370 | ->shouldBeCalledTimes(2) 371 | ->willReturn($image); 372 | $this->imageManager 373 | ->read(self::IMAGE_PATH) 374 | ->shouldNotBeCalled(); 375 | 376 | $result = $this->subject->getResizedImage( 377 | new Resize( 378 | $this->request->reveal(), 379 | $this->guidedImage->reveal(), 380 | $width, 381 | $height 382 | ) 383 | ); 384 | 385 | self::assertInstanceOf(Response::class, $result); 386 | self::assertSame(self::RESPONSE_HTTP_OK, $result->getStatusCode()); 387 | self::assertSame(self::IMAGE_CONTENT_RAW, $result->getOriginalContent()); 388 | } 389 | 390 | /** 391 | * @throws Exception 392 | */ 393 | public function testGetResizedWhenImageRetrievalFails(): void 394 | { 395 | $width = self::IMAGE_WIDTH; 396 | $height = self::IMAGE_HEIGHT; 397 | $image = $this->getImageMock(); 398 | $demand = new Resize( 399 | $this->request->reveal(), 400 | $this->guidedImage->reveal(), 401 | $width, 402 | $height 403 | ); 404 | $cacheFile = $this->getCacheFilename($width, $height); 405 | 406 | $this->cacheDisk 407 | ->exists($cacheFile) 408 | ->shouldBeCalledTimes(1) 409 | ->willReturn(false); 410 | $this->cacheDisk 411 | ->path($cacheFile) 412 | ->shouldBeCalledTimes(1) 413 | ->willReturn($cacheFile); 414 | $this->cacheDisk 415 | ->get($cacheFile) 416 | ->shouldBeCalledTimes(1) 417 | ->willThrow(FileNotFoundException::class); 418 | 419 | $this->imageManager 420 | ->read($cacheFile) 421 | ->shouldNotBeCalled(); 422 | $this->imageManager 423 | ->read(Argument::in([self::IMAGE_PATH, self::FOO_RESOURCE])) 424 | ->shouldBeCalledTimes(2) 425 | ->willReturn($image); 426 | 427 | $this->guidedImage 428 | ->getUrl() 429 | ->shouldBeCalledTimes(1) 430 | ->willReturn(self::IMAGE_PATH); 431 | 432 | $this->logger 433 | ->error( 434 | Argument::containingString('Exception'), 435 | Argument::type('array') 436 | ) 437 | ->shouldBeCalledTimes(1); 438 | 439 | $this->expectException(NotFoundHttpException::class); 440 | 441 | $this->subject->getResizedImage($demand); 442 | } 443 | 444 | /** 445 | * @throws Exception 446 | */ 447 | public function testGetImageThumbnail(): void 448 | { 449 | $width = self::IMAGE_WIDTH; 450 | $height = self::IMAGE_HEIGHT; 451 | $image = $this->getImageMock(); 452 | $demand = new Thumbnail( 453 | $this->request->reveal(), 454 | $this->guidedImage->reveal(), 455 | self::THUMBNAIL_METHOD_CROP, 456 | $width, 457 | $height 458 | ); 459 | $cacheFile = sprintf( 460 | self::CACHE_FILE_FORMAT_THUMBNAIL, 461 | $this->cacheThumbs, 462 | $width, 463 | $height, 464 | $demand->getMethod(), 465 | self::IMAGE_NAME 466 | ); 467 | $imageContent = self::IMAGE_CONTENT_RAW; 468 | 469 | $this->cacheDisk 470 | ->exists($cacheFile) 471 | ->shouldBeCalledTimes(1) 472 | ->willReturn(false); 473 | $this->cacheDisk 474 | ->get($cacheFile) 475 | ->shouldBeCalledTimes(1) 476 | ->willReturn($imageContent); 477 | $this->cacheDisk 478 | ->path($cacheFile) 479 | ->shouldBeCalledTimes(2) 480 | ->willReturn($cacheFile); 481 | 482 | $this->imageManager 483 | ->read($cacheFile) 484 | ->shouldNotBeCalled(); 485 | $this->imageManager 486 | ->read(self::IMAGE_PATH) 487 | ->shouldBeCalledTimes(1) 488 | ->willReturn($image); 489 | 490 | $result = $this->subject->getImageThumbnail($demand); 491 | 492 | self::assertInstanceOf(Response::class, $result); 493 | self::assertSame(self::RESPONSE_HTTP_OK, $result->getStatusCode()); 494 | self::assertSame($imageContent, $result->getOriginalContent()); 495 | } 496 | 497 | /** 498 | * @throws Exception 499 | */ 500 | public function testGetImageThumbnailWhenImageInstanceIsExpected(): void 501 | { 502 | $width = self::IMAGE_WIDTH; 503 | $height = self::IMAGE_HEIGHT; 504 | $image = $this->getImageMock(); 505 | $cacheFile = sprintf( 506 | self::CACHE_FILE_FORMAT_THUMBNAIL, 507 | $this->cacheThumbs, 508 | $width, 509 | $height, 510 | self::THUMBNAIL_METHOD_CROP, 511 | self::IMAGE_NAME 512 | ); 513 | 514 | $this->cacheDisk 515 | ->exists($cacheFile) 516 | ->shouldBeCalledTimes(1) 517 | ->willReturn(false); 518 | $this->cacheDisk 519 | ->path($cacheFile) 520 | ->shouldBeCalledTimes(1) 521 | ->willReturn($cacheFile); 522 | $this->cacheDisk 523 | ->get($cacheFile) 524 | ->shouldNotBeCalled(); 525 | 526 | $this->imageManager 527 | ->read($cacheFile) 528 | ->shouldNotBeCalled(); 529 | $this->imageManager 530 | ->read(Argument::in([self::IMAGE_PATH, self::FOO_RESOURCE])) 531 | ->shouldBeCalledTimes(1) 532 | ->willReturn($image); 533 | 534 | $result = $this->subject->getImageThumbnail( 535 | new Thumbnail( 536 | $this->request->reveal(), 537 | $this->guidedImage->reveal(), 538 | self::THUMBNAIL_METHOD_CROP, 539 | $width, 540 | $height, 541 | true 542 | ) 543 | ); 544 | 545 | self::assertSame($image, $result); 546 | } 547 | 548 | /** 549 | * @throws Exception 550 | */ 551 | public function testGetImageThumbnailWhenCacheFileExists(): void 552 | { 553 | $width = self::IMAGE_WIDTH; 554 | $height = self::IMAGE_HEIGHT; 555 | $image = $this->getImageMock(); 556 | $cacheFile = sprintf( 557 | self::CACHE_FILE_FORMAT_THUMBNAIL, 558 | $this->cacheThumbs, 559 | $width, 560 | $height, 561 | self::THUMBNAIL_METHOD_CROP, 562 | self::IMAGE_NAME 563 | ); 564 | $imageContent = self::IMAGE_CONTENT_RAW; 565 | 566 | $this->cacheDisk 567 | ->exists($cacheFile) 568 | ->shouldBeCalledTimes(1) 569 | ->willReturn(true); 570 | $this->cacheDisk 571 | ->path($cacheFile) 572 | ->shouldBeCalledTimes(2) 573 | ->willReturn($cacheFile); 574 | $this->cacheDisk 575 | ->get($cacheFile) 576 | ->shouldBeCalledTimes(1) 577 | ->willReturn($imageContent); 578 | 579 | $this->imageManager 580 | ->read(Argument::in([$cacheFile, self::FOO_RESOURCE])) 581 | ->shouldBeCalledTimes(2) 582 | ->willReturn($image); 583 | $this->imageManager 584 | ->read(self::IMAGE_PATH) 585 | ->shouldNotBeCalled(); 586 | 587 | $result = $this->subject->getImageThumbnail( 588 | new Thumbnail( 589 | $this->request->reveal(), 590 | $this->guidedImage->reveal(), 591 | self::THUMBNAIL_METHOD_CROP, 592 | $width, 593 | $height 594 | ) 595 | ); 596 | 597 | self::assertInstanceOf(Response::class, $result); 598 | self::assertSame(self::RESPONSE_HTTP_OK, $result->getStatusCode()); 599 | self::assertSame($imageContent, $result->getOriginalContent()); 600 | } 601 | 602 | /** 603 | * @throws Exception 604 | */ 605 | public function testGetImageThumbnailWhenDemandIsInvalid(): void 606 | { 607 | $width = self::IMAGE_WIDTH; 608 | $height = self::IMAGE_HEIGHT; 609 | $demand = new Thumbnail( 610 | $this->request->reveal(), 611 | $this->guidedImage->reveal(), 612 | 'invalid', 613 | $width, 614 | $height 615 | ); 616 | $cacheFile = sprintf( 617 | self::CACHE_FILE_FORMAT_THUMBNAIL, 618 | $this->cacheThumbs, 619 | $width, 620 | $height, 621 | $demand->getMethod(), 622 | self::IMAGE_NAME 623 | ); 624 | 625 | $this->cacheDisk 626 | ->exists($cacheFile) 627 | ->shouldNotBeCalled(); 628 | $this->cacheDisk 629 | ->path($cacheFile) 630 | ->shouldNotBeCalled(); 631 | $this->cacheDisk 632 | ->get($cacheFile) 633 | ->shouldNotBeCalled(); 634 | 635 | $this->imageManager 636 | ->read($cacheFile) 637 | ->shouldNotBeCalled(); 638 | $this->imageManager 639 | ->read(self::IMAGE_PATH) 640 | ->shouldNotBeCalled(); 641 | 642 | $this->logger 643 | ->warning( 644 | Argument::containingString('Invalid'), 645 | [ 646 | 'method' => $demand->getMethod(), 647 | ] 648 | ) 649 | ->shouldBeCalledTimes(1); 650 | 651 | $this->expectException(NotFoundHttpException::class); 652 | 653 | $this->subject->getImageThumbnail($demand); 654 | } 655 | 656 | /** 657 | * @throws Exception 658 | */ 659 | public function testGetImageThumbnailWhenImageRetrievalFails(): void 660 | { 661 | $width = self::IMAGE_WIDTH; 662 | $height = self::IMAGE_HEIGHT; 663 | $demand = new Thumbnail( 664 | $this->request->reveal(), 665 | $this->guidedImage->reveal(), 666 | self::THUMBNAIL_METHOD_COVER, 667 | $width, 668 | $height 669 | ); 670 | $cacheFile = sprintf( 671 | self::CACHE_FILE_FORMAT_THUMBNAIL, 672 | $this->cacheThumbs, 673 | $width, 674 | $height, 675 | $demand->getMethod(), 676 | self::IMAGE_NAME 677 | ); 678 | 679 | $this->cacheDisk 680 | ->exists($cacheFile) 681 | ->shouldBeCalledTimes(1) 682 | ->willReturn(false); 683 | $this->cacheDisk 684 | ->path($cacheFile) 685 | ->shouldNotBeCalled(); 686 | $this->cacheDisk 687 | ->get($cacheFile) 688 | ->shouldNotBeCalled(); 689 | 690 | $this->imageManager 691 | ->read($cacheFile) 692 | ->shouldNotBeCalled(); 693 | $this->imageManager 694 | ->read(self::IMAGE_PATH) 695 | ->shouldBeCalledTimes(1) 696 | ->willThrow(RuntimeException::class); 697 | 698 | $this->logger 699 | ->error( 700 | Argument::containingString('Exception'), 701 | Argument::type('array') 702 | ) 703 | ->shouldBeCalledTimes(1); 704 | 705 | $this->expectException(NotFoundHttpException::class); 706 | 707 | $this->subject->getImageThumbnail($demand); 708 | } 709 | 710 | private function getImageMock(): ImageInterface|MockInterface 711 | { 712 | /** @var Origin|MockInterface $imageOrigin */ 713 | $imageOrigin = Mockery::mock(Origin::class); 714 | $imageOrigin->shouldReceive('mediaType') 715 | ->andReturn(self::IMAGE_MEDIA_TYPE); 716 | $imageOrigin->shouldReceive('filePath') 717 | ->andReturn(self::IMAGE_FILE_PATH); 718 | 719 | $imageMethods = [ 720 | 'fill', 721 | 'save', 722 | 'resize', 723 | 'resizeDown', 724 | 'scale', 725 | 'scaleDown', 726 | self::THUMBNAIL_METHOD_CROP, 727 | self::THUMBNAIL_METHOD_COVER, 728 | ]; 729 | /** @var ImageInterface|MockInterface $image */ 730 | $image = Mockery::mock(ImageInterface::class); 731 | $image->dirname = 'directory'; 732 | $image->basename = 'basename'; 733 | $image->shouldReceive(...$imageMethods) 734 | ->andReturn($image); 735 | $image->shouldReceive('origin') 736 | ->andReturn($imageOrigin); 737 | 738 | /** @var EncodedImageInterface|MockInterface $encodedImage */ 739 | $encodedImage = Mockery::mock(EncodedImageInterface::class); 740 | $encodedImage->shouldReceive('toFilePointer') 741 | ->andReturn(self::FOO_RESOURCE); 742 | $image->shouldReceive('encode') 743 | ->andReturn($encodedImage); 744 | 745 | return $image; 746 | } 747 | 748 | private function getCacheFilename(int $width, int $height): string 749 | { 750 | return sprintf( 751 | self::CACHE_FILE_NAME_FORMAT_RESIZED, 752 | $this->cacheResized, 753 | $width, 754 | $height, 755 | 1, 756 | 0, 757 | self::IMAGE_NAME 758 | ); 759 | } 760 | } 761 | -------------------------------------------------------------------------------- /tests/Unit/Service/ImageUploaderTest.php: -------------------------------------------------------------------------------- 1 | configProvider = $this->prophesize(ConfigProvider::class); 81 | $filesystemManager = $this->prophesize(FilesystemManager::class); 82 | $this->fileHelper = $this->prophesize(FileHelper::class); 83 | $this->validationFactory = $this->prophesize(ValidationFactory::class); 84 | $this->validator = $this->prophesize(Validator::class); 85 | $this->guidedImage = $this->prophesize(GuidedImage::class); 86 | $this->logger = $this->prophesize(Logger::class); 87 | $this->builder = $this->prophesize(Builder::class); 88 | $this->uploadedFile = $this->getUploadedFileMock(); 89 | $this->uploadDisk = $this->prophesize(FilesystemAdapter::class); 90 | 91 | $this->configProvider 92 | ->getAllowedExtensions() 93 | ->shouldBeCalledTimes(1) 94 | ->willReturn(self::ALLOWED_EXTENSIONS); 95 | $this->configProvider 96 | ->getImageRules() 97 | ->shouldBeCalledTimes(1) 98 | ->willReturn(self::IMAGE_RULES); 99 | $this->configProvider 100 | ->getUploadDirectory() 101 | ->shouldBeCalledTimes(1) 102 | ->willReturn(self::UPLOAD_DIRECTORY); 103 | $this->configProvider 104 | ->getUploadDiskName() 105 | ->shouldBeCalledTimes(1) 106 | ->willReturn(self::UPLOAD_DISK_NAME); 107 | $this->configProvider 108 | ->generateUploadDateSubDirectories() 109 | ->shouldBeCalledTimes(1) 110 | ->willReturn(1); 111 | 112 | $filesystemManager 113 | ->disk(self::UPLOAD_DISK_NAME) 114 | ->shouldBeCalledTimes(1) 115 | ->willReturn($this->uploadDisk); 116 | 117 | $this->validationFactory 118 | ->make(Argument::cetera()) 119 | ->shouldBeCalledTimes(1) 120 | ->willReturn($this->validator); 121 | $this->validator 122 | ->fails() 123 | ->shouldBeCalledTimes(1) 124 | ->willReturn(false); 125 | 126 | $this->guidedImage 127 | ->where(Argument::cetera()) 128 | ->shouldBeCalledTimes(1) 129 | ->willReturn($this->builder); 130 | $this->guidedImage 131 | ->unguard() 132 | ->shouldBeCalledTimes(1); 133 | $this->guidedImage 134 | ->reguard() 135 | ->shouldBeCalledTimes(1); 136 | 137 | $this->builder 138 | ->where(Argument::cetera()) 139 | ->shouldBeCalledTimes(1) 140 | ->willReturn($this->builder); 141 | $this->builder 142 | ->first() 143 | ->shouldBeCalledTimes(1) 144 | ->willReturn(null); 145 | 146 | $this->logger 147 | ->error(Argument::cetera()) 148 | ->shouldNotBeCalled(); 149 | 150 | $this->fileHelper 151 | ->getImageSize(Argument::type('string')) 152 | ->willReturn(self::UPLOADED_IMAGE_SIZE); 153 | 154 | $this->subject = new ImageUploader( 155 | $this->configProvider->reveal(), 156 | $filesystemManager->reveal(), 157 | $this->fileHelper->reveal(), 158 | $this->validationFactory->reveal(), 159 | $this->guidedImage->reveal(), 160 | $this->logger->reveal() 161 | ); 162 | } 163 | 164 | /** 165 | * @throws Exception 166 | */ 167 | public function testUpload(): void 168 | { 169 | $this->guidedImage 170 | ->create( 171 | Argument::that( 172 | function ($argument) { 173 | return in_array($this->uploadedFile->getFilename(), $argument, true); 174 | } 175 | ) 176 | ) 177 | ->shouldBeCalledTimes(1); 178 | 179 | $this->uploadDisk 180 | ->putFileAs(Argument::cetera()) 181 | ->shouldBeCalled(); 182 | 183 | $result = $this->subject->upload($this->uploadedFile); 184 | 185 | self::assertInstanceOf(Result::class, $result); 186 | self::assertTrue($result->isSuccess()); 187 | } 188 | 189 | /** 190 | * @throws Exception 191 | */ 192 | public function testUploadWhenFileShouldBeReused(): void 193 | { 194 | $existingGuidedImage = $this->prophesize(GuidedImage::class) 195 | ->reveal(); 196 | 197 | $this->guidedImage 198 | ->unguard() 199 | ->shouldNotBeCalled(); 200 | $this->guidedImage 201 | ->create(Argument::cetera()) 202 | ->shouldNotBeCalled(); 203 | $this->guidedImage 204 | ->reguard() 205 | ->shouldNotBeCalled(); 206 | 207 | $this->builder 208 | ->first() 209 | ->shouldBeCalledTimes(1) 210 | ->willReturn($existingGuidedImage); 211 | 212 | $this->uploadDisk 213 | ->putFileAs(Argument::cetera()) 214 | ->shouldNotBeCalled(); 215 | 216 | $this->logger 217 | ->error(Argument::cetera()) 218 | ->shouldNotBeCalled(); 219 | 220 | $result = $this->subject->upload($this->uploadedFile); 221 | 222 | self::assertInstanceOf(Result::class, $result); 223 | self::assertSame($existingGuidedImage, $result->getExtra()); 224 | self::assertStringContainsStringIgnoringCase('reused', $result->getMessage()); 225 | self::assertTrue($result->isSuccess()); 226 | } 227 | 228 | /** 229 | * @throws Exception 230 | */ 231 | public function testUploadWhenValidationFails(): void 232 | { 233 | $this->configProvider 234 | ->getUploadDirectory() 235 | ->shouldNotBeCalled(); 236 | $this->configProvider 237 | ->generateUploadDateSubDirectories() 238 | ->shouldNotBeCalled(); 239 | 240 | $this->validator 241 | ->fails() 242 | ->shouldBeCalledTimes(1) 243 | ->willReturn(true); 244 | 245 | $this->guidedImage 246 | ->where(Argument::cetera()) 247 | ->shouldNotBeCalled(); 248 | $this->guidedImage 249 | ->unguard() 250 | ->shouldNotBeCalled(); 251 | $this->guidedImage 252 | ->create(Argument::cetera()) 253 | ->shouldNotBeCalled(); 254 | $this->guidedImage 255 | ->reguard() 256 | ->shouldNotBeCalled(); 257 | 258 | $this->builder 259 | ->where(Argument::cetera()) 260 | ->shouldNotBeCalled(); 261 | $this->builder 262 | ->first() 263 | ->shouldNotBeCalled(); 264 | 265 | $this->uploadDisk 266 | ->putFileAs(Argument::cetera()) 267 | ->shouldNotBeCalled(); 268 | 269 | $this->logger 270 | ->error(Argument::cetera()) 271 | ->shouldNotBeCalled(); 272 | 273 | $result = $this->subject->upload($this->uploadedFile); 274 | 275 | self::assertInstanceOf(Result::class, $result); 276 | self::assertFalse($result->isSuccess()); 277 | self::assertStringContainsStringIgnoringCase('invalid', $result->getError()); 278 | } 279 | 280 | /** 281 | * @throws Exception 282 | */ 283 | public function testUploadWhenFileUploadFails(): void 284 | { 285 | $this->guidedImage 286 | ->unguard() 287 | ->shouldNotBeCalled(); 288 | $this->guidedImage 289 | ->create(Argument::cetera()) 290 | ->shouldNotBeCalled(); 291 | $this->guidedImage 292 | ->reguard() 293 | ->shouldNotBeCalled(); 294 | 295 | $this->uploadDisk 296 | ->putFileAs(Argument::cetera()) 297 | ->shouldBeCalled() 298 | ->willThrow(Exception::class); 299 | 300 | $this->logger 301 | ->error(Argument::cetera()) 302 | ->shouldBeCalledTimes(1); 303 | 304 | $result = $this->subject->upload($this->uploadedFile); 305 | 306 | self::assertInstanceOf(Result::class, $result); 307 | self::assertFalse($result->isSuccess()); 308 | } 309 | 310 | /** 311 | * @throws Exception 312 | */ 313 | public function testUploadFromUrl(): void 314 | { 315 | $url = '//url'; 316 | $imageContent = 'foo'; 317 | $tempName = 'tmp.name'; 318 | $systemTempDir = 'sys.temp'; 319 | $mimeType = 'img/jpeg'; 320 | 321 | $this->fileHelper 322 | ->getSystemTempDirectory() 323 | ->shouldBeCalledTimes(1) 324 | ->willReturn($systemTempDir); 325 | $this->fileHelper 326 | ->tempName($systemTempDir, self::TEMP_FILE_PREFIX) 327 | ->shouldBeCalledTimes(1) 328 | ->willReturn($tempName); 329 | $this->fileHelper 330 | ->getContents($url) 331 | ->shouldBeCalledTimes(1) 332 | ->willReturn($imageContent); 333 | $this->fileHelper 334 | ->putContents($tempName, $imageContent) 335 | ->shouldBeCalledTimes(1) 336 | ->willReturn(1234); 337 | $this->fileHelper 338 | ->getMimeType($tempName) 339 | ->shouldBeCalledTimes(1) 340 | ->willReturn($mimeType); 341 | $this->fileHelper 342 | ->mime2Ext($mimeType) 343 | ->shouldBeCalledTimes(1) 344 | ->willReturn('jpeg'); 345 | $this->fileHelper 346 | ->createUploadedFile($tempName, Argument::type('string'), $mimeType) 347 | ->shouldBeCalledTimes(1) 348 | ->willReturn($this->uploadedFile); 349 | $this->fileHelper 350 | ->unlink($tempName) 351 | ->shouldBeCalledTimes(1) 352 | ->willReturn(true); 353 | 354 | $this->configProvider 355 | ->getImageRules() 356 | ->shouldNotBeCalled(); 357 | 358 | $this->validationFactory 359 | ->make(Argument::cetera()) 360 | ->shouldNotBeCalled(); 361 | 362 | $this->validator 363 | ->fails() 364 | ->shouldNotBeCalled(); 365 | 366 | $this->guidedImage 367 | ->create( 368 | Argument::that( 369 | function ($argument) { 370 | return in_array($this->uploadedFile->getFilename(), $argument, true); 371 | } 372 | ) 373 | ) 374 | ->shouldBeCalledTimes(1); 375 | 376 | $this->uploadDisk 377 | ->putFileAs(Argument::cetera()) 378 | ->shouldBeCalled(); 379 | 380 | $result = $this->subject->uploadFromUrl($url); 381 | 382 | self::assertInstanceOf(Result::class, $result); 383 | self::assertTrue($result->isSuccess()); 384 | } 385 | 386 | private function getUploadedFileMock(): UploadedFile 387 | { 388 | $filename = 'my-image'; 389 | 390 | return Mockery::mock( 391 | UploadedFile::class, 392 | [ 393 | 'getFilename' => $filename, 394 | 'getClientOriginalName' => $filename, 395 | 'getClientOriginalExtension' => 'jpg', 396 | 'getMimeType' => 'image/jpeg', 397 | 'getSize' => 80000, 398 | 'getRealPath' => $filename, 399 | 'move' => null, 400 | ] 401 | ); 402 | } 403 | } 404 | -------------------------------------------------------------------------------- /tests/Unit/TestCase.php: -------------------------------------------------------------------------------- 1 | setGroups(array_merge($this->groups(), [self::GROUP])); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 |