├── .github └── workflows │ └── tests.yml ├── .gitignore ├── LICENCE.txt ├── composer.json ├── phpunit.xml ├── readme-ru.md ├── readme.md ├── src ├── FileCleaner.php ├── FileCleanerServiceProvider.php └── file-cleaner.php └── tests ├── Database ├── Models │ ├── File.php │ ├── TestCollection.php │ └── TestOne.php └── migrations │ ├── 2017_09_08_112931_create_files_table.php │ ├── 2017_09_08_112932_create_test_collection_table.php │ └── 2017_09_08_112932_create_test_one_table.php ├── FileCleanerTest.php ├── TestCase.php └── TestKernel.php /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | fail-fast: true 15 | matrix: 16 | php: [ 7.2, 7.3, 7.4, 8.0, 8.1 ] 17 | stability: [ prefer-lowest, prefer-stable ] 18 | exclude: 19 | - php: 8.1 20 | stability: prefer-lowest 21 | 22 | name: PHP ${{ matrix.php }} - ${{ matrix.stability }} 23 | 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v2 27 | 28 | - name: Setup PHP 29 | uses: shivammathur/setup-php@v2 30 | with: 31 | php-version: ${{ matrix.php }} 32 | extensions: dom, curl, libxml, mbstring, zip 33 | coverage: none 34 | 35 | - name: Install dependencies 36 | run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress 37 | 38 | - name: Bump mockery for PHP 8 39 | if: ${{ matrix.php == 8.0 }} 40 | run: | 41 | composer require mockery/mockery:^1.4.2 --dev --prefer-dist --no-interaction --no-progress --update-with-all-dependencies 42 | 43 | - name: Execute tests 44 | run: vendor/bin/phpunit --verbose 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | .phpunit.result.cache 4 | -------------------------------------------------------------------------------- /LICENCE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Ihoshyn Roman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "masterro/laravel-file-cleaner", 3 | "description": "Laravel console command for deleting temp files and associated model instances.", 4 | "license": "MIT", 5 | "keywords": [ 6 | "laravel", 7 | "files", 8 | "delete", 9 | "clean" 10 | ], 11 | "authors": [ 12 | { 13 | "name": "Ihoshyn Roman", 14 | "email": "igoshin18@gmail.com" 15 | } 16 | ], 17 | "require": { 18 | "php": ">=7.2.5", 19 | "illuminate/support": "^6.0|^7.0|^8.0", 20 | "illuminate/filesystem": "^6.0|^7.0|^8.0", 21 | "illuminate/console": "^6.0|^7.0|^8.0", 22 | "illuminate/database": "^6.0|^7.0|^8.0", 23 | "nesbot/carbon": "^1.21|^2.1" 24 | }, 25 | "require-dev": { 26 | "orchestra/testbench": "^v5.0|^v6.0" 27 | }, 28 | "suggest": { 29 | "masterro/laravel-fresh-start": "v1.0.0", 30 | "masterro/laravel-flashes": "v0.1.0" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "MasterRO\\LaravelFileCleaner\\": "src/", 35 | "MasterRO\\LaravelFileCleaner\\Tests\\": "tests/" 36 | }, 37 | "classmap": [ 38 | "tests/Database/migrations" 39 | ] 40 | }, 41 | "extra": { 42 | "laravel": { 43 | "providers": [ 44 | "MasterRO\\LaravelFileCleaner\\FileCleanerServiceProvider" 45 | ] 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | ./tests/ 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /readme-ru.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

6 | 7 | Latest Stable Version 8 | 9 | 10 | Total Downloads 11 | 12 | 13 | Build Status 14 | 15 | 16 | License 17 | 18 |

19 | 20 | # LaravelFileCleaner 21 | 22 | LaravelFileCleaner - это пакет для Laravel 5, который позвлоляет удалять временные файлы и связанные с ними сущности модели (при необходимости). 23 | 24 | ## Установка 25 | 26 | ### ШАГ 1: Composer 27 | 28 | В коммандной строке: 29 | 30 | ``` 31 | composer require masterro/laravel-file-cleaner 32 | ``` 33 | 34 | ### ШАГ 2: Service Provider (Для версии Laravel < 5.5) 35 | 36 | Откройте файл `config/app.php` и добавьте в массив `providers` : 37 | 38 | ``` 39 | MasterRO\LaravelFileCleaner\FileCleanerServiceProvider::class 40 | ``` 41 | 42 | Таким образом мы подключим пакет в автозагрузку Laravel. 43 | 44 | ### ШАГ 3: Конфигурация 45 | 46 | Для начала в коммандной строке пишем: 47 | 48 | ``` 49 | php artisan vendor:publish --provider="MasterRO\LaravelFileCleaner\FileCleanerServiceProvider" 50 | ``` 51 | 52 | После чего в директории `config` появится файл `file-cleaner.php` 53 | 54 | Для текущей версии пакета доступны слледующие настройки: 55 | * Массив путей к папкам, где храняться (или будут хранится) файлы для удаления | пути относительно корневого каталога. 56 | * Массив путей к папкам, файлы и одпапки которых не будут удалены | пути относительно корневого каталога. 57 | * Массив путей к файлам, которые не будут удалены | пути относительно корневого каталога. 58 | * Время, после которого файлы будут удалены | _по умолчанию **60** минут_ 59 | * Модель, сущности которой будут удалены вместе с привязанными файлами | _не обязательно_ 60 | * Имя поля в таблице модели, которое хранит имя привязанного файла | _не обязательно, **работает только если указана модель**_ 61 | * Флаг указывающий на то удалять или не удалять пустые папки после удаления файлов | _по умолчанию **true**_ 62 | * Релейшн, если указан то файлы и сущности будут удалены только в случае если связанной сущности нет 63 | 64 | ## Использование 65 | 66 | ### Scheduling 67 | 68 | Добавьте вызов команды в фукцию `schedule`: 69 | > [Документация по Task Scheduling](https://laravel.com/docs/scheduling), если есть вопросы. 70 | 71 | ```php 72 | protected function schedule(Schedule $schedule) 73 | { 74 | $schedule->command('file-cleaner:clean')->everyMinute(); 75 | } 76 | ``` 77 | 78 | И это все что нужно для работы пакета. Если вы настроили крон правильновсе будет работать. 79 | 80 | 81 | ### Вручную, используя Artisan Console 82 | 83 | Вы можете запустить удаление вручную прописав в консоли: 84 | ``` 85 | php artisan file-cleaner:clean 86 | ``` 87 | Удаляться только те файлы, которые храняться больше указанного в настройках времени. 88 | 89 | 90 | Или если нужно удалить все файлы без проверки на время (просто удалить все файлы из указанных директорий): 91 | ``` 92 | php artisan file-cleaner:clean -f 93 | ``` 94 | 95 | Вы даже можете переопределить значения конфига `paths`, `excluded_paths` и `excluded_files` используя `--directories`, `--excluded-paths` and `--excluded-files` options (разделяя запятой): 96 | ``` 97 | php artisan file-cleaner:clean -f --directories=storage/temp/images,public/uploads/test 98 | ``` 99 | ``` 100 | php artisan file-cleaner:clean -f --excluded-paths=public/uploads/images/default,public/uploads/test 101 | ``` 102 | ``` 103 | php artisan file-cleaner:clean -f --excluded-files=public/uploads/images/default.png,public/uploads/test/01.png 104 | ``` 105 | 106 | Также можно переопределить значени конфига `remove_directories` используя `--remove-directories` option: 107 | ``` 108 | php artisan file-cleaner:clean -f --directories=storage/temp/images,public/uploads/test --remove-directories=false 109 | ``` 110 | 111 | #### _Буду признателен за звезды :)_ 112 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

6 | 7 | Latest Stable Version 8 | 9 | 10 | Total Downloads 11 | 12 | 13 | Build Status 14 | 15 | 16 | License 17 | 18 |

19 | 20 |

21 | 22 | StandWithUkraine 23 | 24 |

25 | 26 | # File Cleaner for Laravel 27 | 28 | File Cleaner is a package for Laravel 5+ that provides deleting files and associated model instances. 29 | 30 | ## Installation 31 | 32 | ### Step 1: Composer 33 | 34 | From the command line, run: 35 | 36 | ``` 37 | composer require masterro/laravel-file-cleaner 38 | ``` 39 | 40 | ### Step 2: Service Provider (For Laravel < 5.5) 41 | 42 | For your Laravel app, open `config/app.php` and, within the `providers` array, append: 43 | 44 | ``` 45 | MasterRO\LaravelFileCleaner\FileCleanerServiceProvider::class 46 | ``` 47 | 48 | This will bootstrap the package into Laravel. 49 | 50 | ### Step 3: Publish Configs 51 | 52 | First from the command line, run: 53 | 54 | ``` 55 | php artisan vendor:publish --provider="MasterRO\LaravelFileCleaner\FileCleanerServiceProvider" 56 | ``` 57 | 58 | After that you will see `file-cleaner.php` file in config directory 59 | 60 | For this package you may set such configurations: 61 | * `paths` - Paths where temp files are storing (or will be storing), relative to root directory 62 | * `excluded_paths` - Excluded directory paths where nothing would be deleted, relative to root directory 63 | * `excluded_files` - Excluded files path that would not be deleted, relative to root directory 64 | * `time_before_remove` - Time after which the files will be deleted | _default **60** minutes_ 65 | * `model` - Model which instances will be deleted with associated files | _optional_ 66 | * `file_field_name` - Field name that contains the name of the removing file | _optional, **only if model set**_ 67 | * `remove_directories` - Remove directories flag, if set to true all nested directories would be removed | _default **true**_ 68 | * `relation` - Relation, remove files and model instances only if model instance does not have a set relation 69 | 70 | ### Voter callback 71 | Additionally, you can set a static voter callback or invokable object to have more power on controlling deletion logic. 72 | You can register it in one of yours Service providers. The callback will be called after `time_before_remove` and `excluded_*` checks. 73 | 74 | ```php 75 | FileCleaner::voteDeleteUsing(function($path, $entity) { 76 | if (isset($entity) && !$entity->user->isActive()) { 77 | return true; 78 | } 79 | 80 | return false; 81 | }); 82 | ``` 83 | 84 | If callback return `true` file and optionally associated record in db will be removed. 85 | If callback return `false` file and record won't be removed. 86 | Otherwise `relation` check will be performed. 87 | 88 | 89 | ## Usage 90 | 91 | ### Scheduling 92 | 93 | Add new command call to schedule function: 94 | > Have a look at [Laravel's task scheduling documentation](https://laravel.com/docs/scheduling), if you need any help. 95 | 96 | ```php 97 | protected function schedule(Schedule $schedule) 98 | { 99 | $schedule->command('file-cleaner:clean')->everyMinute(); 100 | } 101 | ``` 102 | 103 | And that's all. If your cron set up everything will work. 104 | 105 | 106 | ### Manual, using artisan console 107 | 108 | You can run deleting manually, just run from the command line: 109 | ``` 110 | php artisan file-cleaner:clean 111 | ``` 112 | And see the output. 113 | 114 | 115 | Or if you want to delete files without checking time (just delete all files from all set directories) use the --force flag (or -f shortcut): 116 | ``` 117 | php artisan file-cleaner:clean -f 118 | ``` 119 | 120 | You can even override config directories `paths`, `excluded_paths` and `excluded_files` values with `--directories`, `--excluded-paths` and `--excluded-files` options (separate by comma): 121 | ``` 122 | php artisan file-cleaner:clean -f --directories=storage/temp/images,public/uploads/test 123 | ``` 124 | ``` 125 | php artisan file-cleaner:clean -f --excluded-paths=public/uploads/images/default,public/uploads/test 126 | ``` 127 | ``` 128 | php artisan file-cleaner:clean -f --excluded-files=public/uploads/images/default.png,public/uploads/test/01.png 129 | ``` 130 | 131 | 132 | Also you can even override `remove_directories` config value with `--remove-directories` option: 133 | ``` 134 | php artisan file-cleaner:clean -f --directories=storage/temp/images,public/uploads/test --remove-directories=false 135 | ``` 136 | 137 | #### _I will be grateful if you star this project :)_ 138 | -------------------------------------------------------------------------------- /src/FileCleaner.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 117 | } 118 | 119 | /** 120 | * Vote Delete Using 121 | * 122 | * @param Closure $voter 123 | */ 124 | public static function voteDeleteUsing(Closure $voter = null) 125 | { 126 | static::$voter = $voter; 127 | } 128 | 129 | /** 130 | * Execute the console command. 131 | * 132 | * @return void 133 | */ 134 | public function handle() 135 | { 136 | $this->readConfigs()->setUp(); 137 | 138 | if (!count($this->paths)) { 139 | $this->info('Nothing to delete.'); 140 | 141 | return; 142 | } 143 | 144 | foreach ($this->paths as $path) { 145 | $this->clear($path); 146 | } 147 | 148 | $this->outputResultCounts(); 149 | } 150 | 151 | /** 152 | * Read Configs 153 | * 154 | * @return FileCleaner 155 | */ 156 | protected function readConfigs() 157 | { 158 | $this->paths = config('file-cleaner.paths', []); 159 | $this->excludedPaths = config('file-cleaner.excluded_paths', []); 160 | $this->excludedFiles = config('file-cleaner.excluded_files', []); 161 | $this->setRealPaths(); 162 | 163 | if (!is_null(config('file-cleaner.model'))) { 164 | $model = config('file-cleaner.model'); 165 | $this->model = app($model); 166 | 167 | if (!is_a($this->model, Model::class)) { 168 | throw new InvalidArgumentException("Model [{$model}] should be an instance of " . Model::class); 169 | } 170 | 171 | $this->fileField = config('file-cleaner.file_field_name'); 172 | $this->relation = config('file-cleaner.relation'); 173 | } 174 | 175 | return $this; 176 | } 177 | 178 | /** 179 | * Set Up 180 | * 181 | * @return FileCleaner 182 | */ 183 | protected function setUp() 184 | { 185 | $this->timeBeforeRemove = $this->option('force') ? -1 186 | : config('file-cleaner.time_before_remove', self::DEFAULT_TIME_BEFORE_REMOVE); 187 | 188 | if ($directories = $this->option('directories')) { 189 | $this->readPathsFromConsole($directories); 190 | } 191 | 192 | if ($excludedDirectory = $this->option('excluded-paths')) { 193 | $this->readExcludedPathsFromConsole($excludedDirectory); 194 | } 195 | 196 | if ($excludedFiles = $this->option('excluded-files')) { 197 | $this->readExcludedFilesFromConsole($excludedFiles); 198 | } 199 | 200 | $this->removeDirectories = ($removeDirectories = $this->option('remove-directories')) 201 | ? ($removeDirectories == "false" && $removeDirectories !== true ? false : true) 202 | : config('file-cleaner.remove_directories', true); 203 | 204 | return $this; 205 | } 206 | 207 | /** 208 | * Remove files and directories from specified path 209 | * 210 | * @param string $path 211 | * 212 | * @return FileCleaner 213 | */ 214 | protected function clear($path) 215 | { 216 | if ($this->filesystem->exists($path)) { 217 | $this->removeFiles( 218 | $this->filesystem->allFiles($path) 219 | ); 220 | 221 | if ($this->removeDirectories === true) { 222 | $this->removeDirectories( 223 | $this->filesystem->directories($path) 224 | ); 225 | } 226 | } else { 227 | $this->warn('Directory ' . $path . ' does not exists'); 228 | } 229 | 230 | return $this; 231 | } 232 | 233 | /** 234 | * Remove Files 235 | * 236 | * @param array $files 237 | * 238 | * @return FileCleaner 239 | */ 240 | protected function removeFiles(array $files) 241 | { 242 | foreach ($files as $file) { 243 | // File fresh. 244 | if (Carbon::createFromTimestamp($file->getMTime()) 245 | ->diffInMinutes(Carbon::now()) <= $this->timeBeforeRemove 246 | ) { 247 | continue; 248 | } 249 | 250 | // If file is excluded skip it. 251 | if (in_array($file->getPath(), $this->excludedPaths) 252 | || in_array($filename = $file->getRealPath(), $this->excludedFiles) 253 | ) { 254 | continue; 255 | } 256 | 257 | if (!is_null($this->model) && !is_null($this->fileField)) { 258 | $model = $this->model->where($this->fileField, $file->getBasename())->first(); 259 | } 260 | 261 | if (static::$voter) { 262 | $decision = call_user_func_array(static::$voter, [$file, isset($model) ? $model : null]); 263 | 264 | if (false === $decision) { 265 | $this->info("Voter decision: 'Do not delete file: {$file->getRealPath()}'"); 266 | continue; 267 | } 268 | 269 | if (true === $decision) { 270 | $this->info("Voter decision: 'Delete file: {$file->getRealPath()}'"); 271 | $this->deleteFile($filename, $file->getBasename()); 272 | continue; 273 | } 274 | } 275 | 276 | // If relation option set, then we remove files only if there is no related instance(s). 277 | if (!is_null($this->relation)) { 278 | if (!isset($model)) { 279 | throw new ModelNotFoundException( 280 | sprintf( 281 | "'Instance of [%s] not found with '%s' by '%s' field.'", 282 | is_object($this->model) ? get_class($this->model) : $this->model, 283 | $file->getBasename(), 284 | $this->fileField 285 | ) 286 | ); 287 | } 288 | 289 | $related = $model->{$this->relation}; 290 | 291 | if (is_null($related) || ($related instanceof Collection && $related->isEmpty())) { 292 | $this->info("File instance without relation: {$file->getRealPath()}"); 293 | $this->deleteFile($filename, $file->getBasename()); 294 | } 295 | } else { 296 | $this->deleteFile($filename, $file->getBasename()); 297 | } 298 | } 299 | 300 | return $this; 301 | } 302 | 303 | /** 304 | * Remove Directories 305 | * 306 | * @param array $directories 307 | * 308 | * @return FileCleaner 309 | */ 310 | protected function removeDirectories(array $directories) 311 | { 312 | foreach ($directories as $dir) { 313 | if (!count($this->filesystem->allFiles($dir))) { 314 | $this->filesystem->deleteDirectory($dir); 315 | $this->info('Deleted directory: ' . $dir); 316 | $this->countRemovedDirectories++; 317 | } 318 | } 319 | 320 | return $this; 321 | } 322 | 323 | /** 324 | * Display how much files and directories totally were removed 325 | * 326 | * @return FileCleaner 327 | */ 328 | protected function outputResultCounts() 329 | { 330 | if (!$this->countRemovedFiles && !$this->countRemovedDirectories) { 331 | $this->info('Nothing to delete. All files are fresh.'); 332 | } else { 333 | if ($this->countRemovedFiles) { 334 | $this->info('Deleted ' . $this->countRemovedFiles . ' file(s)'); 335 | } 336 | if ($this->countRemovedDirectories) { 337 | $this->info('Deleted ' . $this->countRemovedDirectories . ' directory(ies).'); 338 | } 339 | if ($this->countRemovedInstances) { 340 | $this->info('Deleted ' . $this->countRemovedInstances . ' instance(s).'); 341 | } 342 | } 343 | 344 | return $this; 345 | } 346 | 347 | /** 348 | * @param string $filename 349 | * 350 | * @return bool 351 | */ 352 | protected function deleteModelInstances($filename) 353 | { 354 | if (is_null($this->model) || is_null($this->fileField)) { 355 | return false; 356 | } 357 | 358 | if ($instances = $this->model->where($this->fileField, $filename)->get()) { 359 | foreach ($instances as $instance) { 360 | $instance->delete(); 361 | $this->countRemovedInstances++; 362 | } 363 | } 364 | 365 | return true; 366 | } 367 | 368 | /** 369 | * Delete File 370 | * 371 | * @param string $filename 372 | * @param string $fileBaseName 373 | * 374 | * @return FileCleaner 375 | */ 376 | protected function deleteFile($filename, $fileBaseName) 377 | { 378 | $this->filesystem->delete($filename); 379 | $this->deleteModelInstances($fileBaseName); 380 | $this->info('Deleted file: ' . $filename); 381 | $this->countRemovedFiles++; 382 | 383 | return $this; 384 | } 385 | 386 | /** 387 | * Read Paths From Console 388 | * 389 | * @param string $directories 390 | * 391 | * @return FileCleaner 392 | */ 393 | protected function readPathsFromConsole($directories) 394 | { 395 | $this->paths = explode(',', $directories); 396 | 397 | return $this->setRealDirectoryPaths(); 398 | } 399 | 400 | /** 401 | * Read Excluded Paths From Console 402 | * 403 | * @param string $paths 404 | * 405 | * @return FileCleaner 406 | */ 407 | protected function readExcludedPathsFromConsole($paths) 408 | { 409 | $this->excludedPaths = explode(',', $paths); 410 | 411 | return $this->setRealExcludedDirectoryPaths(); 412 | } 413 | 414 | /** 415 | * Read Excluded Files From Console 416 | * 417 | * @param string $paths 418 | * 419 | * @return FileCleaner 420 | */ 421 | protected function readExcludedFilesFromConsole($paths) 422 | { 423 | $this->excludedFiles = explode(',', $paths); 424 | 425 | return $this->setRealExcludedFilesPaths(); 426 | } 427 | 428 | /** 429 | * Set Real Paths 430 | * 431 | * @return FileCleaner 432 | */ 433 | protected function setRealPaths() 434 | { 435 | return $this->setRealDirectoryPaths() 436 | ->setRealExcludedDirectoryPaths() 437 | ->setRealExcludedFilesPaths(); 438 | } 439 | 440 | /** 441 | * Set Real Directory Paths 442 | * 443 | * @return FileCleaner 444 | */ 445 | private function setRealDirectoryPaths() 446 | { 447 | if ($count = count($this->paths)) { 448 | for ($i = 0; $i < $count; $i++) { 449 | $this->paths[$i] = realpath(base_path($this->paths[$i])) ?: $this->paths[$i]; 450 | } 451 | } 452 | 453 | return $this; 454 | } 455 | 456 | /** 457 | * Set Real Excluded Directory Paths 458 | * 459 | * @return FileCleaner 460 | */ 461 | private function setRealExcludedDirectoryPaths() 462 | { 463 | if ($count = count($this->excludedPaths)) { 464 | for ($i = 0; $i < $count; $i++) { 465 | $this->excludedPaths[$i] = realpath(base_path($this->excludedPaths[$i])) ?: $this->excludedPaths[$i]; 466 | } 467 | } 468 | 469 | return $this; 470 | } 471 | 472 | /** 473 | * Set Real Excluded Files Paths 474 | * 475 | * @return FileCleaner 476 | */ 477 | private function setRealExcludedFilesPaths() 478 | { 479 | if ($count = count($this->excludedFiles)) { 480 | for ($i = 0; $i < $count; $i++) { 481 | $this->excludedFiles[$i] = realpath(base_path($this->excludedFiles[$i])) ?: $this->excludedFiles[$i]; 482 | } 483 | } 484 | 485 | return $this; 486 | } 487 | } 488 | -------------------------------------------------------------------------------- /src/FileCleanerServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 18 | __DIR__ . '/file-cleaner.php' => config_path('file-cleaner.php'), 19 | ], 'config'); 20 | 21 | $this->commands([ 22 | FileCleaner::class, 23 | ]); 24 | } 25 | 26 | 27 | /** 28 | * Register the service provider. 29 | * 30 | * @return void 31 | */ 32 | public function register() 33 | { 34 | // 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/file-cleaner.php: -------------------------------------------------------------------------------- 1 | [ 30 | 'storage/app/temp/images', 31 | ], 32 | 33 | /** 34 | * array 35 | */ 36 | 'excluded_paths' => [ 37 | 'public/uploads/images/default', 38 | ], 39 | 40 | /** 41 | * array 42 | */ 43 | 'excluded_files' => [ 44 | 'public/uploads/images/default.png', 45 | ], 46 | 47 | /** 48 | * integer 49 | */ 50 | 'time_before_remove' => 60, 51 | 52 | /** 53 | * bool 54 | */ 55 | 'remove_directories' => true, 56 | 57 | /** 58 | * EloquentModel|null 59 | */ 60 | 'model' => null, 61 | 62 | /** 63 | * string|null 64 | */ 65 | 'file_field_name' => null, 66 | 67 | /** 68 | * Delete file and instance only if there is no related instance(s) 69 | */ 70 | 'relation' => null, 71 | 72 | ]; 73 | -------------------------------------------------------------------------------- /tests/Database/Models/File.php: -------------------------------------------------------------------------------- 1 | belongsTo(TestOne::class); 18 | } 19 | 20 | 21 | /** 22 | * @return \Illuminate\Database\Eloquent\Relations\HasMany 23 | */ 24 | public function testCollection() 25 | { 26 | return $this->hasMany(TestCollection::class); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Database/Models/TestCollection.php: -------------------------------------------------------------------------------- 1 | belongsTo(File::class); 20 | } 21 | } -------------------------------------------------------------------------------- /tests/Database/Models/TestOne.php: -------------------------------------------------------------------------------- 1 | hasMany(File::class); 20 | } 21 | } -------------------------------------------------------------------------------- /tests/Database/migrations/2017_09_08_112931_create_files_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 16 | $table->string('name'); 17 | $table->unsignedInteger('test_one_id')->nullable(); 18 | $table->timestamps(); 19 | }); 20 | } 21 | 22 | 23 | /** 24 | * Drop files table 25 | */ 26 | public function down() 27 | { 28 | Schema::dropIfExists('files'); 29 | } 30 | } -------------------------------------------------------------------------------- /tests/Database/migrations/2017_09_08_112932_create_test_collection_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 16 | $table->string('name'); 17 | $table->unsignedInteger('file_id')->nullable(); 18 | $table->timestamps(); 19 | }); 20 | } 21 | 22 | 23 | /** 24 | * Drop files table 25 | */ 26 | public function down() 27 | { 28 | Schema::dropIfExists('test_collection'); 29 | } 30 | } -------------------------------------------------------------------------------- /tests/Database/migrations/2017_09_08_112932_create_test_one_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 16 | $table->string('name'); 17 | $table->timestamps(); 18 | }); 19 | } 20 | 21 | 22 | /** 23 | * Drop files table 24 | */ 25 | public function down() 26 | { 27 | Schema::dropIfExists('test_one'); 28 | } 29 | } -------------------------------------------------------------------------------- /tests/FileCleanerTest.php: -------------------------------------------------------------------------------- 1 | app->singleton('Illuminate\Contracts\Console\Kernel', TestKernel::class); 25 | 26 | $this->tempDir = __DIR__ . '/tmp'; 27 | mkdir($this->tempDir, 0777, true); 28 | $this->setTestConfig(); 29 | 30 | $this->app['Illuminate\Contracts\Console\Kernel']->registerCommand(app(FileCleaner::class)); 31 | } 32 | 33 | public function tearDown(): void 34 | { 35 | parent::tearDown(); 36 | 37 | FileCleaner::voteDeleteUsing(null); 38 | 39 | $files = new Filesystem; 40 | $files->deleteDirectory($this->tempDir); 41 | } 42 | 43 | /** 44 | * @test 45 | */ 46 | public function it_deletes_fresh_files_with_force() 47 | { 48 | $files = new Filesystem; 49 | 50 | config(['file-cleaner.time_before_remove' => 60]); 51 | 52 | $files->put("{$this->tempDir}/test.txt", 'test'); 53 | 54 | $this->callCleaner(false); 55 | 56 | $this->assertFileExists("{$this->tempDir}/test.txt"); 57 | 58 | $this->callCleaner(); 59 | 60 | $this->assertFileDoesNotExist("{$this->tempDir}/test.txt"); 61 | } 62 | 63 | /** 64 | * @test 65 | */ 66 | public function it_removes_directories_when_config_set() 67 | { 68 | config(['file-cleaner.remove_directories' => false]); 69 | 70 | $this->createNestedTestFilesAndDirectories(); 71 | 72 | $this->callCleaner(); 73 | 74 | $this->assertFileDoesNotExist("{$this->tempDir}/test.txt"); 75 | $this->assertFileDoesNotExist("{$this->tempDir}/dir1/example1.txt"); 76 | $this->assertFileDoesNotExist("{$this->tempDir}/dir1/dir2/example2.txt"); 77 | 78 | $this->assertDirectoryExists("{$this->tempDir}/dir1/dir2"); 79 | 80 | config(['file-cleaner.remove_directories' => true]); 81 | 82 | $this->callCleaner(); 83 | 84 | $this->assertDirectoryDoesNotExist("{$this->tempDir}/dir1"); 85 | } 86 | 87 | /** 88 | * @test 89 | */ 90 | public function it_can_override_remove_directories_config_parameter() 91 | { 92 | config(['file-cleaner.remove_directories' => false]); 93 | $this->createNestedTestFilesAndDirectories(); 94 | 95 | $this->callCleaner(); 96 | $this->assertDirectoryExists("{$this->tempDir}/dir1/dir2"); 97 | 98 | $this->createNestedTestFilesAndDirectories(); 99 | 100 | $this->callCleaner(true, ['--remove-directories' => true]); 101 | $this->assertDirectoryDoesNotExist("{$this->tempDir}/dir1"); 102 | } 103 | 104 | /** 105 | * @test 106 | */ 107 | public function it_can_delete_files_in_specified_directories() 108 | { 109 | config([ 110 | 'file-cleaner.paths' => [ 111 | "{$this->tempDir}/dir1", 112 | "{$this->tempDir}/dir2", 113 | ], 114 | ]); 115 | 116 | $this->createTestDirectoriesAndFiles(3); 117 | 118 | $this->callCleaner(); 119 | 120 | $this->assertFileDoesNotExist("{$this->tempDir}/dir1/test.txt"); 121 | $this->assertFileDoesNotExist("{$this->tempDir}/dir2/test.txt"); 122 | $this->assertFileExists("{$this->tempDir}/dir3/test.txt"); 123 | } 124 | 125 | /** 126 | * @test 127 | */ 128 | public function it_can_override_paths_config_parameter_with_directories_parameter() 129 | { 130 | config([ 131 | 'file-cleaner.paths' => [ 132 | "{$this->tempDir}/dir1", 133 | "{$this->tempDir}/dir2", 134 | ], 135 | ]); 136 | 137 | $this->createTestDirectoriesAndFiles(4); 138 | 139 | $this->callCleaner(true, ['--directories' => "{$this->tempDir}/dir3,{$this->tempDir}/dir4"]); 140 | 141 | $this->assertFileExists("{$this->tempDir}/dir1/test.txt"); 142 | $this->assertFileExists("{$this->tempDir}/dir2/test.txt"); 143 | $this->assertFileDoesNotExist("{$this->tempDir}/dir3/test.txt"); 144 | $this->assertFileDoesNotExist("{$this->tempDir}/dir4/test.txt"); 145 | } 146 | 147 | /** 148 | * @test 149 | */ 150 | public function it_can_exclude_directories_from_cleaning() 151 | { 152 | config([ 153 | 'file-cleaner.excluded_paths' => [ 154 | "{$this->tempDir}/dir1", 155 | ], 156 | ]); 157 | 158 | $this->createTestDirectoriesAndFiles(); 159 | 160 | $this->callCleaner(); 161 | 162 | $this->assertFileExists("{$this->tempDir}/dir1/test.txt"); 163 | $this->assertDirectoryDoesNotExist("{$this->tempDir}/dir2"); 164 | } 165 | 166 | /** 167 | * @test 168 | */ 169 | public function it_can_override_excluded_directories_from_cleaning() 170 | { 171 | config([ 172 | 'file-cleaner.excluded_paths' => [ 173 | "{$this->tempDir}/dir1", 174 | "{$this->tempDir}/dir2", 175 | ], 176 | ]); 177 | 178 | $this->createTestDirectoriesAndFiles(4); 179 | 180 | $this->callCleaner(true, ['--excluded-paths' => "{$this->tempDir}/dir3,{$this->tempDir}/dir4"]); 181 | 182 | $this->assertFileExists("{$this->tempDir}/dir3/test.txt"); 183 | $this->assertFileExists("{$this->tempDir}/dir4/test.txt"); 184 | $this->assertDirectoryDoesNotExist("{$this->tempDir}/dir1"); 185 | $this->assertDirectoryDoesNotExist("{$this->tempDir}/dir2"); 186 | } 187 | 188 | /** 189 | * @test 190 | */ 191 | public function it_can_exclude_files_from_cleaning() 192 | { 193 | $files = new Filesystem; 194 | 195 | config([ 196 | 'file-cleaner.excluded_files' => [ 197 | "{$this->tempDir}/dir1/test.txt", 198 | ], 199 | ]); 200 | 201 | $this->createTestDirectoriesAndFiles(); 202 | 203 | $files->put("{$this->tempDir}/dir1/test2.txt", 'test'); 204 | 205 | $this->callCleaner(); 206 | 207 | $this->assertFileExists("{$this->tempDir}/dir1/test.txt"); 208 | $this->assertFileDoesNotExist("{$this->tempDir}/dir1/test2.txt"); 209 | $this->assertDirectoryDoesNotExist("{$this->tempDir}/dir2"); 210 | } 211 | 212 | /** 213 | * @test 214 | */ 215 | public function it_can_override_excluded_files_from_cleaning() 216 | { 217 | $files = new Filesystem; 218 | 219 | config([ 220 | 'file-cleaner.excluded_files' => [ 221 | "{$this->tempDir}/dir1/test.txt", 222 | "{$this->tempDir}/dir2/test.txt", 223 | ], 224 | ]); 225 | 226 | $this->createTestDirectoriesAndFiles(4); 227 | 228 | $files->put("{$this->tempDir}/dir1/test2.txt", 'test'); 229 | $files->put("{$this->tempDir}/dir2/test2.txt", 'test'); 230 | 231 | $this->callCleaner( 232 | true, 233 | ['--excluded-files' => "{$this->tempDir}/dir1/test2.txt,{$this->tempDir}/dir2/test2.txt"] 234 | ); 235 | 236 | $this->assertFileExists("{$this->tempDir}/dir1/test2.txt"); 237 | $this->assertFileExists("{$this->tempDir}/dir2/test2.txt"); 238 | $this->assertFileDoesNotExist("{$this->tempDir}/dir1/test.txt"); 239 | $this->assertFileDoesNotExist("{$this->tempDir}/dir2/test.txt"); 240 | $this->assertDirectoryDoesNotExist("{$this->tempDir}/dir3"); 241 | $this->assertDirectoryDoesNotExist("{$this->tempDir}/dir4"); 242 | } 243 | 244 | /** 245 | * @test 246 | */ 247 | public function it_deletes_associated_model_instance() 248 | { 249 | $this->setUpDatabase($this->app); 250 | 251 | $this->createTestDirectoriesAndFiles(1); 252 | 253 | config([ 254 | 'file-cleaner.model' => File::class, 255 | 'file-cleaner.file_field_name' => 'name', 256 | ]); 257 | 258 | File::create(['name' => 'test.txt']); 259 | 260 | $this->callCleaner(); 261 | 262 | $this->assertCount(0, File::where(['name' => 'test.txt'])->get()); 263 | $this->assertFileDoesNotExist("{$this->tempDir}/dir1/test1.txt"); 264 | } 265 | 266 | /** 267 | * @test 268 | */ 269 | public function it_does_not_delete_associated_model_instance_if_field_name_wrong() 270 | { 271 | $this->setUpDatabase($this->app); 272 | 273 | $this->createTestDirectoriesAndFiles(1); 274 | 275 | config([ 276 | 'file-cleaner.model' => File::class, 277 | 'file-cleaner.file_field_name' => 'wrong_name', 278 | ]); 279 | 280 | File::create(['name' => 'test.txt']); 281 | 282 | $this->callCleaner(); 283 | 284 | $this->assertCount(1, File::where(['name' => 'test.txt'])->get()); 285 | $this->assertFileDoesNotExist("{$this->tempDir}/dir1/test1.txt"); 286 | } 287 | 288 | /** 289 | * @test 290 | */ 291 | public function model_should_be_an_instance_of_eloquent() 292 | { 293 | $this->expectException(InvalidArgumentException::class); 294 | 295 | $this->setUpDatabase($this->app); 296 | 297 | $this->createTestDirectoriesAndFiles(1); 298 | 299 | config([ 300 | 'file-cleaner.model' => stdClass::class, 301 | 'file-cleaner.file_field_name' => 'name', 302 | ]); 303 | 304 | $this->callCleaner(); 305 | } 306 | 307 | /** 308 | * @test 309 | */ 310 | public function it_can_delete_model_instance_if_it_does_not_have_related_instance() 311 | { 312 | $this->setUpDatabase($this->app); 313 | 314 | $this->createTestDirectoriesAndFiles(1); 315 | 316 | config([ 317 | 'file-cleaner.model' => File::class, 318 | 'file-cleaner.file_field_name' => 'name', 319 | 'file-cleaner.relation' => 'testOne', 320 | ]); 321 | 322 | File::create(['name' => 'test.txt']); 323 | 324 | $this->callCleaner(); 325 | 326 | $this->assertCount(0, File::where(['name' => 'test.txt'])->get()); 327 | $this->assertFileDoesNotExist("{$this->tempDir}/dir1/test.txt"); 328 | } 329 | 330 | /** 331 | * @test 332 | */ 333 | public function it_should_not_delete_model_instance_if_it_has_related_instance() 334 | { 335 | $this->setUpDatabase($this->app); 336 | 337 | $this->createTestDirectoriesAndFiles(1); 338 | 339 | config([ 340 | 'file-cleaner.model' => File::class, 341 | 'file-cleaner.file_field_name' => 'name', 342 | 'file-cleaner.relation' => 'testOne', 343 | ]); 344 | 345 | $oneRelated = TestOne::create(['name' => 'test']); 346 | $oneRelated->files()->create(['name' => 'test.txt']); 347 | 348 | $this->callCleaner(); 349 | 350 | $this->assertCount(1, File::where(['name' => 'test.txt'])->get()); 351 | $this->assertFileExists("{$this->tempDir}/dir1/test.txt"); 352 | } 353 | 354 | /** 355 | * @test 356 | */ 357 | public function it_can_delete_model_instance_if_it_does_not_have_related_instances() 358 | { 359 | $this->setUpDatabase($this->app); 360 | 361 | $this->createTestDirectoriesAndFiles(1); 362 | 363 | config([ 364 | 'file-cleaner.model' => File::class, 365 | 'file-cleaner.file_field_name' => 'name', 366 | 'file-cleaner.relation' => 'testCollection', 367 | ]); 368 | 369 | File::create(['name' => 'test.txt']); 370 | 371 | $this->callCleaner(); 372 | 373 | $this->assertCount(0, File::where(['name' => 'test.txt'])->get()); 374 | $this->assertFileDoesNotExist("{$this->tempDir}/dir1/test.txt"); 375 | } 376 | 377 | /** 378 | * @test 379 | */ 380 | public function it_should_not_delete_file_and_model_instance_if_it_has_related_instances() 381 | { 382 | $this->setUpDatabase($this->app); 383 | 384 | $this->createTestDirectoriesAndFiles(1); 385 | 386 | config([ 387 | 'file-cleaner.model' => File::class, 388 | 'file-cleaner.file_field_name' => 'name', 389 | 'file-cleaner.relation' => 'testCollection', 390 | ]); 391 | 392 | $file = File::create(['name' => 'test.txt']); 393 | $file->testCollection()->createMany([ 394 | ['name' => 'test'], 395 | ['name' => 'test2'], 396 | ]); 397 | 398 | $this->callCleaner(); 399 | 400 | $this->assertCount(1, File::where(['name' => 'test.txt'])->get()); 401 | $this->assertFileExists("{$this->tempDir}/dir1/test.txt"); 402 | } 403 | 404 | /** 405 | * @test 406 | */ 407 | public function it_throws_exception_if_model_instance_not_found() 408 | { 409 | $this->expectException(ModelNotFoundException::class); 410 | 411 | $this->setUpDatabase($this->app); 412 | 413 | $this->createTestDirectoriesAndFiles(1); 414 | 415 | config([ 416 | 'file-cleaner.model' => File::class, 417 | 'file-cleaner.file_field_name' => 'name', 418 | 'file-cleaner.relation' => 'testOne', 419 | ]); 420 | 421 | $oneRelated = TestOne::create(['name' => 'test']); 422 | $oneRelated->files()->create(['name' => 'wrong_name.txt']); 423 | 424 | $this->callCleaner(); 425 | } 426 | 427 | /** 428 | * @test 429 | */ 430 | public function it_removes_file_if_voter_decision_is_true() 431 | { 432 | $this->setUpDatabase($this->app); 433 | 434 | $this->createTestDirectoriesAndFiles(1); 435 | 436 | config([ 437 | 'file-cleaner.model' => File::class, 438 | 'file-cleaner.file_field_name' => 'name', 439 | ]); 440 | 441 | File::create(['name' => 'test.txt']); 442 | 443 | FileCleaner::voteDeleteUsing(function ($path, $entity) { 444 | if (isset($entity) && false !== strpos($path, 'test.txt')) { 445 | return true; 446 | } 447 | 448 | return false; 449 | }); 450 | 451 | $this->callCleaner(); 452 | 453 | $this->assertCount(0, File::where(['name' => 'test.txt'])->get()); 454 | $this->assertFileDoesNotExist("{$this->tempDir}/dir1/test.txt"); 455 | } 456 | 457 | /** 458 | * @test 459 | */ 460 | public function it_doesnt_removes_file_if_voter_decision_is_false() 461 | { 462 | $this->setUpDatabase($this->app); 463 | 464 | $this->createTestDirectoriesAndFiles(1); 465 | 466 | config([ 467 | 'file-cleaner.model' => File::class, 468 | 'file-cleaner.file_field_name' => 'name', 469 | ]); 470 | 471 | File::create(['name' => 'test.txt']); 472 | 473 | FileCleaner::voteDeleteUsing(function ($path, $entity) { 474 | if (isset($entity) && false !== strpos($path, 'test.txt')) { 475 | return false; 476 | } 477 | 478 | return true; 479 | }); 480 | 481 | $this->callCleaner(); 482 | 483 | $this->assertCount(1, File::where(['name' => 'test.txt'])->get()); 484 | $this->assertFileExists("{$this->tempDir}/dir1/test.txt"); 485 | } 486 | 487 | /** 488 | * Set up the environment. 489 | * 490 | * @param \Illuminate\Foundation\Application $app 491 | */ 492 | protected function getEnvironmentSetUp($app) 493 | { 494 | $app['config']->set('database.default', 'sqlite'); 495 | $app['config']->set('database.connections.sqlite', [ 496 | 'driver' => 'sqlite', 497 | 'database' => ':memory:', 498 | 'prefix' => '', 499 | ]); 500 | } 501 | 502 | /** 503 | * Set up the database. 504 | * 505 | * @param \Illuminate\Foundation\Application $app 506 | */ 507 | protected function setUpDatabase($app) 508 | { 509 | (new CreateFilesTable)->up(); 510 | (new CreateTestOneTable)->up(); 511 | (new CreateTestCollectionTable)->up(); 512 | } 513 | 514 | protected function setTestConfig() 515 | { 516 | config([ 517 | 'file-cleaner' => [ 518 | 'paths' => [ 519 | $this->tempDir, 520 | ], 521 | 'excluded_paths' => [], 522 | 'excluded_files' => [], 523 | 'time_before_remove' => 0, 524 | 'remove_directories' => true, 525 | 'model' => null, 526 | 'file_field_name' => null, 527 | 'relation' => null, 528 | 529 | ], 530 | ]); 531 | } 532 | 533 | /** 534 | * @param bool $force 535 | * @param array $params 536 | */ 537 | protected function callCleaner($force = true, array $params = []) 538 | { 539 | $params = $force ? array_merge($params, ['-f' => true]) : $params; 540 | 541 | $this->artisan('file-cleaner:clean', $params); 542 | } 543 | 544 | /** 545 | * @param int $depth 546 | */ 547 | protected function createNestedTestFilesAndDirectories($depth = 3) 548 | { 549 | $files = new Filesystem; 550 | 551 | $path = $this->tempDir; 552 | 553 | for ($i = 1; $i <= $depth; ++$i) { 554 | $path .= "/dir{$i}"; 555 | } 556 | 557 | $files->deleteDirectory($path); 558 | $files->makeDirectory($path, 0777, true); 559 | 560 | $path = $this->tempDir; 561 | 562 | $files->put("{$this->tempDir}/test.txt", 'test'); 563 | for ($i = 1; $i <= $depth; ++$i) { 564 | $path .= "/dir{$i}"; 565 | $files->put("{$path}/test.txt", 'test'); 566 | } 567 | } 568 | 569 | /** 570 | * @param int $count 571 | */ 572 | protected function createTestDirectoriesAndFiles($count = 2) 573 | { 574 | $files = new Filesystem; 575 | 576 | for ($i = 1; $i <= $count; ++$i) { 577 | $files->makeDirectory("{$this->tempDir}/dir{$i}", 0777, true); 578 | $files->put("{$this->tempDir}/dir{$i}/test.txt", 'test'); 579 | } 580 | } 581 | 582 | } 583 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | getArtisan()->add($command); 13 | } 14 | } --------------------------------------------------------------------------------