├── .github └── workflows │ └── run-tests.yml ├── .php-cs-fixer.php ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── composer.json ├── config └── chunky.php ├── docker-compose.yml ├── docs ├── _config.yml ├── _layouts │ └── default.html └── index.md └── src ├── Chunk.php ├── ChunkyManager.php ├── ChunkyServiceProvider.php ├── ChunkySettings.php ├── Commands └── ClearChunks.php ├── Concerns └── ChunkyRequestHelpers.php ├── Contracts ├── ChunkyManager.php └── MergeHandler.php ├── Events ├── ChunkAdded.php ├── ChunkDeleted.php ├── ChunksMerged.php └── MergeAdded.php ├── Exceptions ├── ChunksIntegrityException.php └── ChunkyException.php ├── Facades └── Chunky.php ├── Handlers └── MergeHandler.php ├── Http ├── Requests │ └── AddChunkRequest.php └── Resources │ └── ChunkResource.php ├── Jobs └── MergeChunks.php └── Support ├── ChunksFilesystem.php ├── Filesystem.php ├── MergeFilesystem.php └── TempFilesystem.php /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'feature/*' 7 | pull_request: 8 | branches: 9 | - master 10 | - develop 11 | jobs: 12 | run-tests: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | php: ['8.1'] 18 | laravel: [10.*] 19 | dependency-version: [prefer-stable] 20 | include: 21 | - laravel: 10.* 22 | testbench: 8.* 23 | 24 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} 25 | 26 | steps: 27 | - name: Update apt 28 | run: sudo apt-get update --fix-missing 29 | 30 | - name: Checkout code 31 | uses: actions/checkout@v3 32 | 33 | - name: Cache dependencies 34 | uses: actions/cache@v3 35 | with: 36 | path: ~/.composer/cache/files 37 | key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 38 | 39 | - name: Setup PHP 40 | uses: shivammathur/setup-php@v2 41 | with: 42 | php-version: ${{ matrix.php }} 43 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick 44 | coverage: none 45 | 46 | - name: Install dependencies 47 | run: | 48 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 49 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction --no-suggest 50 | 51 | - name: Execute tests 52 | run: vendor/bin/phpunit 53 | env: 54 | CHUNKY_CHUNK_DISK: local 55 | CHUNKY_MERGE_DISK: local 56 | CHUNKY_AUTO_MERGE: true 57 | CHUNKY_MERGE_CONNECTION: default 58 | CHUNKY_MERGE_QUEUE: null 59 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 60 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 61 | AWS_DEFAULT_REGION: ${{ secrets.AWS_DEFAULT_REGION }} 62 | AWS_BUCKET: ${{ secrets.AWS_BUCKET }} 63 | 64 | - name: CS Fix 65 | run: vendor/bin/php-cs-fixer check 66 | -------------------------------------------------------------------------------- /.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->name('*.php') 6 | ->notName('.phpstorm.meta.php') 7 | ->notPath('scripts') 8 | ->notPath('vendor') 9 | ->ignoreDotFiles(true) 10 | ->ignoreVCS(true); 11 | 12 | return (new PhpCsFixer\Config()) 13 | ->setFinder($finder) 14 | ->setRiskyAllowed(true) 15 | ->setUsingCache(true) 16 | ->setRules([ 17 | '@PhpCsFixer' => true, 18 | 'blank_line_before_statement' => [ 19 | 'statements' => ['return'] 20 | ], 21 | 'increment_style' => [ 22 | 'style' => 'post' 23 | ], 24 | 'multiline_whitespace_before_semicolons' => [ 25 | 'strategy' => 'no_multi_line' 26 | ], 27 | 'modernize_types_casting' => true, 28 | 'no_superfluous_phpdoc_tags' => false, 29 | 'no_unset_cast' => false, 30 | 'ordered_imports' => ['sort_algorithm' => 'length'], 31 | 'php_unit_test_class_requires_covers' => false, 32 | 'phpdoc_add_missing_param_annotation' => ['only_untyped' => false], 33 | 'phpdoc_no_alias_tag' => false, 34 | 'phpdoc_types_order' => [ 35 | 'null_adjustment' => 'always_last' 36 | ], 37 | 'protected_to_private' => false, 38 | 'psr_autoloading' => true, 39 | 'simple_to_complex_string_variable' => false, 40 | 'single_line_comment_style' => [ 41 | 'comment_types' => ['hash'] 42 | ], 43 | 'single_trait_insert_per_statement' => false, 44 | 'ternary_to_null_coalescing' => true, 45 | 'yoda_style' => [ 46 | 'equal' => false, 47 | 'identical' => false, 48 | 'less_and_greater' => false 49 | ], 50 | ]) 51 | ->setLineEnding("\n"); 52 | 53 | // vim: ft=php sw=4 sts=4 et ai si 54 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Every major / minor version release will be documented in the changelog. 4 | 5 | ## v3.0.0 - 2020-12-10 6 | - Require laravel 10 7 | - Add docker container based on php 8.1 and composer 2.22 to improve developer experience 8 | 9 | ## v2.0.0 - 2020-12-10 10 | - Require laravel 9 11 | 12 | ## v1.4.1 - 2020-12-10 13 | Bug fix for the chunks listing. This error was due to the alphabetical order of files when listed from the chunk's folder. 14 | 15 | ## v1.4.0 - 2020-12-05 16 | Major updates: 17 | 18 | After a few tests, there was a RAM memory usage pick (10x the file dimension) when the stream of a PHP temporary file was written into S3. 19 | 20 | In detail, if the ChunkFilesystem has a remote adapter (S3 adapter tested) in order to generate the merge file, local temporary files are generated from remote chunks and, once appended to the final file, a stream is opened and putted as resource object into S3. This very last operation, mesured with `memory_get_peak_usage` function, was about ten times the dimension of the remote chunks sum. 21 | 22 | To avoid this behaviour TemporaryFilesystem has been introduced and the memory pick has been optimized. 23 | 24 | ## v1.3.0 - 2020-11-10 25 | Major updates: 26 | 27 | * Removed the merge strategy logic. It was completely useless since the package doesn't aim anymore to convert the files after merge. 28 | * Code refactor. 29 | * Temporary files support when merging from remote chunks disks. 30 | 31 | Minor fixes: 32 | 33 | * Improved tests. 34 | * Updated documentation. 35 | * Fixed Github actions 36 | 37 | ## v1.2.2 - 2020-09-28 38 | Major features: 39 | 40 | * Removed all the logic regarding the mime-type strategy. It was out of context. 41 | * Splitted ChunksManager in two classes ChunksManager and MergeManager 42 | * Code cleanup 43 | * Better handling of remote chunks merge 44 | 45 | 46 | ## v1.1.5 - 2020-09-02 47 | * Little fixes on `Chunk` model. 48 | * `MergeChunks` job refactor to avoid request serialization. 49 | * Fixed progress bar while running `deleteAllChunks` in console 50 | * Other minor fixes. 51 | 52 | ## v1.1.2 - 2020-09-01 53 | 54 | Fixed manager last chunk upload response. 55 | 56 | ## v1.1.1 - 2020-09-01 57 | 58 | Better documentation, fixed issue with remote files mapped into a chunk object. 59 | 60 | ## v1.0.0 - 2020-08-31 61 | 62 | Laravel Chunky first release. Main features: 63 | 64 | * Handle chunks upload with custom save disks and folders. 65 | * Handle file merge with custom save disks and folders. 66 | * Different merge strategies based on the file mime type. 67 | * Once the merge is done, the chunks folder is automatically cleared. 68 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are **greatly appreciated**. 4 | 5 | ## Pull Requests 6 | 7 | - **[PSR-2 Coding Standard.](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** The easiest way to apply the conventions is to install [PHP CS Fixer](https://github.com/FriendsOfPHP/PHP-CS-Fixer). 8 | - **Add tests!** Your patch won't be accepted if it doesn't have tests. 9 | - **Document any change in behaviour.** Make sure the `README.md` and any other relevant documentation are kept up-to-date. 10 | - **Consider our release cycle.** We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 11 | - **Create feature branches.** Don't ask us to pull from your master branch. 12 | - **One pull request per feature.** If you want to do more than one thing, send multiple pull requests. 13 | - **Send coherent history.** Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 14 | 15 | ## Security 16 | 17 | If you discover any security related issues, please email [dev@jobtech.it](dev@jobtech.it) instead of using the issue tracker. 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.1-fpm 2 | 3 | COPY --from=composer:2.2.22 /usr/bin/composer /usr/bin/composer 4 | 5 | RUN apt update && apt install -y libzip-dev zip libpng-dev \ 6 | && docker-php-ext-install zip gd 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Filippo Galante [galante.filippo@gmail.com](mailto:galante.filippo@gmail.com) 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. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | help: ## Show command list 2 | @awk -F ':|##' '/^[^\t].+?:.*?##/ {printf "\033[36m%-30s\033[0m %s\n", $$1, $$NF}' $(MAKEFILE_LIST) 3 | start: ## Start the container 4 | docker-compose up -d --remove-orphans 5 | stop: ## Stop the container 6 | docker-compose stop 7 | shell: ## Enter the docker shell for the container 8 | docker exec -it laravel-chunky bash 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | Logo 5 | 6 | 7 |

8 | This package handles chunked files upload requests in order to safely save chunked files and, once the upload has been completed, merge all the chunks into a single file. 9 |
10 |
11 | Report Bug 12 | · 13 | Request Feature 14 |

15 |

16 | 17 | [![MIT License](https://img.shields.io/github/license/jobtech-dev/laravel-chunky.svg?style=flat-square)](https://github.com/jobtech-dev/laravel-chunky/blob/master/LICENSE.txt) 18 | [![Build status](https://github.com/jobtech-dev/laravel-chunky/workflows/tests/badge.svg)](https://github.com/jobtech-dev/laravel-chunky/actions) 19 | [![StyleCI](https://github.styleci.io/repos/291024576/shield?branch=master)](https://github.styleci.io/repos/291024576?branch=master) 20 | [![GitHub stars](https://img.shields.io/github/stars/jobtech-dev/laravel-chunky)](https://github.com/jobtech-dev/laravel-chunky/stargazers) 21 | [![GitHub issues](https://img.shields.io/github/issues/jobtech-dev/laravel-chunky)](https://github.com/jobtech-dev/laravel-chunky/issues) 22 | [![LinkedIn](https://img.shields.io/badge/-LinkedIn-black.svg?style=flat-square&logo=linkedin&colorB=555)](https://linkedin.com/in/jobtech-srl) 23 | 24 | ## Table of Contents 25 | 26 | * [Getting Started](#getting-started) 27 | * [Main features](#main-features) 28 | * [Installation](#installation) 29 | * [Usage](#usage) 30 | * [Chunks](#chunks) 31 | * [Merge handler](#merge-handler) 32 | * [Testing](#testing) 33 | * [Roadmap](#roadmap) 34 | * [Changelog](#changelog) 35 | * [Contributing](#contributing) 36 | * [License](#license) 37 | * [Contact](#contact) 38 | * [Credits](#credits) 39 | 40 | ## Laravel compatibility 41 | 42 | Laravel | laravel-chunky 43 | :-------------|:---------- 44 | 10.x | 3.0.0 45 | 9.x | 2.0.0 46 | 8.x | 1.4.1 47 | 7.x | 1.4.1 48 | 6.x | 1.4.1 49 | 50 | ## Getting Started 51 | 52 | Laravel chunky is a package that can handle chunk upload for large files in Laravel 6.x, 7.x. and 8.x. Its main goal is automatically handle the upload request (see the [usage](#usage) section below) and save all the chunks into the desired disk. 53 | 54 | Once the upload completes, the package will dispatch a job in order to merge all the files into a single one and save in the same chunks disks or in another one. 55 | 56 | ### Main features 57 | * Handle chunks upload with custom save disks and folders. 58 | * Handle file merge with custom save disks and folders. 59 | * Once the merge is done, the chunks folder is automatically cleared. 60 | 61 | ### Installation 62 | 63 | In order to install Laravel Chunky into your project you have to require it via composer. 64 | 65 | ```sh 66 | $ composer require jobtech/laravel-chunky 67 | ``` 68 | 69 | Laravel uses Package Auto-Discovery and the `ChunkyServiceProvider` will be automatically registered. If you are using a Laravel version lower than 5.5 or you're not using autodiscovery, manually register the service provider: 70 | 71 | ```php 72 | // config/app.php 73 | [ 74 | // [...] 75 | 'providers' => [ 76 | // [...] 77 | Jobtech\LaravelChunky\ChunkyServiceProvider::class, 78 | ] 79 | ]; 80 | ``` 81 | 82 | You can also register an alias for the `Chunky` facade: 83 | 84 | ```php 85 | // config/app.php 86 | [ 87 | // [...] 88 | 'aliases' => [ 89 | // [...] 90 | 'Chunky' => Jobtech\LaravelChunky\Facades\Chunky::class, 91 | ] 92 | ]; 93 | ``` 94 | 95 | #### Configuration 96 | 97 | To publish the configuration file, run the following command: 98 | 99 | ```sh 100 | $ php artisan vendor:publish --provider="Jobtech\LaravelChunky\ChunkyServiceProvider" --tag="config" 101 | ``` 102 | 103 | #### Lumen 104 | 105 | This package can also work with Lumen, just register the service provider in `bootstrap/app.php`: 106 | 107 | ```php 108 | $app->register(Jobtech\LaravelChunky\ChunkyServiceProvider::class); 109 | ``` 110 | 111 | In order to configure the package, since lumen doesn't include the `vendor:publish` command, copy the configuration file to your config folder and enable it: 112 | 113 | ```php 114 | $app->configure('chunky'); 115 | ``` 116 | 117 | ## Usage 118 | 119 | This package has been designed to leave you the full control of the chunks upload and simple use the helper methods to handle the files merge as well as an _all-in-one_ solution for a fast scaffolding of the controllers delegated to handle large files upload. 120 | 121 | At the moment, this package doesn't include any wrapper for the frontend forms for the file uploads but, in the `config/chunky.php` configuration file, you can find two ways of integrate the package with [Dropzone](https://www.dropzonejs.com/) and [ResumableJs](http://resumablejs.com/). 122 | 123 | ### Chunks 124 | 125 | Laravel Chunky handles the chunks as an _ordered list_ of files. This is a **must** and if a wrong file index has been uploaded, an exception will be thrown in order to guarantee the integrity of the final merged file. Once all the chunks have been uploaded, and the merge process is executing, another integrity check will be made to all the chunks. If the sum of each file size is lower than the original file size, another exception will be thrown. For this reason a chunk request must include both the chunk and these attributes: 126 | 127 | * An `index`: indicates the current chunk that is uploading. The first index can be set in the configuration file. 128 | * A `file size`: the original file size. Will be used for the integrity check. 129 | * A `chunk size`: the chunk file size. Will be used for the integrity check. 130 | 131 | #### Configuration 132 | 133 | ```php 134 | // config/chunky.php 135 | [ 136 | 137 | /* 138 | |-------------------------------------------------------------------------- 139 | | Default disks 140 | |-------------------------------------------------------------------------- 141 | | 142 | | This option defines the disks on which to store the chunks from an upload 143 | | request as well as the final merged file. If you don't need to save the 144 | | files into a sub folder just set null as value. 145 | | 146 | */ 147 | 148 | 'disks' => [ 149 | 'chunks' => [ 150 | 'disk' => env('CHUNKY_CHUNK_DISK'), 151 | 'folder' => 'chunks', 152 | ], 153 | ], 154 | 155 | /* 156 | |-------------------------------------------------------------------------- 157 | | Default index 158 | |-------------------------------------------------------------------------- 159 | | 160 | | This option defines if chunky should start counting the chunks indexes 161 | | from 0 (ChunkySettings::INDEX_ZERO) or 1 (ChunkySettings::INDEX_ONE). You 162 | | can override this feature with any number, but the indexes must always 163 | | be index + n or the integrity check for the chunks folder will throw an 164 | | exception. 165 | | 166 | */ 167 | 168 | 'index' => \Jobtech\LaravelChunky\ChunkySettings::INDEX_ZERO, 169 | 170 | /* 171 | |-------------------------------------------------------------------------- 172 | | Additional options 173 | |-------------------------------------------------------------------------- 174 | | 175 | | This option defines the additional settings that chunky should pass to 176 | | the `storeAs` method while saving chunks or the merged file. This can be 177 | | useful, for example, when storing public files in S3 storage. 178 | | 179 | */ 180 | 181 | 'options' => [ 182 | 'chunks' => [ 183 | // 'visibility' => 'public' 184 | ], 185 | ], 186 | ]; 187 | ``` 188 | 189 | #### Chunks methods 190 | 191 | If you want to manually save a chunk from a request you can use the `addChunk` method. It gets in input the uploaded file, the chunk index and, optionally, a folder name. If no folder is passed, the chunks anyway will be stored into a chunk's root subfolder. This folder will be named as the slug of the uploaded file basename. 192 | 193 | This method will return a `Jobtech\LaravelChunky\Chunk` object, that implements the `Illuminate\Contracts\Support\Responsable` contract so you can easily return a JSON response. If the requests has the `Accept application/json` header, the object will be automatically transformed into a `Jobtech\LaravelChunky\Http\Resources\ChunkResource` object. Furthermore, every time a chunk is added a `Jobtech\LaravelChunky\Events\ChunkAdded` event is fired. 194 | 195 | ```php 196 | // ... 197 | $chunk = Chunky::addChunk( 198 | $request->file('your-file-key'), 199 | $request->input('your-index-key'), 200 | 'folder-is-optional' 201 | ); 202 | 203 | return $chunk->hideFileInfo()->toResponse(); 204 | // This will return 205 | // { 206 | // "data": { 207 | // "name": "my-very-big-file.ext", 208 | // "extension": "ext", 209 | // "index": 0, 210 | // "last": false 211 | // } 212 | // } 213 | 214 | return $chunk->showFileInfo()->toResponse(); 215 | // This will return 216 | // { 217 | // "data": { 218 | // "name": "my-very-big-file.ext", 219 | // "extension": "ext", 220 | // "index": 0, 221 | // "last": false, 222 | // "file": "/path/to/my-very-big-file.ext", 223 | // "path": "/path/to" 224 | // } 225 | // } 226 | ``` 227 | 228 | Everytime a chunk is added, a `Jobtech\LaravelChunky\Events\ChunkDeleted` event is fired. 229 | 230 | > If you're trying to add a chunk that violates the integrity of the chunks folder an exception will be thrown. 231 | > for example: 232 | > 233 | >```php 234 | > |- chunks 235 | > |- folder 236 | > |- 0_chunk.ext 237 | > |- 1_chunk.ext 238 | > |- 2_chunk.ext 239 | > 240 | > Chunk::addChunk($chunk, 4); 241 | >``` 242 | > This will throw a `Jobtech\LaravelChunky\Exceptions\ChunksIntegrityException` 243 | 244 | If you're using, for example, Dropzone you can block the upload action, in that case you will delete the currently uploaded chunks: 245 | 246 | 247 | ```php 248 | Chunky::deleteChunks('chunks-folder'); 249 | ``` 250 | Everytime a chunk is deleted, a `Jobtech\LaravelChunky\Events\ChunkDeleted` event is fired. 251 | 252 | --- 253 | 254 | The package include a method that, given the chunks folder, will return a sorted collection. Each item contains the relative chunk's path and index. 255 | 256 | ```php 257 | $chunks = Chunky::listChunks('chunks-folder-name'); 258 | 259 | foreach($chunks as $chunk) { 260 | /** @var \Jobtech\LaravelChunky\Chunk $chunk */ 261 | print_r($chunk->toArray()); 262 | } 263 | 264 | // [ 265 | // 'index' => 0, 266 | // 'path' => '/path/to/chunks-folder-name/0_chunk.ext', 267 | // [...] 268 | // ], 269 | // [ 270 | // 'index' => 1, 271 | // 'path' => '/path/to/chunks-folder-name/1_chunk.ext', 272 | // [...] 273 | // ], 274 | // [ 275 | // 'index' => 2, 276 | // 'path' => '/path/to/chunks-folder-name/2_chunk.ext', 277 | // [...] 278 | // ], 279 | // ... 280 | ``` 281 | 282 | #### Chunks request 283 | 284 | If you want to automate the chunks upload and merge (requires the value `true` for the `auto_merge` config key), you can use the `Jobtech\LaravelChunky\Http\Requests\AddChunkRequest` class. For more informations about form request please have a look at the [official documentation](https://laravel.com/docs/6.x/validation#form-request-validation). 285 | 286 | Include the form request in your method and simply call the `handle` method of the Chunky facade. The package will automatically handle the upload and return a `Jobtech\LaravelChunky\Chunk` object. 287 | 288 | ```php 289 | // Example with `auto_merge = false` 290 | 291 | use Jobtech\LaravelChunky\Http\Requests\AddChunkRequest; 292 | 293 | class UploadController extends Controller { 294 | // [...] 295 | 296 | public function chunkUpload(AddChunkRequest $request) { 297 | $chunk = Chunky::handle($request, 'folder-is-optional'); 298 | 299 | if($chunk->isLast()) { 300 | // See section below for merge or 301 | // implement your own logic 302 | } 303 | 304 | return $chunk->toResponse(); 305 | } 306 | } 307 | ``` 308 | 309 | ### Merge handler 310 | 311 | If you need to merge chunks into a single file, you can call the `merge` function that will use the configured merge handler to concatenate all the uploaded chunks into a single file. 312 | 313 | ``` 314 | public function chunkUpload(AddChunkRequest $request) { 315 | $chunk = Chunky::handle($request, 'folder-is-optional'); 316 | 317 | if($chunk->isLast()) { 318 | Chunky::merge('upload-folder', 'your/merge/file.ext'); 319 | } 320 | 321 | return $chunk->toResponse(); 322 | } 323 | ``` 324 | 325 | Once the last chunk has been uploaded and the `auto_merge` config key has `true` value, the package will automatically merge the chunks. A `Jobtech\LaravelChunky\Jobs\MergeChunks` job will be dispatched on the given connection and queue if these options have been set. 326 | 327 | ```php 328 | // config/chunky.php 329 | [ 330 | // [...] 331 | 332 | /* 333 | |-------------------------------------------------------------------------- 334 | | Merge settings 335 | |-------------------------------------------------------------------------- 336 | | 337 | | This option defines the merge handler that should be used to perform the 338 | | chunks merge once the upload is completed (automagically depending on 339 | | `auto_merge` config value. 340 | | 341 | | `connection` and `queue` keys define which queue and which connection 342 | | should be used for the merge job. If connection is null, a synchronous 343 | | job will be dispatched 344 | */ 345 | 346 | 'merge' => [ 347 | 'handler' => \Jobtech\LaravelChunky\Handlers\MergeHandler::class, 348 | 349 | 'connection' => env('CHUNKY_MERGE_CONNECTION', 'sync'), 350 | 351 | 'queue' => env('CHUNKY_MERGE_QUEUE'), 352 | ], 353 | ]; 354 | ``` 355 | 356 | You can manually dispatch the job (or if you're not using the `Jobtech\LaravelChunky\Http\Requests\AddChunkRequest` form request create your own): 357 | 358 | ```php 359 | use Jobtech\LaravelChunky\Http\Requests\AddChunkRequest; 360 | use Jobtech\LaravelChunky\Jobs\MergeChunks; 361 | 362 | class UploadController extends Controller { 363 | // [...] 364 | 365 | public function chunkUpload(AddChunkRequest $request) { 366 | $chunk = Chunky::handle($request, 'folder-is-optional'); 367 | 368 | if($chunk->isLast()) { 369 | $job = new MergeChunks($request, 'chunks-folder', 'destination/path/to/merge.ext'); 370 | 371 | dispatch( 372 | $job->onConnection('your-connection') 373 | ->onQueue('your-queue') 374 | ); 375 | } 376 | 377 | return $chunk->toResponse(); 378 | } 379 | } 380 | ``` 381 | 382 | Once the job is completed, a `Jobtech\LaravelChunky\Events\ChunksMerged` event is fired as well as once the merge file is moved to destination a `Jobtech\LaravelChunky\Events\MergeAdded` event is fired. 383 | 384 | #### Custom handler 385 | 386 | If you want to integrate your own handler, remember to implement the `Jobtech\LaravelChunky\Contracts\MergeHandler` contract (or at least implement the same methods) in your class, and update the related `handler` configuration option: 387 | 388 | ```php 389 | use Jobtech\LaravelChunky\Contracts\MergeHandler; 390 | 391 | class MyHandler implements MergeHandler { 392 | private ChunkyManager $manager; 393 | 394 | /** 395 | * @param \Jobtech\LaravelChunky\Contracts\ChunkyManager $manager 396 | * @return \Jobtech\LaravelChunky\Handlers\MergeHandler 397 | */ 398 | public function setManager(ChunkyManager $manager): MergeHandler 399 | { 400 | $this->manager = $manager; 401 | 402 | return $this; 403 | } 404 | 405 | /** 406 | * @return \Jobtech\LaravelChunky\Contracts\ChunkyManager 407 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 408 | */ 409 | public function manager(): ChunkyManager 410 | { 411 | return $this->manager; 412 | } 413 | 414 | /** 415 | * @param \Jobtech\LaravelChunky\Http\Requests\AddChunkRequest $request 416 | * @param string $folder 417 | * 418 | * @return \Illuminate\Foundation\Bus\PendingDispatch|string 419 | */ 420 | public function dispatchMerge(AddChunkRequest $request, string $folder) 421 | { 422 | // Your logic here 423 | } 424 | 425 | /** 426 | * @param string $chunks_folder 427 | * @param string $merge_destination 428 | * 429 | * @return string 430 | */ 431 | public function merge(string $chunks_folder, string $merge_destination): string 432 | { 433 | // Your logic here 434 | } 435 | 436 | /** 437 | * @return \Jobtech\LaravelChunky\Contracts\MergeHandler 438 | */ 439 | public static function instance(): MergeHandler 440 | { 441 | return new static(); 442 | } 443 | } 444 | ``` 445 | 446 | ## Testing 447 | 448 | You can run the tests with PHP unit: 449 | 450 | ```sh 451 | $ vendor/bin/phpunit 452 | ``` 453 | 454 | If you want to set custom environment variable, you can add a `.env` file for custom disks, queue or whatever you need. Tests anyway set a temporary local disk by default. 455 | 456 | ``` 457 | CHUNKY_CHUNK_DISK=s3 458 | CHUNKY_MERGE_DISK=public 459 | CHUNKY_AUTO_MERGE=false 460 | CHUNKY_MERGE_CONNECTION=redis 461 | CHUNKY_MERGE_QUEUE=my-custom-queue 462 | ``` 463 | 464 | ## Roadmap 465 | 466 | See the [open issues](https://github.com/jobtech-dev/laravel-chunky/issues) for a list of proposed features (and known issues). 467 | 468 | We're working on: 469 | 470 | * Integrate frontend chunk upload (Not sure if necessary... there are so many packages that does it). 471 | * Custom concatenation, at the moment we're using a third party package. 472 | * Better tests. 473 | * Laravel 5.5+ compatibility. 474 | 475 | ## Changelog 476 | 477 | Please see [CHANGELOG.md](https://github.com/jobtech-dev/laravel-chunky/blob/master/CHANGELOG.md) for more information what has changed recently. 478 | 479 | ## Contributing 480 | This package comes with a docker container based on php 8.1 and composer 2.2. To start it simply run `make start`. To enter the container shell you can use `make shell`. 481 | 482 | Please see [CONTRIBUTING.md](https://github.com/jobtech-dev/laravel-chunky/blob/master/CONTRIBUTING.md) for more details. 483 | 484 | ## License 485 | 486 | Distributed under the MIT License. See [LICENSE](https://github.com/jobtech-dev/laravel-chunky/blob/master/LICENSE) for more information. 487 | 488 | ## Contact 489 | 490 | Jobtech dev team - [dev@jobtech.it](mailto:dev@jobtech.it) 491 | 492 | ## Credits 493 | 494 | Laravel Chunky is a Laravel package made with :heart: by the JT nerds. 495 | 496 | Thanks to: 497 | 498 | * Filippo Galante ([ilGala](https://github.com/ilgala)) 499 | * [All contributors](https://github.com/jobtech-dev/laravel-chunky/graphs/contributors) 500 | 501 | We've used these packages for the chunks concatenation: 502 | 503 | * Keven Godet - [Flysystem concatenate](https://github.com/kevengodet/flysystem-concatenate) 504 | 505 | And this repository for the readme boilerplate: 506 | 507 | * Othneil Drew - [Best-README-Template](https://github.com/othneildrew/Best-README-Template) 508 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jobtech/laravel-chunky", 3 | "description": "A laravel manager to handle chunked files upload", 4 | "keywords": ["upload", "chunk", "laravel", "jobtech", "chunky"], 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "ilGala", 9 | "email": "filippo.galante@jobtech.it", 10 | "homepage": "https://jobtech.it" 11 | } 12 | ], 13 | "homepage": "https://github.com/jobtech-dev/laravel-chunky", 14 | "require": { 15 | "php": "^8.1", 16 | "ext-fileinfo": "*", 17 | "ext-json": "*", 18 | "illuminate/contracts": "^10.0", 19 | "illuminate/filesystem": "^10.0", 20 | "illuminate/support": "^10.0", 21 | "keven/append-stream": "^1.0.5" 22 | }, 23 | "require-dev": { 24 | "phpunit/phpunit": "^10.0", 25 | "mockery/mockery": "^1.4", 26 | "aws/aws-sdk-php": "^3.155", 27 | "league/flysystem-aws-s3-v3": "^3.0", 28 | "orchestra/testbench": "^8.21", 29 | "friendsofphp/php-cs-fixer": "^3.49" 30 | }, 31 | "suggest": { 32 | "league/flysystem-aws-s3-v3": "Required to use AWS S3 file storage" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Jobtech\\LaravelChunky\\": "src" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "Jobtech\\LaravelChunky\\Tests\\": "tests" 42 | } 43 | }, 44 | "extra": { 45 | "laravel": { 46 | "providers": [ 47 | "Jobtech\\LaravelChunky\\ChunkyServiceProvider" 48 | ] 49 | } 50 | }, 51 | "scripts": { 52 | "test": "vendor/bin/phpunit", 53 | "lint": "vendor/bin/php-cs-fixer fix" 54 | }, 55 | "minimum-stability": "dev", 56 | "prefer-stable": true 57 | } 58 | -------------------------------------------------------------------------------- /config/chunky.php: -------------------------------------------------------------------------------- 1 | [ 20 | 'chunks' => [ 21 | 'disk' => env('CHUNKY_CHUNK_DISK', 'local'), 22 | 'folder' => 'chunks', 23 | ], 24 | 'merge' => [ 25 | 'disk' => env('CHUNKY_MERGE_DISK', 'local'), 26 | 'folder' => null, 27 | ], 28 | 'tmp' => [ 29 | 'disk' => env('CHUNKY_TMP_DISK', 'local'), 30 | 'folder' => null, 31 | ], 32 | ], 33 | 34 | /* 35 | |-------------------------------------------------------------------------- 36 | | Default index 37 | |-------------------------------------------------------------------------- 38 | | 39 | | This option defines if chunky should start counting the chunks indexes 40 | | from 0 (ChunkySettings::INDEX_ZERO) or 1 (ChunkySettings::INDEX_ONE). You 41 | | can override this feature with any number, but the indexes must always 42 | | be index + n or the integrity check for the chunks folder will throw an 43 | | exception. 44 | | 45 | */ 46 | 47 | 'index' => ChunkySettings::INDEX_ZERO, 48 | 49 | /* 50 | |-------------------------------------------------------------------------- 51 | | Additional options 52 | |-------------------------------------------------------------------------- 53 | | 54 | | This option defines the additional settings that chunky should pass to 55 | | the `storeAs` method while saving chunks or the merged file. This can be 56 | | useful, for example, when storing public files in S3 storage. 57 | | 58 | */ 59 | 60 | 'options' => [ 61 | 'chunks' => [], 62 | 63 | 'merge' => [ 64 | // 'visibility' => 'public' 65 | ], 66 | ], 67 | 68 | /* 69 | |-------------------------------------------------------------------------- 70 | | Validation rules 71 | |-------------------------------------------------------------------------- 72 | | 73 | | When using a chunk request these rules will be used to validate the input. 74 | | `index`, `file`, `chunkSize` and `totalSize` attributes are mandatory in 75 | | order to let chunky work properly, so if missing from the configuration 76 | | file an exception will be thrown. 77 | */ 78 | 79 | 'validation' => [ 80 | // Mandatory 81 | 'index' => [ 82 | 'key' => 'index', 83 | 'rules' => ['required', 'integer', 'min:0'], 84 | ], 85 | 'file' => [ 86 | 'key' => 'file', 87 | 'rules' => ['required', 'file'], 88 | ], 89 | 'chunkSize' => [ 90 | 'key' => 'chunkSize', 91 | 'rules' => ['required', 'integer', 'min:1'], 92 | ], 93 | 'totalSize' => [ 94 | 'key' => 'totalSize', 95 | 'rules' => ['required', 'integer', 'min:1'], 96 | ], 97 | // Optional 98 | 'folder' => [ 99 | 'key' => 'folder', 100 | 'rules' => ['filled', 'string'], 101 | ], 102 | // -------------------------------------------------------------------------- 103 | // Dropzone chunk uploads example 104 | // -------------------------------------------------------------------------- 105 | // 'index' => [ 106 | // 'key' => 'dzchunkindex', 107 | // 'rules' => ['required', 'integer', 'min:0'] 108 | // ], 109 | // 'file' => [ 110 | // 'key' => 'file', 111 | // 'rules' => ['required', 'file'] 112 | // ], 113 | // 'chunkSize' => [ 114 | // 'key' => 'dzchunksize', 115 | // 'rules' => ['required', 'integer', 'min:1'] 116 | // ], 117 | // 'totalSize' => [ 118 | // 'key' => 'dztotalfilesize', 119 | // 'rules' => ['required', 'integer', 'min:1'] 120 | // ], 121 | // // Optional 122 | // 'folder' => [ 123 | // 'key' => 'dzuuid', 124 | // 'rules' => ['required', 'string'] 125 | // ] 126 | // -------------------------------------------------------------------------- 127 | // Resumable js chunk uploads example 128 | // -------------------------------------------------------------------------- 129 | // 'index' => [ 130 | // 'key' => 'resumableChunkNumber', 131 | // 'rules' => ['required', 'integer', 'min:0'] 132 | // ], 133 | // 'file' => [ 134 | // 'key' => 'file', 135 | // 'rules' => ['required', 'file'] 136 | // ], 137 | // 'chunkSize' => [ 138 | // 'key' => 'resumableChunkSize', 139 | // 'rules' => ['required', 'integer', 'min:1'] 140 | // ], 141 | // 'totalSize' => [ 142 | // 'key' => 'resumableTotalSize', 143 | // 'rules' => ['required', 'integer', 'min:1'] 144 | // ], 145 | // // Optional 146 | // 'folder' => [ 147 | // 'key' => 'resumableIdentifier', 148 | // 'rules' => ['required', 'string'] 149 | // ] 150 | ], 151 | 152 | /* 153 | |-------------------------------------------------------------------------- 154 | | Chunk json resource 155 | |-------------------------------------------------------------------------- 156 | | 157 | | This option defines the class to use when a chunk is transformed to a 158 | | json resource and returned as response. 159 | | 160 | */ 161 | 162 | 'resource' => ChunkResource::class, 163 | 164 | /* 165 | |-------------------------------------------------------------------------- 166 | | Automatically merge chunks 167 | |-------------------------------------------------------------------------- 168 | | 169 | | This option defines if chunky should automatically dispatch a merge job 170 | | once the last chunk has been upload. 171 | | 172 | */ 173 | 174 | 'auto_merge' => env('CHUNKY_AUTO_MERGE', true), 175 | 176 | /* 177 | |-------------------------------------------------------------------------- 178 | | Merge settings 179 | |-------------------------------------------------------------------------- 180 | | 181 | | This option defines the merge handler that should be used to perform the 182 | | chunks merge once the upload is completed (automagically depending on 183 | | `auto_merge` config value. 184 | | 185 | | `connection` and `queue` keys define which queue and which connection 186 | | should be used for the merge job. If connection is null, a synchronous 187 | | job will be dispatched 188 | */ 189 | 190 | 'merge' => [ 191 | 'handler' => MergeHandler::class, 192 | 193 | 'connection' => env('CHUNKY_MERGE_CONNECTION', 'sync'), 194 | 195 | 'queue' => env('CHUNKY_MERGE_QUEUE'), 196 | ], 197 | ]; 198 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | services: 4 | laravel-chunky: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | container_name: laravel-chunky 9 | working_dir: /var/www/html 10 | volumes: 11 | - ./:/var/www/html 12 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal 2 | title: Laravel Chunky 3 | description: A laravel manager to handle chunked files upload 4 | logo: https://user-images.githubusercontent.com/1577699/100456224-4ab28680-30c0-11eb-8452-e6a674f3dcdb.png 5 | show_downloads: true -------------------------------------------------------------------------------- /docs/_layouts/default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% seo %} 9 | 10 | 13 | 14 | 15 |
16 |
17 |

{{ site.title | default: site.github.repository_name }}

18 | 19 | {% if site.logo %} 20 | Logo 21 | {% endif %} 22 | 23 |

{{ site.description | default: site.github.project_tagline }}

24 | 25 | {% if site.github.is_project_page %} 26 |

View the Project on GitHub {{ site.github.repository_nwo }}

27 | {% endif %} 28 | 29 | {% if site.github.is_user_page %} 30 |

View My GitHub Profile

31 | {% endif %} 32 | 33 | {% if site.show_downloads %} 34 | 39 | {% endif %} 40 |
41 |
42 | 43 | {{ content }} 44 | 45 |
46 | 52 |
53 | 54 | {% if site.google_analytics %} 55 | 63 | {% endif %} 64 | 65 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 | Logo 5 | 6 | 7 |

8 | This package handles chunked files upload requests in order to safely save chunked files and, once the upload has been completed, merge all the chunks into a single file. 9 |
10 |
11 | Report Bug 12 | · 13 | Request Feature 14 |

15 |

16 | 17 | [![MIT License](https://img.shields.io/github/license/jobtech-dev/laravel-chunky.svg?style=flat-square)](https://github.com/jobtech-dev/laravel-chunky/blob/master/LICENSE.txt) 18 | [![Build status](https://github.com/jobtech-dev/laravel-chunky/workflows/tests/badge.svg)](https://github.com/jobtech-dev/laravel-chunky/actions) 19 | [![StyleCI](https://github.styleci.io/repos/291024576/shield?branch=master)](https://github.styleci.io/repos/291024576?branch=master) 20 | [![GitHub stars](https://img.shields.io/github/stars/jobtech-dev/laravel-chunky)](https://github.com/jobtech-dev/laravel-chunky/stargazers) 21 | [![GitHub issues](https://img.shields.io/github/issues/jobtech-dev/laravel-chunky)](https://github.com/jobtech-dev/laravel-chunky/issues) 22 | [![LinkedIn](https://img.shields.io/badge/-LinkedIn-black.svg?style=flat-square&logo=linkedin&colorB=555)](https://linkedin.com/in/jobtech-srl) 23 | 24 | ## Table of Contents 25 | 26 | * [Getting Started](#getting-started) 27 | * [Main features](#main-features) 28 | * [Installation](#installation) 29 | * [Usage](#usage) 30 | * [Chunks](#chunks) 31 | * [Merge handler](#merge-handler) 32 | * [Testing](#testing) 33 | * [Roadmap](#roadmap) 34 | * [Changelog](#changelog) 35 | * [Contributing](#contributing) 36 | * [License](#license) 37 | * [Contact](#contact) 38 | * [Credits](#credits) 39 | 40 | ## Getting Started 41 | 42 | Laravel chunky is a package that can handle chunk upload for large files in Laravel 6.x, 7.x. and 8.x. Its main goal is automatically handle the upload request (see the [usage](#usage) section below) and save all the chunks into the desired disk. 43 | 44 | Once the upload completes, the package will dispatch a job in order to merge all the files into a single one and save in the same chunks disks or in another one. 45 | 46 | ### Main features 47 | * Handle chunks upload with custom save disks and folders. 48 | * Handle file merge with custom save disks and folders. 49 | * Once the merge is done, the chunks folder is automatically cleared. 50 | 51 | ### Installation 52 | 53 | In order to install Laravel Chunky into your project you have to require it via composer. 54 | 55 | ```sh 56 | $ composer require jobtech/laravel-chunky 57 | ``` 58 | 59 | Laravel uses Package Auto-Discovery and the `ChunkyServiceProvider` will be automatically registered. If you are using a Laravel version lower than 5.5 or you're not using autodiscovery, manually register the service provider: 60 | 61 | ```php 62 | // config/app.php 63 | [ 64 | // [...] 65 | 'providers' => [ 66 | // [...] 67 | Jobtech\LaravelChunky\ChunkyServiceProvider::class, 68 | ] 69 | ]; 70 | ``` 71 | 72 | You can also register an alias for the `Chunky` facade: 73 | 74 | ```php 75 | // config/app.php 76 | [ 77 | // [...] 78 | 'aliases' => [ 79 | // [...] 80 | 'Chunky' => Jobtech\LaravelChunky\Facades\Chunky::class, 81 | ] 82 | ]; 83 | ``` 84 | 85 | #### Configuration 86 | 87 | To publish the configuration file, run the following command: 88 | 89 | ```sh 90 | $ php artisan vendor:publish --provider="Jobtech\LaravelChunky\ChunkyServiceProvider" --tag="config" 91 | ``` 92 | 93 | #### Lumen 94 | 95 | This package can also work with Lumen, just register the service provider in `bootstrap/app.php`: 96 | 97 | ```php 98 | $app->register(Jobtech\LaravelChunky\ChunkyServiceProvider::class); 99 | ``` 100 | 101 | In order to configure the package, since lumen doesn't include the `vendor:publish` command, copy the configuration file to your config folder and enable it: 102 | 103 | ```php 104 | $app->configure('chunky'); 105 | ``` 106 | 107 | ## Usage 108 | 109 | This package has been designed to leave you the full control of the chunks upload and simple use the helper methods to handle the files merge as well as an _all-in-one_ solution for a fast scaffolding of the controllers delegated to handle large files upload. 110 | 111 | At the moment, this package doesn't include any wrapper for the frontend forms for the file uploads but, in the `config/chunky.php` configuration file, you can find two ways of integrate the package with [Dropzone](https://www.dropzonejs.com/) and [ResumableJs](http://resumablejs.com/). 112 | 113 | ### Chunks 114 | 115 | Laravel Chunky handles the chunks as an _ordered list_ of files. This is a **must** and if a wrong file index has been uploaded, an exception will be thrown in order to guarantee the integrity of the final merged file. Once all the chunks have been uploaded, and the merge process is executing, another integrity check will be made to all the chunks. If the sum of each file size is lower than the original file size, another exception will be thrown. For this reason a chunk request must include both the chunk and these attributes: 116 | 117 | * An `index`: indicates the current chunk that is uploading. The first index can be set in the configuration file. 118 | * A `file size`: the original file size. Will be used for the integrity check. 119 | * A `chunk size`: the chunk file size. Will be used for the integrity check. 120 | 121 | #### Configuration 122 | 123 | ```php 124 | // config/chunky.php 125 | [ 126 | 127 | /* 128 | |-------------------------------------------------------------------------- 129 | | Default disks 130 | |-------------------------------------------------------------------------- 131 | | 132 | | This option defines the disks on which to store the chunks from an upload 133 | | request as well as the final merged file. If you don't need to save the 134 | | files into a sub folder just set null as value. 135 | | 136 | */ 137 | 138 | 'disks' => [ 139 | 'chunks' => [ 140 | 'disk' => env('CHUNKY_CHUNK_DISK'), 141 | 'folder' => 'chunks', 142 | ], 143 | ], 144 | 145 | /* 146 | |-------------------------------------------------------------------------- 147 | | Default index 148 | |-------------------------------------------------------------------------- 149 | | 150 | | This option defines if chunky should start counting the chunks indexes 151 | | from 0 (ChunkySettings::INDEX_ZERO) or 1 (ChunkySettings::INDEX_ONE). You 152 | | can override this feature with any number, but the indexes must always 153 | | be index + n or the integrity check for the chunks folder will throw an 154 | | exception. 155 | | 156 | */ 157 | 158 | 'index' => \Jobtech\LaravelChunky\ChunkySettings::INDEX_ZERO, 159 | 160 | /* 161 | |-------------------------------------------------------------------------- 162 | | Additional options 163 | |-------------------------------------------------------------------------- 164 | | 165 | | This option defines the additional settings that chunky should pass to 166 | | the `storeAs` method while saving chunks or the merged file. This can be 167 | | useful, for example, when storing public files in S3 storage. 168 | | 169 | */ 170 | 171 | 'options' => [ 172 | 'chunks' => [ 173 | // 'visibility' => 'public' 174 | ], 175 | ], 176 | ]; 177 | ``` 178 | 179 | #### Chunks methods 180 | 181 | If you want to manually save a chunk from a request you can use the `addChunk` method. It gets in input the uploaded file, the chunk index and, optionally, a folder name. If no folder is passed, the chunks anyway will be stored into a chunk's root subfolder. This folder will be named as the slug of the uploaded file basename. 182 | 183 | This method will return a `Jobtech\LaravelChunky\Chunk` object, that implements the `Illuminate\Contracts\Support\Responsable` contract so you can easily return a JSON response. If the requests has the `Accept application/json` header, the object will be automatically transformed into a `Jobtech\LaravelChunky\Http\Resources\ChunkResource` object. Furthermore, every time a chunk is added a `Jobtech\LaravelChunky\Events\ChunkAdded` event is fired. 184 | 185 | ```php 186 | // ... 187 | $chunk = Chunky::addChunk( 188 | $request->file('your-file-key'), 189 | $request->input('your-index-key'), 190 | 'folder-is-optional' 191 | ); 192 | 193 | return $chunk->hideFileInfo()->toResponse(); 194 | // This will return 195 | // { 196 | // "data": { 197 | // "name": "my-very-big-file.ext", 198 | // "extension": "ext", 199 | // "index": 0, 200 | // "last": false 201 | // } 202 | // } 203 | 204 | return $chunk->showFileInfo()->toResponse(); 205 | // This will return 206 | // { 207 | // "data": { 208 | // "name": "my-very-big-file.ext", 209 | // "extension": "ext", 210 | // "index": 0, 211 | // "last": false, 212 | // "file": "/path/to/my-very-big-file.ext", 213 | // "path": "/path/to" 214 | // } 215 | // } 216 | ``` 217 | 218 | Everytime a chunk is added, a `Jobtech\LaravelChunky\Events\ChunkDeleted` event is fired. 219 | 220 | > If you're trying to add a chunk that violates the integrity of the chunks folder an exception will be thrown. 221 | > for example: 222 | > 223 | >```php 224 | > |- chunks 225 | > |- folder 226 | > |- 0_chunk.ext 227 | > |- 1_chunk.ext 228 | > |- 2_chunk.ext 229 | > 230 | > Chunk::addChunk($chunk, 4); 231 | >``` 232 | > This will throw a `Jobtech\LaravelChunky\Exceptions\ChunksIntegrityException` 233 | 234 | If you're using, for example, Dropzone you can block the upload action, in that case you will delete the currently uploaded chunks: 235 | 236 | 237 | ```php 238 | Chunky::deleteChunks('chunks-folder'); 239 | ``` 240 | Everytime a chunk is deleted, a `Jobtech\LaravelChunky\Events\ChunkDeleted` event is fired. 241 | 242 | --- 243 | 244 | The package include a method that, given the chunks folder, will return a sorted collection. Each item contains the relative chunk's path and index. 245 | 246 | ```php 247 | $chunks = Chunky::listChunks('chunks-folder-name'); 248 | 249 | foreach($chunks as $chunk) { 250 | /** @var \Jobtech\LaravelChunky\Chunk $chunk */ 251 | print_r($chunk->toArray()); 252 | } 253 | 254 | // [ 255 | // 'index' => 0, 256 | // 'path' => '/path/to/chunks-folder-name/0_chunk.ext', 257 | // [...] 258 | // ], 259 | // [ 260 | // 'index' => 1, 261 | // 'path' => '/path/to/chunks-folder-name/1_chunk.ext', 262 | // [...] 263 | // ], 264 | // [ 265 | // 'index' => 2, 266 | // 'path' => '/path/to/chunks-folder-name/2_chunk.ext', 267 | // [...] 268 | // ], 269 | // ... 270 | ``` 271 | 272 | #### Chunks request 273 | 274 | If you want to automate the chunks upload and merge (requires the value `true` for the `auto_merge` config key), you can use the `Jobtech\LaravelChunky\Http\Requests\AddChunkRequest` class. For more informations about form request please have a look at the [official documentation](https://laravel.com/docs/6.x/validation#form-request-validation). 275 | 276 | Include the form request in your method and simply call the `handle` method of the Chunky facade. The package will automatically handle the upload and return a `Jobtech\LaravelChunky\Chunk` object. 277 | 278 | ```php 279 | // Example with `auto_merge = false` 280 | 281 | use Jobtech\LaravelChunky\Http\Requests\AddChunkRequest; 282 | 283 | class UploadController extends Controller { 284 | // [...] 285 | 286 | public function chunkUpload(AddChunkRequest $request) { 287 | $chunk = Chunky::handle($request, 'folder-is-optional'); 288 | 289 | if($chunk->isLast()) { 290 | // See section below for merge or 291 | // implement your own logic 292 | } 293 | 294 | return $chunk->toResponse(); 295 | } 296 | } 297 | ``` 298 | 299 | ### Merge handler 300 | 301 | If you need to merge chunks into a single file, you can call the `merge` function that will use the configured merge handler to concatenate all the uploaded chunks into a single file. 302 | 303 | ``` 304 | public function chunkUpload(AddChunkRequest $request) { 305 | $chunk = Chunky::handle($request, 'folder-is-optional'); 306 | 307 | if($chunk->isLast()) { 308 | Chunky::merge('upload-folder', 'your/merge/file.ext'); 309 | } 310 | 311 | return $chunk->toResponse(); 312 | } 313 | ``` 314 | 315 | Once the last chunk has been uploaded and the `auto_merge` config key has `true` value, the package will automatically merge the chunks. A `Jobtech\LaravelChunky\Jobs\MergeChunks` job will be dispatched on the given connection and queue if these options have been set. 316 | 317 | ```php 318 | // config/chunky.php 319 | [ 320 | // [...] 321 | 322 | /* 323 | |-------------------------------------------------------------------------- 324 | | Merge settings 325 | |-------------------------------------------------------------------------- 326 | | 327 | | This option defines the merge handler that should be used to perform the 328 | | chunks merge once the upload is completed (automagically depending on 329 | | `auto_merge` config value. 330 | | 331 | | `connection` and `queue` keys define which queue and which connection 332 | | should be used for the merge job. If connection is null, a synchronous 333 | | job will be dispatched 334 | */ 335 | 336 | 'merge' => [ 337 | 'handler' => \Jobtech\LaravelChunky\Handlers\MergeHandler::class, 338 | 339 | 'connection' => env('CHUNKY_MERGE_CONNECTION', 'sync'), 340 | 341 | 'queue' => env('CHUNKY_MERGE_QUEUE'), 342 | ], 343 | ]; 344 | ``` 345 | 346 | You can manually dispatch the job (or if you're not using the `Jobtech\LaravelChunky\Http\Requests\AddChunkRequest` form request create your own): 347 | 348 | ```php 349 | use Jobtech\LaravelChunky\Http\Requests\AddChunkRequest; 350 | use Jobtech\LaravelChunky\Jobs\MergeChunks; 351 | 352 | class UploadController extends Controller { 353 | // [...] 354 | 355 | public function chunkUpload(AddChunkRequest $request) { 356 | $chunk = Chunky::handle($request, 'folder-is-optional'); 357 | 358 | if($chunk->isLast()) { 359 | $job = new MergeChunks($request, 'chunks-folder', 'destination/path/to/merge.ext'); 360 | 361 | dispatch( 362 | $job->onConnection('your-connection') 363 | ->onQueue('your-queue') 364 | ); 365 | } 366 | 367 | return $chunk->toResponse(); 368 | } 369 | } 370 | ``` 371 | 372 | Once the job is completed, a `Jobtech\LaravelChunky\Events\ChunksMerged` event is fired as well as once the merge file is moved to destination a `Jobtech\LaravelChunky\Events\MergeAdded` event is fired. 373 | 374 | #### Custom handler 375 | 376 | If you want to integrate your own handler, remember to implement the `Jobtech\LaravelChunky\Contracts\MergeHandler` contract (or at least implement the same methods) in your class, and update the related `handler` configuration option: 377 | 378 | ```php 379 | use Jobtech\LaravelChunky\Contracts\MergeHandler; 380 | 381 | class MyHandler implements MergeHandler { 382 | private ChunkyManager $manager; 383 | 384 | /** 385 | * @param \Jobtech\LaravelChunky\Contracts\ChunkyManager $manager 386 | * @return \Jobtech\LaravelChunky\Handlers\MergeHandler 387 | */ 388 | public function setManager(ChunkyManager $manager): MergeHandler 389 | { 390 | $this->manager = $manager; 391 | 392 | return $this; 393 | } 394 | 395 | /** 396 | * @return \Jobtech\LaravelChunky\Contracts\ChunkyManager 397 | * @throws \Illuminate\Contracts\Container\BindingResolutionException 398 | */ 399 | public function manager(): ChunkyManager 400 | { 401 | return $this->manager; 402 | } 403 | 404 | /** 405 | * @param \Jobtech\LaravelChunky\Http\Requests\AddChunkRequest $request 406 | * @param string $folder 407 | * 408 | * @return \Illuminate\Foundation\Bus\PendingDispatch|string 409 | */ 410 | public function dispatchMerge(AddChunkRequest $request, string $folder) 411 | { 412 | // Your logic here 413 | } 414 | 415 | /** 416 | * @param string $chunks_folder 417 | * @param string $merge_destination 418 | * 419 | * @return string 420 | */ 421 | public function merge(string $chunks_folder, string $merge_destination): string 422 | { 423 | // Your logic here 424 | } 425 | 426 | /** 427 | * @return \Jobtech\LaravelChunky\Contracts\MergeHandler 428 | */ 429 | public static function instance(): MergeHandler 430 | { 431 | return new static(); 432 | } 433 | } 434 | ``` 435 | 436 | ## Testing 437 | 438 | You can run the tests with PHP unit: 439 | 440 | ```sh 441 | $ vendor/bin/phpunit 442 | ``` 443 | 444 | If you want to set custom environment variable, you can add a `.env` file for custom disks, queue or whatever you need. Tests anyway set a temporary local disk by default. 445 | 446 | ``` 447 | CHUNKY_CHUNK_DISK=s3 448 | CHUNKY_MERGE_DISK=public 449 | CHUNKY_AUTO_MERGE=false 450 | CHUNKY_MERGE_CONNECTION=redis 451 | CHUNKY_MERGE_QUEUE=my-custom-queue 452 | ``` 453 | 454 | ## Roadmap 455 | 456 | See the [open issues](https://github.com/jobtech-dev/laravel-chunky/issues) for a list of proposed features (and known issues). 457 | 458 | We're working on: 459 | 460 | * Integrate frontend chunk upload (Not sure if necessary... there are so many packages that does it). 461 | * Custom concatenation, at the moment we're using a third party package. 462 | * Better tests. 463 | * Laravel 5.5+ compatibility. 464 | 465 | ## Changelog 466 | 467 | Please see [CHANGELOG.md](https://github.com/jobtech-dev/laravel-chunky/blob/master/CHANGELOG.md) for more information what has changed recently. 468 | 469 | ## Contributing 470 | 471 | Please see [CONTRIBUTING.md](https://github.com/jobtech-dev/laravel-chunky/blob/master/CONTRIBUTING.md) for more details. 472 | 473 | ## License 474 | 475 | Distributed under the MIT License. See [LICENSE](https://github.com/jobtech-dev/laravel-chunky/blob/master/LICENSE) for more information. 476 | 477 | ## Contact 478 | 479 | Jobtech dev team - [dev@jobtech.it](mailto:dev@jobtech.it) 480 | 481 | ## Credits 482 | 483 | Laravel Chunky is a Laravel package made with :heart: by the JT nerds. 484 | 485 | Thanks to: 486 | 487 | * Filippo Galante ([ilGala](https://github.com/ilgala)) 488 | * [All contributors](https://github.com/jobtech-dev/laravel-chunky/graphs/contributors) 489 | 490 | We've used these packages for the chunks concatenation: 491 | 492 | * Keven Godet - [Flysystem concatenate](https://github.com/kevengodet/flysystem-concatenate) 493 | 494 | And this repository for the readme boilerplate: 495 | 496 | * Othneil Drew - [Best-README-Template](https://github.com/othneildrew/Best-README-Template) 497 | -------------------------------------------------------------------------------- /src/Chunk.php: -------------------------------------------------------------------------------- 1 | index = $index; 38 | $this->path = $path; 39 | $this->disk = $disk; 40 | $this->last = $last; 41 | } 42 | 43 | /** 44 | * @return int 45 | */ 46 | public function getIndex(): int 47 | { 48 | return $this->index; 49 | } 50 | 51 | /** 52 | * @return string 53 | */ 54 | public function getPath(): string 55 | { 56 | if ($this->path instanceof File) { 57 | return $this->path->getRealPath(); 58 | } 59 | 60 | return $this->path; 61 | } 62 | 63 | /** 64 | * @return mixed 65 | */ 66 | public function getOriginalPath() 67 | { 68 | return $this->path; 69 | } 70 | 71 | /** 72 | * @param $path 73 | */ 74 | public function setPath($path) 75 | { 76 | $this->path = $path; 77 | } 78 | 79 | /** 80 | * @param string|null $suffix 81 | * 82 | * @return string 83 | */ 84 | public function getFilename($suffix = null): string 85 | { 86 | if ($this->path instanceof UploadedFile) { 87 | return basename($this->path->getClientOriginalName(), $suffix); 88 | } 89 | 90 | if ($this->path instanceof File) { 91 | return $this->path->getBasename($suffix); 92 | } 93 | 94 | return basename($this->path, $suffix); 95 | } 96 | 97 | /** 98 | * @return string 99 | */ 100 | public function getName(): string 101 | { 102 | return pathinfo( 103 | $this->getFilename($this->getExtension()), 104 | PATHINFO_FILENAME 105 | ); 106 | } 107 | 108 | /** 109 | * @return string|string[] 110 | */ 111 | public function getExtension() 112 | { 113 | return pathinfo($this->getFilename(), PATHINFO_EXTENSION); 114 | } 115 | 116 | /** 117 | * @return string 118 | */ 119 | public function getSlug(): string 120 | { 121 | return $this->index.'_'.Str::slug($this->getName()).'.'.$this->getExtension(); 122 | } 123 | 124 | /** 125 | * Retrieve the chunk file disk. 126 | * 127 | * @return string|null 128 | */ 129 | public function getDisk(): ?string 130 | { 131 | return $this->disk; 132 | } 133 | 134 | /** 135 | * Set the chunk file disk. 136 | * 137 | * @param string|null $disk 138 | */ 139 | public function setDisk($disk = null) 140 | { 141 | $this->disk = $disk; 142 | } 143 | 144 | /** 145 | * @return bool 146 | */ 147 | public function isLast(): bool 148 | { 149 | return $this->last; 150 | } 151 | 152 | /** 153 | * @param bool $last 154 | */ 155 | public function setLast(bool $last): void 156 | { 157 | $this->last = $last; 158 | } 159 | 160 | /** 161 | * If this method is called, when a chunk is turned to array, the file path and real path 162 | * will be omitted. 163 | * 164 | * @return $this 165 | */ 166 | public function hideFileInfo() 167 | { 168 | $this->show_file_info = false; 169 | 170 | return $this; 171 | } 172 | 173 | /** 174 | * If this method is called, when a chunk is turned to array, the file path and real path 175 | * will be included. 176 | * 177 | * @return $this 178 | */ 179 | public function showFileInfo() 180 | { 181 | $this->show_file_info = true; 182 | 183 | return $this; 184 | } 185 | 186 | /** 187 | * {@inheritdoc} 188 | */ 189 | public function toArray(): array 190 | { 191 | $extension = $this->getExtension(); 192 | 193 | $data = [ 194 | 'name' => $this->getName(), 195 | 'extension' => $extension, 196 | 'index' => $this->getIndex(), 197 | 'last' => $this->isLast(), 198 | ]; 199 | 200 | if ($this->show_file_info) { 201 | $data['file'] = $this->getFilename(); 202 | $data['path'] = $this->getPath(); 203 | } 204 | 205 | return $data; 206 | } 207 | 208 | /** 209 | * {@inheritdoc} 210 | */ 211 | public function toJson($options = 0): string 212 | { 213 | return json_encode($this->toArray(), $options); 214 | } 215 | 216 | /** 217 | * {@inheritdoc} 218 | */ 219 | public function toResponse($request) 220 | { 221 | return $this->toResource() 222 | ->toResponse($request); 223 | } 224 | 225 | /** 226 | * Transforms the current model into a json resource. 227 | */ 228 | public function toResource(): JsonResource 229 | { 230 | /** @var JsonResource $resource */ 231 | $resource = config('chunky.resource', ChunkResource::class); 232 | 233 | return new $resource($this); 234 | } 235 | 236 | /** 237 | * @param string|\Symfony\Component\HttpFoundation\File\File $file 238 | * @param int $index 239 | * @param array $options 240 | * 241 | * @return Chunk 242 | */ 243 | public static function create($file, int $index, $options = []) 244 | { 245 | return new static($index, $file, Arr::pull($options, 'disk')); 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/ChunkyManager.php: -------------------------------------------------------------------------------- 1 | settings = $settings; 38 | 39 | $this->setChunksFilesystem($settings->chunksDisk(), $settings->chunksFolder()); 40 | $this->setMergeFilesystem($settings->mergeDisk(), $settings->mergeFolder()); 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | public function settings(): ChunkySettings 47 | { 48 | return $this->settings; 49 | } 50 | 51 | /** 52 | * {@inheritdoc} 53 | */ 54 | public function setChunksFilesystem(string $disk, string $folder): ChunkyManager 55 | { 56 | $this->chunksFilesystem = ChunksFilesystem::instance( 57 | compact('disk', 'folder') 58 | ); 59 | 60 | return $this; 61 | } 62 | 63 | /** 64 | * {@inheritdoc} 65 | */ 66 | public function chunksFilesystem(): ChunksFilesystem 67 | { 68 | return $this->chunksFilesystem; 69 | } 70 | 71 | /** 72 | * {@inheritdoc} 73 | */ 74 | public function setMergeFilesystem(string $disk, string $folder): ChunkyManager 75 | { 76 | $this->mergeFilesystem = MergeFilesystem::instance( 77 | compact('disk', 'folder') 78 | ); 79 | 80 | return $this; 81 | } 82 | 83 | /** 84 | * {@inheritdoc} 85 | */ 86 | public function mergeFilesystem(): MergeFilesystem 87 | { 88 | return $this->mergeFilesystem; 89 | } 90 | 91 | /** 92 | * {@inheritdoc} 93 | * 94 | * @codeCoverageIgnore 95 | */ 96 | public function mergeHandler(): MergeHandler 97 | { 98 | return $this->settings->mergeHandler()->setManager($this); 99 | } 100 | 101 | /** 102 | * {@inheritdoc} 103 | */ 104 | public function chunksDisk(): ?string 105 | { 106 | return $this->settings 107 | ->chunksDisk(); 108 | } 109 | 110 | /** 111 | * {@inheritdoc} 112 | */ 113 | public function mergeDisk(): ?string 114 | { 115 | return $this->settings 116 | ->mergeDisk(); 117 | } 118 | 119 | /** 120 | * {@inheritdoc} 121 | */ 122 | public function chunksFolder(): string 123 | { 124 | return $this->settings 125 | ->chunksFolder(); 126 | } 127 | 128 | /** 129 | * {@inheritdoc} 130 | */ 131 | public function mergeFolder(): string 132 | { 133 | return $this->settings 134 | ->mergeFolder(); 135 | } 136 | 137 | /** 138 | * {@inheritdoc} 139 | */ 140 | public function chunksOptions(): array 141 | { 142 | return array_merge([ 143 | 'disk' => $this->chunksDisk(), 144 | ], $this->settings->additionalChunksOptions()); 145 | } 146 | 147 | /** 148 | * {@inheritdoc} 149 | */ 150 | public function mergeOptions(): array 151 | { 152 | return $this->settings->additionalMergeOptions(); 153 | } 154 | 155 | /** 156 | * {@inheritdoc} 157 | */ 158 | public function listChunks(?string $folder = null): Collection 159 | { 160 | return $this->chunksFilesystem()->listChunks($folder); 161 | } 162 | 163 | /** 164 | * {@inheritdoc} 165 | */ 166 | public function readChunk($chunk) 167 | { 168 | if ($chunk instanceof Chunk) { 169 | $chunk = $chunk->getPath(); 170 | } 171 | 172 | return $this->chunksFilesystem() 173 | ->readChunk($chunk); 174 | } 175 | 176 | /** 177 | * {@inheritdoc} 178 | */ 179 | public function addChunk(UploadedFile $file, int $index, string $folder): Chunk 180 | { 181 | // Check integrity 182 | if (!$this->checkChunks($folder, $index)) { 183 | throw new ChunksIntegrityException("Uploaded chunk with index {$index} violates the integrity"); 184 | } 185 | 186 | $chunk = Chunk::create($file, $index, $this->chunksOptions()); 187 | 188 | // Store chunk 189 | return $this->chunksFilesystem() 190 | ->store($chunk, $folder, $this->chunksOptions()); 191 | } 192 | 193 | /** 194 | * {@inheritdoc} 195 | */ 196 | public function deleteChunks(string $folder): bool 197 | { 198 | return $this->chunksFilesystem()->delete($folder); 199 | } 200 | 201 | /** 202 | * @param string $folder 203 | * 204 | * @return bool 205 | */ 206 | public function isValidChunksFolder(string $folder): bool 207 | { 208 | return $this->chunksFilesystem()->exists($folder); 209 | } 210 | 211 | /** 212 | * {@inheritdoc} 213 | */ 214 | public function checkChunks(string $folder, int $index): bool 215 | { 216 | $default = $this->settings->defaultIndex(); 217 | 218 | if (!$this->chunksFilesystem()->exists($folder) && $index != $default) { 219 | return false; 220 | } 221 | 222 | if ($this->chunksFilesystem()->exists($folder)) { 223 | if ($default != ChunkySettings::INDEX_ZERO) { 224 | $index -= $default; 225 | } 226 | 227 | return $this->chunksFilesystem()->chunksCount($folder) == $index; 228 | } 229 | 230 | if ($index == $default) { 231 | if (!$this->chunksFilesystem()->makeDirectory($folder)) { 232 | throw new ChunksIntegrityException("Cannot create chunks folder {$folder}"); 233 | } 234 | } 235 | 236 | return true; 237 | } 238 | 239 | /** 240 | * {@inheritdoc} 241 | */ 242 | public function checkChunksIntegrity(string $folder, int $chunk_size, int $total_size): bool 243 | { 244 | $total = 0; 245 | $chunks = $this->listChunks($folder); 246 | 247 | foreach ($chunks as $chunk) { 248 | $size = $this->chunksFilesystem()->chunkSize($chunk->getPath()); 249 | 250 | if ($size < $chunk_size && !$chunk->isLast()) { 251 | return false; 252 | } 253 | 254 | $total += $size; 255 | } 256 | 257 | return $total >= $total_size; 258 | } 259 | 260 | /** 261 | * {@inheritdoc} 262 | */ 263 | public function handle(AddChunkRequest $request, ?string $folder = null): Chunk 264 | { 265 | // Store chunk 266 | $folder = $this->checkFolder($request, $folder); 267 | $chunk = $this->addChunk( 268 | $request->fileInput(), 269 | $request->indexInput(), 270 | $folder 271 | ); 272 | 273 | $chunk->setLast( 274 | $this->isLastIndex($request) 275 | ); 276 | 277 | // Check merge 278 | if ($chunk->isLast() && $this->settings->autoMerge()) { 279 | $this->mergeHandler()->dispatchMerge($request, $folder); 280 | } 281 | 282 | return $chunk; 283 | } 284 | 285 | /** 286 | * {@inheritdoc} 287 | */ 288 | public function merge(string $chunks_folder, string $merge_path): string 289 | { 290 | return $this->mergeHandler()->merge($chunks_folder, $merge_path); 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /src/ChunkyServiceProvider.php: -------------------------------------------------------------------------------- 1 | setupConfig(); 24 | } 25 | 26 | /** 27 | * Register any application services. 28 | */ 29 | public function register() 30 | { 31 | $this->registerBindings(); 32 | $this->registerCommands(); 33 | 34 | $this->app->alias(ChunkyManagerContract::class, 'chunky'); 35 | } 36 | 37 | protected function setupConfig() 38 | { 39 | $source = realpath(__DIR__.'/../config/chunky.php'); 40 | 41 | if ($this->app instanceof LaravelApplication) { 42 | $this->publishes([ 43 | $source => config_path('chunky.php'), 44 | ], 'config'); 45 | } elseif ($this->app instanceof LumenApplication) { 46 | $this->app->configure('chunky'); 47 | } 48 | 49 | $this->mergeConfigFrom($source, 'chunky'); 50 | } 51 | 52 | private function registerCommands() 53 | { 54 | $this->app->bind('command.chunky:clear', ClearChunks::class); 55 | 56 | $this->commands([ 57 | 'command.chunky:clear', 58 | ]); 59 | } 60 | 61 | private function registerBindings() 62 | { 63 | $this->app->singleton(TempFilesystem::class, function () { 64 | $config = $this->app->make('config'); 65 | $filesystem = new TempFilesystem(app()->make(Factory::class)); 66 | 67 | $filesystem->disk($config->get('chunky.disks.tmp.disk', $config->get('filesystems.default'))); 68 | $filesystem->folder($config->get('chunky.disks.tmp.folder')); 69 | 70 | return $filesystem; 71 | }); 72 | 73 | $this->app->bind(ChunksFilesystem::class, ChunksFilesystem::class); 74 | $this->app->bind(MergeFilesystem::class, MergeFilesystem::class); 75 | 76 | $this->app->singleton(ChunkySettings::class, function (Container $app) { 77 | return new ChunkySettings($app->make('config')); 78 | }); 79 | 80 | $this->app->singleton(ChunkyManagerContract::class, function (Container $app) { 81 | return new ChunkyManager($app->make(ChunkySettings::class)); 82 | }); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/ChunkySettings.php: -------------------------------------------------------------------------------- 1 | config = $config->get('chunky'); 27 | } 28 | 29 | /** 30 | * Retrieve the chunky configurations. 31 | * 32 | * @return array 33 | */ 34 | public function config(): array 35 | { 36 | return $this->config; 37 | } 38 | 39 | /** 40 | * Retrieve the default chunks disk. 41 | * 42 | * @return string|null 43 | */ 44 | public function chunksDisk(): ?string 45 | { 46 | return Arr::get($this->config, 'disks.chunks.disk'); 47 | } 48 | 49 | /** 50 | * Retrieve the chunks destination folder. 51 | * 52 | * @return string 53 | */ 54 | public function chunksFolder(): string 55 | { 56 | $folder = Arr::get($this->config, 'disks.chunks.folder'); 57 | 58 | if ($folder === null) { 59 | return ''; 60 | } 61 | 62 | if (!Str::endsWith($folder, '/')) { 63 | $folder .= DIRECTORY_SEPARATOR; 64 | } 65 | 66 | return $folder; 67 | } 68 | 69 | /** 70 | * Retrieve the default merge file disk. 71 | * 72 | * @return string|null 73 | */ 74 | public function mergeDisk(): ?string 75 | { 76 | return Arr::get($this->config, 'disks.merge.disk'); 77 | } 78 | 79 | /** 80 | * Retrieve the merge file destination folder. 81 | * 82 | * @return string 83 | */ 84 | public function mergeFolder(): string 85 | { 86 | $folder = Arr::get($this->config, 'disks.merge.folder'); 87 | 88 | if ($folder === null) { 89 | return ''; 90 | } 91 | 92 | if (!Str::endsWith($folder, '/')) { 93 | $folder .= DIRECTORY_SEPARATOR; 94 | } 95 | 96 | return $folder; 97 | } 98 | 99 | /** 100 | * Retrieve the default index value for chunks. 101 | * 102 | * @return int 103 | */ 104 | public function defaultIndex(): int 105 | { 106 | return Arr::get($this->config, 'index', self::INDEX_ZERO) 107 | ?: self::INDEX_ZERO; 108 | } 109 | 110 | /** 111 | * Retrieve the additional options for chunk store. 112 | * 113 | * @return array 114 | */ 115 | public function additionalChunksOptions(): array 116 | { 117 | return Arr::get($this->config, 'options.chunks', []); 118 | } 119 | 120 | /** 121 | * Retrieve the additional options for merge store. 122 | * 123 | * @return array 124 | */ 125 | public function additionalMergeOptions(): array 126 | { 127 | return Arr::get($this->config, 'options.merge', []); 128 | } 129 | 130 | /** 131 | * Retrieve the auto merge option. 132 | * 133 | * @return bool 134 | */ 135 | public function autoMerge(): bool 136 | { 137 | return Arr::get($this->config, 'auto_merge', false); 138 | } 139 | 140 | /** 141 | * Retrieve the default merge handler. 142 | * 143 | * @return MergeHandler 144 | */ 145 | public function mergeHandler(): MergeHandler 146 | { 147 | if ($this->handler === null) { 148 | $handler = Arr::get($this->config, 'merge.handler'); 149 | 150 | if (!class_exists($handler)) { 151 | throw new ChunkyException("Undefined handler {$handler}"); 152 | } 153 | 154 | $this->handler = $handler::instance(); 155 | } 156 | 157 | return $this->handler; 158 | } 159 | 160 | /** 161 | * Retrieve the queue connection for the merge job. 162 | * 163 | * @return string 164 | */ 165 | public function connection() 166 | { 167 | return Arr::get($this->config, 'merge.connection'); 168 | } 169 | 170 | /** 171 | * Retrieve the queue for the merge job. 172 | * 173 | * @return array|\ArrayAccess|mixed 174 | */ 175 | public function queue() 176 | { 177 | return Arr::get($this->config, 'merge.queue'); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/Commands/ClearChunks.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 51 | 52 | if (!$this->confirmToProceed()) { 53 | return; 54 | } 55 | 56 | $root = $this->manager->chunksFolder(); 57 | $folder = $this->argument('folder'); 58 | 59 | if (!empty($folder)) { 60 | $root .= $folder; 61 | $this->deleteFolder($root); 62 | 63 | return; 64 | } 65 | 66 | $folders = $this->manager->chunksFilesystem()->chunkFolders(); 67 | $bar = $this->output->createProgressBar(count($folders)); 68 | 69 | $bar->start(); 70 | 71 | foreach ($folders as $folder) { 72 | $this->deleteFolder($folder); 73 | } 74 | 75 | $bar->finish(); 76 | $this->info('Chunks folders have been deleted!'); 77 | } 78 | 79 | /** 80 | * @param string $folder 81 | */ 82 | private function deleteFolder(string $folder) 83 | { 84 | if (!$this->manager->deleteChunks($folder)) { 85 | $this->error("An error occurred while deleting folder {$folder}"); 86 | 87 | return; 88 | } 89 | 90 | $this->info("folder {$folder} has been deleted!"); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Concerns/ChunkyRequestHelpers.php: -------------------------------------------------------------------------------- 1 | totalSizeInput(); 21 | $chunk_size = $request->chunkSizeInput(); 22 | 23 | if ($total_size < $chunk_size) { 24 | // In this case usually it means that there's only a chunk 25 | return 1; 26 | } 27 | 28 | return ceil($total_size / $chunk_size); 29 | } 30 | 31 | /** 32 | * Check if current index refers to the last chunk. 33 | * 34 | * @param AddChunkRequest $request 35 | * 36 | * @return bool 37 | */ 38 | public function isLastIndex(AddChunkRequest $request): bool 39 | { 40 | $last_index = $this->lastIndexFrom($request) 41 | + ($this->settings->defaultIndex() - 1); 42 | 43 | return $request->indexInput() == $last_index; 44 | } 45 | 46 | /** 47 | * Check if folder is a valid string, otherwise guess the folder from the 48 | * request file input. 49 | * 50 | * @param AddChunkRequest $request 51 | * @param string|null $folder 52 | * 53 | * @return string 54 | */ 55 | protected function checkFolder(AddChunkRequest $request, ?string $folder) 56 | { 57 | $file = $request->fileInput(); 58 | 59 | if ($folder !== null) { 60 | return Str::slug($folder); 61 | } 62 | 63 | return $this->chunkFolderNameFor( 64 | str_replace( 65 | $file->guessExtension(), 66 | '', 67 | $file->getClientOriginalName() 68 | ) 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Contracts/ChunkyManager.php: -------------------------------------------------------------------------------- 1 | chunk = $chunk; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Events/ChunkDeleted.php: -------------------------------------------------------------------------------- 1 | chunk = $chunk; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Events/ChunksMerged.php: -------------------------------------------------------------------------------- 1 | path = $path; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Events/MergeAdded.php: -------------------------------------------------------------------------------- 1 | path = $path; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Exceptions/ChunksIntegrityException.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 43 | $this->temp_filesystem = Container::getInstance()->make(TempFilesystem::class); 44 | } 45 | 46 | public function __call($method, $parameters) 47 | { 48 | if (!method_exists($this, $method)) { 49 | return $this->forwardCallTo($this->manager(), $method, $parameters); 50 | } 51 | 52 | return $this->{$method}(...$parameters); 53 | } 54 | 55 | /** 56 | * {@inheritdoc} 57 | * 58 | * @codeCoverageIgnore 59 | */ 60 | public function setManager(ChunkyManager $manager): MergeHandler 61 | { 62 | $this->manager = $manager; 63 | 64 | return $this; 65 | } 66 | 67 | /** 68 | * {@inheritdoc} 69 | * 70 | * @codeCoverageIgnore 71 | */ 72 | public function manager(): ChunkyManager 73 | { 74 | if ($this->manager === null) { 75 | $this->manager = Container::getInstance()->make('chunky'); 76 | } 77 | 78 | return $this->manager; 79 | } 80 | 81 | /** 82 | * {@inheritdoc} 83 | */ 84 | public function dispatchMerge(AddChunkRequest $request, string $folder) 85 | { 86 | $merge_path = $request->fileInput()->getClientOriginalName(); 87 | $chunk_size = $request->chunkSizeInput(); 88 | $total_size = $request->totalSizeInput(); 89 | 90 | if (empty($connection = $this->settings()->connection())) { 91 | if (!$this->checkChunksIntegrity($folder, $chunk_size, $total_size)) { 92 | throw new ChunksIntegrityException('Chunks total file size doesnt match with original file size'); 93 | } 94 | 95 | return $this->merge($folder, $merge_path); 96 | } 97 | 98 | return MergeChunks::dispatch( 99 | $folder, 100 | $merge_path, 101 | $chunk_size, 102 | $total_size 103 | )->onConnection($connection) 104 | ->onQueue($this->settings()->queue()); 105 | } 106 | 107 | /** 108 | * {@inheritdoc} 109 | */ 110 | public function merge(string $chunks_folder, string $merge_path): string 111 | { 112 | // Check chunks folder 113 | if (!$this->isValidChunksFolder($chunks_folder)) { 114 | throw new ChunkyException("Invalid chunks folder {$chunks_folder}"); 115 | } 116 | 117 | /** @var resource $origin */ 118 | $path = $this->concatenate($chunks_folder, $merge_path); 119 | 120 | // Final check and cleanup 121 | if (!$path) { 122 | throw new ChunkyException('An error occurred while moving merge to destination'); 123 | } 124 | 125 | $this->deleteChunks($chunks_folder); 126 | 127 | event(new ChunksMerged($path)); 128 | 129 | return $path; 130 | } 131 | 132 | /** 133 | * {@inheritdoc} 134 | */ 135 | public static function instance(?ChunkyManager $manager = null): MergeHandlerContract 136 | { 137 | return new static($manager); 138 | } 139 | 140 | /** 141 | * @param string $folder 142 | * @param string $target 143 | * 144 | * @return string 145 | * 146 | * @throws \Illuminate\Contracts\Filesystem\FileExistsException|\Illuminate\Contracts\Filesystem\FileNotFoundException 147 | */ 148 | private function concatenate(string $folder, string $target): string 149 | { 150 | if (!$this->chunksFilesystem()->isLocal()) { 151 | return $this->temporaryConcatenate($target, $this->listChunks($folder)); 152 | } 153 | 154 | $chunks = $this->listChunks($folder)->map(function (Chunk $item) { 155 | return $item->getPath(); 156 | }); 157 | $merge = $chunks->first(); 158 | 159 | if (!$this->chunksFilesystem()->concatenate($merge, $chunks->toArray())) { 160 | throw new ChunkyException('Unable to concatenate chunks'); 161 | } 162 | 163 | return $this->mergeFilesystem() 164 | ->store( 165 | $target, 166 | $this->readChunk($merge), 167 | $this->mergeOptions() 168 | ); 169 | } 170 | 171 | /** 172 | * @param string $target 173 | * @param Collection $chunks 174 | * 175 | * @return string 176 | * 177 | * @throws FileExistsException 178 | * @throws FileNotFoundException 179 | */ 180 | private function temporaryConcatenate(string $target, Collection $chunks) 181 | { 182 | $stream = new AppendStream(); 183 | $chunks->each(function (Chunk $chunk) use ($stream) { 184 | $path = $this->temp_filesystem->store('tmp-'.$chunk->getFilename(), $this->chunksFilesystem()->readChunk($chunk->getPath())); 185 | $stream->append($this->temp_filesystem->readFile($path)); 186 | }); 187 | 188 | $tmp_merge = $this->temp_filesystem->store($target, $stream->getResource()); 189 | $path = $this->mergeFilesystem()->store($target, $this->temp_filesystem->readFile($tmp_merge), $this->mergeOptions()); 190 | 191 | $this->temp_filesystem->clean(); 192 | 193 | return $path; 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/Http/Requests/AddChunkRequest.php: -------------------------------------------------------------------------------- 1 | $rules, 32 | ]; 33 | } 34 | 35 | /** 36 | * Retrieve file rules. 37 | * 38 | * @return array 39 | */ 40 | public function fileRules(): array 41 | { 42 | $key = config('chunky.validation.file.key'); 43 | $rules = array_unique( 44 | array_merge([ 45 | 'required', 'file', 46 | ], config('chunky.validation.file.rules', [])) 47 | ); 48 | 49 | if (empty($key)) { 50 | throw new ChunkyException('File key cannot be null'); 51 | } 52 | 53 | return [ 54 | $key => $rules, 55 | ]; 56 | } 57 | 58 | /** 59 | * Retrieve chunk file size rules. 60 | * 61 | * @return array 62 | */ 63 | public function chunkSizeRules(): array 64 | { 65 | $key = config('chunky.validation.chunkSize.key'); 66 | $rules = array_unique( 67 | array_merge([ 68 | 'required', 'integer', 'min:1', 69 | ], config('chunky.validation.chunkSize.rules', [])) 70 | ); 71 | 72 | if (empty($key)) { 73 | throw new ChunkyException('Chunk file size key cannot be null'); 74 | } 75 | 76 | return [ 77 | $key => $rules, 78 | ]; 79 | } 80 | 81 | /** 82 | * Retrieve total file size rules. 83 | * 84 | * @return array 85 | */ 86 | public function totalSizeRules(): array 87 | { 88 | $key = config('chunky.validation.totalSize.key'); 89 | $rules = array_unique( 90 | array_merge([ 91 | 'required', 'integer', 'min:1', 92 | ], config('chunky.validation.totalSize.rules', [])) 93 | ); 94 | 95 | if (empty($key)) { 96 | throw new ChunkyException('Total file size key cannot be null'); 97 | } 98 | 99 | return [ 100 | $key => $rules, 101 | ]; 102 | } 103 | 104 | public function additionalRules(): array 105 | { 106 | $rules = []; 107 | 108 | foreach (config('chunky.validation') as $input => $config) { 109 | if ( 110 | !in_array($input, ['index', 'file', 'chunkSize', 'totalSize']) 111 | && Arr::has($config, 'key') 112 | && Arr::has($config, 'rules') 113 | ) { 114 | $rules[$config['key']] = Arr::get($config, 'rules', []); 115 | } 116 | } 117 | 118 | return $rules; 119 | } 120 | 121 | /** 122 | * Retrieve index input. 123 | * 124 | * @return int 125 | */ 126 | public function indexInput(): int 127 | { 128 | $key = config('chunky.validation.index.key'); 129 | 130 | if (empty($key)) { 131 | throw new ChunkyException('Index key cannot be null'); 132 | } 133 | 134 | return $this->input($key); 135 | } 136 | 137 | /** 138 | * Retrieve file input. 139 | * 140 | * @return UploadedFile 141 | */ 142 | public function fileInput(): UploadedFile 143 | { 144 | $key = config('chunky.validation.file.key'); 145 | 146 | if (empty($key)) { 147 | throw new ChunkyException('File key cannot be null'); 148 | } 149 | 150 | return $this->file($key); 151 | } 152 | 153 | /** 154 | * Retrieve chunk file size input. 155 | * 156 | * @return int 157 | */ 158 | public function chunkSizeInput(): int 159 | { 160 | $key = config('chunky.validation.chunkSize.key'); 161 | 162 | if (empty($key)) { 163 | throw new ChunkyException('Chunk file size key cannot be null'); 164 | } 165 | 166 | return $this->input($key); 167 | } 168 | 169 | /** 170 | * Retrieve total file size input. 171 | * 172 | * @return int 173 | */ 174 | public function totalSizeInput(): int 175 | { 176 | $key = config('chunky.validation.totalSize.key'); 177 | 178 | if (empty($key)) { 179 | throw new ChunkyException('Total file size key cannot be null'); 180 | } 181 | 182 | return $this->input($key); 183 | } 184 | 185 | /** 186 | * Get the validation rules that apply to the request. 187 | * 188 | * @return array 189 | */ 190 | public function rules() 191 | { 192 | return array_merge( 193 | $this->additionalRules(), 194 | $this->indexRules(), 195 | $this->fileRules(), 196 | $this->chunkSizeRules(), 197 | $this->totalSizeRules() 198 | ); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/Http/Resources/ChunkResource.php: -------------------------------------------------------------------------------- 1 | resource->toArray(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Jobs/MergeChunks.php: -------------------------------------------------------------------------------- 1 | chunks_folder = $chunks_folder; 39 | $this->merge_path = $merge_path; 40 | $this->chunk_size = $chunk_size; 41 | $this->total_size = $total_size; 42 | } 43 | 44 | /** 45 | * Execute the job. 46 | */ 47 | public function handle() 48 | { 49 | if (!Chunky::checkChunksIntegrity($this->chunks_folder, $this->chunk_size, $this->total_size)) { 50 | throw new ChunksIntegrityException('Chunks total file size doesnt match with original file size'); 51 | } 52 | 53 | Chunky::merge($this->chunks_folder, $this->merge_path); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Support/ChunksFilesystem.php: -------------------------------------------------------------------------------- 1 | path($folder); 29 | $files = $this->list($folder); 30 | 31 | return collect($files) 32 | ->map(function ($path) use ($folder, $files) { 33 | $filename = str_replace($folder.DIRECTORY_SEPARATOR, '', $path); 34 | $exploded_name = explode('_', $filename); 35 | 36 | $index = array_shift($exploded_name); 37 | $last = count($files) - 1 == $index; 38 | 39 | return new Chunk((int) $index, $path, $this->disk(), $last); 40 | })->sortBy(function (Chunk $chunk) { 41 | return $chunk->getIndex(); 42 | })->values(); 43 | } 44 | 45 | /** 46 | * @return array 47 | * 48 | * @throws FilesystemException 49 | */ 50 | public function chunkFolders(): array 51 | { 52 | return $this->filesystem()->disk($this->disk())->listContents($this->folder(), false) 53 | ->filter(fn (StorageAttributes $attributes) => $attributes->isDir()) 54 | ->sortByPath() 55 | ->map(fn (StorageAttributes $attributes) => $attributes->path()) 56 | ->toArray(); 57 | } 58 | 59 | /** 60 | * @param string $folder 61 | * 62 | * @return int 63 | */ 64 | public function chunksCount(string $folder): int 65 | { 66 | return count($this->list($this->path($folder))); 67 | } 68 | 69 | /** 70 | * @param string $path 71 | * 72 | * @return int 73 | */ 74 | public function chunkSize(string $path): int 75 | { 76 | return $this->filesystem()->disk($this->disk())->size($this->path($path)); 77 | } 78 | 79 | /** 80 | * @param string $path 81 | * 82 | * @return resource|null 83 | */ 84 | public function readChunk(string $path) 85 | { 86 | return $this->filesystem()->disk($this->disk())->readStream($this->path($path)); 87 | } 88 | 89 | /** 90 | * @param Chunk $chunk 91 | * @param string $folder 92 | * @param array $options 93 | * 94 | * @return Chunk 95 | */ 96 | public function store(Chunk $chunk, string $folder, $options = []): Chunk 97 | { 98 | if (!$chunk->getOriginalPath() instanceof File) { 99 | throw new ChunkyException('Path must be a file'); 100 | } 101 | 102 | // Build destination 103 | $suffix = Str::endsWith($folder, DIRECTORY_SEPARATOR) ? '' : DIRECTORY_SEPARATOR; 104 | $destination = $this->path($folder.$suffix.$chunk->getSlug()); 105 | 106 | // Copy file 107 | $file = fopen($chunk->getPath(), 'r'); 108 | 109 | Arr::pull($options, 'disk'); 110 | $this->filesystem()->disk($this->disk())->put($destination, $file, $options); 111 | fclose($file); 112 | 113 | // Return chunk 114 | $chunk->setPath($destination); 115 | event(new ChunkAdded($chunk)); 116 | 117 | return $chunk; 118 | } 119 | 120 | /** 121 | * Delete all chunks and, once empty, delete the folder. 122 | * 123 | * @param Chunk $chunk 124 | * 125 | * @return bool 126 | */ 127 | public function deleteChunk(Chunk $chunk): bool 128 | { 129 | if (!$this->filesystem()->disk($this->disk())->exists($chunk->getPath())) { 130 | return true; 131 | } 132 | 133 | $deleted = $this->filesystem()->disk($this->disk())->delete($chunk->getPath()); 134 | 135 | if ($deleted) { 136 | event(new ChunkDeleted($chunk)); 137 | } 138 | 139 | return $deleted; 140 | } 141 | 142 | /** 143 | * Delete all chunks and, once empty, delete the folder. 144 | * 145 | * @param string $folder 146 | * 147 | * @return bool 148 | */ 149 | public function delete(string $folder): bool 150 | { 151 | $folder = $this->path($folder); 152 | 153 | if (!$this->filesystem()->disk($this->disk())->exists($folder)) { 154 | return true; 155 | } 156 | 157 | foreach ($this->listChunks($folder) as $chunk) { 158 | $this->deleteChunk($chunk); 159 | } 160 | 161 | return $this->filesystem()->disk($this->disk()) 162 | ->deleteDirectory($folder); 163 | } 164 | 165 | /** 166 | * Concatenate all chunks into final merge. 167 | * 168 | * @param string $chunk 169 | * @param array $chunks 170 | * 171 | * @return bool 172 | * 173 | * @throws FileNotFoundException|FilesystemException 174 | */ 175 | public function concatenate(string $chunk, array $chunks): bool 176 | { 177 | foreach ($chunks as $path) { 178 | if (!$this->filesystem()->disk($this->disk())->has($path)) { 179 | throw new FileNotFoundException($path); 180 | } 181 | } 182 | 183 | $overwrite = in_array($chunk, $chunks, true); 184 | if ($overwrite) { 185 | $this->filesystem()->disk($this->disk())->move($chunk, $chunkBackupPath = $chunk.'.backup'); 186 | $key = array_search($chunk, $chunks, true); 187 | $chunks[$key] = $chunkBackupPath; 188 | } 189 | 190 | $stream = new AppendStream(); 191 | foreach ($chunks as $fragment) { 192 | $stream->append($this->filesystem()->disk($this->disk())->readStream($fragment)); 193 | } 194 | $this->filesystem()->disk($this->disk())->writeStream($chunk, $resource = $stream->getResource()); 195 | 196 | if ($overwrite) { 197 | $this->filesystem()->disk($this->disk())->delete($chunkBackupPath); 198 | } 199 | 200 | return true; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/Support/Filesystem.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 30 | } 31 | 32 | public function __call($method, $parameters) 33 | { 34 | if (!method_exists($this, $method)) { 35 | return $this->forwardCallTo($this->filesystem(), $method, $parameters); 36 | } 37 | 38 | return $this->{$method}($parameters); 39 | } 40 | 41 | /** 42 | * @return Factory 43 | * 44 | * @codeCoverageIgnore 45 | */ 46 | public function filesystem(): Factory 47 | { 48 | return $this->filesystem; 49 | } 50 | 51 | /** 52 | * Disk getter and setter. 53 | * 54 | * @param string|null $disk 55 | * 56 | * @return string|null 57 | */ 58 | public function disk($disk = null): ?string 59 | { 60 | if (!empty($disk) && is_string($disk)) { 61 | $this->disk = $disk; 62 | } 63 | 64 | return $this->disk; 65 | } 66 | 67 | /** 68 | * Folder getter and setter. 69 | * 70 | * @param string|null $folder 71 | * 72 | * @return string|null 73 | */ 74 | public function folder($folder = null): ?string 75 | { 76 | if (!empty($folder) && is_string($folder)) { 77 | $suffix = Str::endsWith($folder, DIRECTORY_SEPARATOR) ? '' : DIRECTORY_SEPARATOR; 78 | 79 | $this->folder = $folder.$suffix; 80 | } 81 | 82 | return $this->folder; 83 | } 84 | 85 | /** 86 | * @return bool 87 | * 88 | * @codeCoverageIgnore 89 | */ 90 | public function isLocal(): bool 91 | { 92 | $adapter = $this->filesystem() 93 | ->disk($this->disk) 94 | ->getAdapter(); 95 | 96 | return $adapter instanceof LocalFilesystemAdapter; 97 | } 98 | 99 | /** 100 | * @param string $path 101 | * 102 | * @return bool 103 | */ 104 | public function exists(string $path): bool 105 | { 106 | return $this->filesystem() 107 | ->disk($this->disk()) 108 | ->exists($this->path($path)); 109 | } 110 | 111 | /** 112 | * Retrieve every chunks' folder. 113 | * 114 | * @return array 115 | */ 116 | public function folders(): array 117 | { 118 | return $this->filesystem()->disk($this->disk())->listContents($this->folder(), false) 119 | ->filter(fn (StorageAttributes $attributes) => $attributes->isDir()) 120 | ->sortByPath() 121 | ->map(fn (StorageAttributes $attributes) => $attributes->path()) 122 | ->toArray(); 123 | } 124 | 125 | /** 126 | * @param string|null $folder 127 | * 128 | * @return array 129 | */ 130 | public function list($folder = ''): array 131 | { 132 | return $this->filesystem()->disk( 133 | $this->disk() 134 | )->files( 135 | $this->path($folder) 136 | ); 137 | } 138 | 139 | /** 140 | * @param string $folder 141 | * 142 | * @return bool 143 | */ 144 | public function makeDirectory(string $folder): bool 145 | { 146 | return $this->filesystem->disk($this->disk) 147 | ->makeDirectory($this->path($folder)); 148 | } 149 | 150 | /** 151 | * @param array $config 152 | * 153 | * @return mixed 154 | * 155 | * @throws BindingResolutionException 156 | */ 157 | public static function instance(array $config) 158 | { 159 | $filesystem = Container::getInstance()->make(get_called_class()); 160 | 161 | $filesystem->disk(Arr::get($config, 'disk')); 162 | $filesystem->folder(Arr::get($config, 'folder')); 163 | 164 | return $filesystem; 165 | } 166 | 167 | /** 168 | * @param string $path 169 | * 170 | * @return string 171 | */ 172 | protected function path(string $path): string 173 | { 174 | if (!Str::startsWith($path, $this->folder)) { 175 | return $this->folder().$path; 176 | } 177 | 178 | return $path; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/Support/MergeFilesystem.php: -------------------------------------------------------------------------------- 1 | path($destination); 24 | 25 | if ($this->filesystem()->disk($this->disk)->writeStream($destination, $origin, $options)) { 26 | event(new MergeAdded($destination)); 27 | 28 | return $destination; 29 | } 30 | 31 | return false; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Support/TempFilesystem.php: -------------------------------------------------------------------------------- 1 | filesystem()->disk($this->disk())->readStream($this->path($path)); 22 | } 23 | 24 | /** 25 | * @param string $path 26 | * @param resource|string $resource 27 | * @param array $options 28 | * 29 | * @return string 30 | * 31 | * @throws FileExistsException 32 | */ 33 | public function store(string $path, $resource = '', $options = []): string 34 | { 35 | $path = $this->path($path); 36 | $this->filesystem()->disk($this->disk())->writeStream($path, $resource, $options); 37 | $this->temp_files[] = $path; 38 | 39 | return $path; 40 | } 41 | 42 | public function clean() 43 | { 44 | foreach ($this->temp_files as $file) { 45 | $this->filesystem()->disk($this->disk())->delete($file); 46 | } 47 | } 48 | } 49 | --------------------------------------------------------------------------------