├── .gitignore ├── .styleci.yml ├── LICENSE ├── README.md ├── composer.json ├── config └── filepond.php ├── phpunit.xml ├── routes └── web.php ├── src ├── Exceptions │ ├── InvalidPathException.php │ └── LaravelFilepondException.php ├── Filepond.php ├── Http │ └── Controllers │ │ └── FilepondController.php └── LaravelFilepondServiceProvider.php └── tests ├── Feature └── SingleFileUploadTest.php └── TestCase.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.phar 3 | composer.lock 4 | .DS_Store 5 | .idea 6 | .phpunit.result.cache 7 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | 3 | risky: false 4 | 5 | enabled: 6 | - align_double_arrow 7 | - align_equals 8 | - concat_with_spaces 9 | - ordered_class_elements 10 | 11 | disabled: 12 | - concat_without_spaces 13 | - not_operator_with_successor_space 14 | - unalign_equals 15 | 16 | finder: 17 | not-name: 18 | - "*.md" 19 | not-path: 20 | - ".github" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sopamo GmbH 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | Laravel FilePond Backend 5 |

6 | 7 |

8 | An all in one Laravel backend for FilePond
9 |

10 |

11 | 12 | ## :rocket: Be up and running in 2 minutes 13 | 14 | ### Laravel setup 15 | 16 | Require this package in the `composer.json` of your Laravel project. 17 | 18 | ```bash 19 | composer require sopamo/laravel-filepond 20 | ``` 21 | 22 | If you need to edit the configuration, you can publish it with: 23 | 24 | ```bash 25 | php artisan vendor:publish --provider="Sopamo\LaravelFilepond\LaravelFilepondServiceProvider" 26 | ``` 27 | 28 | 29 | ```php 30 | // Get the temporary path using the serverId returned by the upload function in `FilepondController.php` 31 | $filepond = app(\Sopamo\LaravelFilepond\Filepond::class); 32 | $disk = config('filepond.temporary_files_disk'); 33 | 34 | $path = $filepond->getPathFromServerId($serverId); 35 | $fullpath = Storage::disk($disk)->get($filePath); 36 | 37 | 38 | // Move the file from the temporary path to the final location 39 | $finalLocation = public_path('output.jpg'); 40 | \File::move($fullpath, $finalLocation); 41 | ``` 42 | 43 | #### External storage 44 | 45 | You can use any [Laravel disk](https://laravel.com/docs/7.x/filesystem) as the storage for temporary files. If you use a different disk for the temporary files and the final location, you will need to copy the file from the temporary location to the new disk then delete the temporary file yourself. 46 | 47 | If you are using the default `local` disk, make sure the /storage/app/filepond directory exists in your project and is writable. 48 | 49 | ### Filepond client setup 50 | 51 | This is the minimum Filepond JS configuration you need to set after installing laravel-filepond. 52 | 53 | ```javascript 54 | FilePond.setOptions({ 55 | server: { 56 | url: '/filepond/api', 57 | process: { 58 | url: "/process", 59 | headers: (file: File) => { 60 | // Send the original file name which will be used for chunked uploads 61 | return { 62 | "Upload-Name": file.name, 63 | "X-CSRF-TOKEN": "{{ csrf_token() }}", 64 | } 65 | }, 66 | }, 67 | revert: '/process', 68 | patch: "?patch=", 69 | headers: { 70 | 'X-CSRF-TOKEN': '{{ csrf_token() }}' 71 | } 72 | } 73 | }); 74 | ``` 75 | 76 | ## Package development 77 | Please make sure all tests run successfully before submitting a PR. 78 | ### Testing 79 | - Start a docker container to execute the tests in with ` docker run -it -v $PWD:/app composer /bin/bash` 80 | - Run `composer install` 81 | - Run `./vendor/bin/phpunit` 82 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sopamo/laravel-filepond", 3 | "description": "Laravel backend module for filepond uploads", 4 | "license": "MIT", 5 | "keywords": [ 6 | "laravel", 7 | "php", 8 | "filepond", 9 | "upload", 10 | "image" 11 | ], 12 | "authors": [ 13 | { 14 | "name": "Paul Mohr", 15 | "email": "p.mohr@sopamo.de" 16 | } 17 | ], 18 | "require": { 19 | "php": "^7.0|^8.0", 20 | "illuminate/contracts": "5.5.*|5.6.*|5.7.*|5.8.*|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", 21 | "illuminate/http": "5.5.*|5.6.*|5.7.*|5.8.*|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", 22 | "illuminate/routing": "5.5.*|5.6.*|5.7.*|5.8.*|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", 23 | "illuminate/support": "5.5.*|5.6.*|5.7.*|5.8.*|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0" 24 | }, 25 | "autoload": { 26 | "psr-4": { 27 | "Sopamo\\LaravelFilepond\\": "src/", 28 | "Sopamo\\LaravelFilepond\\Tests\\": "tests/" 29 | } 30 | }, 31 | "extra": { 32 | "laravel": { 33 | "providers": [ 34 | "Sopamo\\LaravelFilepond\\LaravelFilepondServiceProvider" 35 | ] 36 | } 37 | }, 38 | "scripts": { 39 | "post-autoload-dump": [ 40 | "@php ./vendor/bin/testbench package:discover --ansi" 41 | ] 42 | }, 43 | "minimum-stability": "dev", 44 | "prefer-stable": true, 45 | "require-dev": { 46 | "phpunit/phpunit": "^9.5|^10.5|^11.5.3", 47 | "orchestra/testbench": "^7.5|^8.0|^9.0|^10.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /config/filepond.php: -------------------------------------------------------------------------------- 1 | 'api', 13 | 14 | /* 15 | |-------------------------------------------------------------------------- 16 | | Prefix 17 | |-------------------------------------------------------------------------- 18 | | 19 | | The prefix to add to all filepond controller routes 20 | | 21 | */ 22 | 'route_prefix' => 'filepond', 23 | 24 | /* 25 | |-------------------------------------------------------------------------- 26 | | Temporary Path 27 | |-------------------------------------------------------------------------- 28 | | 29 | | When initially uploading the files we store them in this path 30 | | By default, it is stored on the local disk which defaults to `/storage/app/{temporary_files_path}` 31 | | 32 | */ 33 | 'temporary_files_path' => env('FILEPOND_TEMP_PATH', 'filepond'), 34 | 'temporary_files_disk' => env('FILEPOND_TEMP_DISK', 'local'), 35 | 36 | /* 37 | |-------------------------------------------------------------------------- 38 | | Chunks path 39 | |-------------------------------------------------------------------------- 40 | | 41 | | When using chunks, we want to place them inside of this folder. 42 | | Make sure it is writeable. 43 | | Chunks use the same disk as the temporary files do. 44 | | 45 | */ 46 | 'chunks_path' => env('FILEPOND_CHUNKS_PATH', 'filepond' . DIRECTORY_SEPARATOR . 'chunks'), 47 | 48 | 'input_name' => 'file', 49 | ]; 50 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 18 | src/ 19 | 20 | 21 | 22 | 23 | ./tests/Unit 24 | 25 | 26 | ./tests/Feature 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | group(function () { 7 | Route::patch('/', [FilepondController::class, 'chunk'])->name('filepond.chunk'); 8 | Route::post('/process', [FilepondController::class, 'upload'])->name('filepond.upload'); 9 | Route::delete('/process', [FilepondController::class, 'delete'])->name('filepond.delete'); 10 | }); 11 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidPathException.php: -------------------------------------------------------------------------------- 1 | filepond = $filepond; 25 | } 26 | 27 | /** 28 | * Uploads the file to the temporary directory 29 | * and returns an encrypted path to the file 30 | * 31 | * @param Request $request 32 | * 33 | * @return \Illuminate\Http\Response 34 | */ 35 | public function upload(Request $request) 36 | { 37 | $input = $request->file(config('filepond.input_name')); 38 | 39 | if ($input === null) { 40 | return $this->handleChunkInitialization($request); 41 | } 42 | 43 | $file = is_array($input) ? $input[0] : $input; 44 | $path = config('filepond.temporary_files_path', 'filepond'); 45 | $disk = config('filepond.temporary_files_disk', 'local'); 46 | 47 | if (!($newFile = $file->storeAs($path . DIRECTORY_SEPARATOR . Str::random(), $file->getClientOriginalName(), $disk))) { 48 | return Response::make('Could not save file', 500, [ 49 | 'Content-Type' => 'text/plain', 50 | ]); 51 | } 52 | 53 | return Response::make($this->filepond->getServerIdFromPath($newFile), 200, [ 54 | 'Content-Type' => 'text/plain', 55 | ]); 56 | } 57 | 58 | /** 59 | * This handles the case where filepond wants to start uploading chunks of a file 60 | * See: https://pqina.nl/filepond/docs/patterns/api/server/ 61 | * 62 | * @param Request $request 63 | * @return \Illuminate\Http\Response 64 | */ 65 | private function handleChunkInitialization(Request $request) 66 | { 67 | $randomId = Str::random(); 68 | $path = config('filepond.temporary_files_path', 'filepond'); 69 | $disk = config('filepond.temporary_files_disk', 'local'); 70 | 71 | $baseName = $randomId; 72 | if ($request->header('Upload-Name')) { 73 | $fileName = pathinfo($request->header('Upload-Name'), PATHINFO_FILENAME); 74 | $ext = pathinfo($request->header('Upload-Name'), PATHINFO_EXTENSION); 75 | $baseName = $fileName.'-'.$randomId.'.'.$ext; 76 | } 77 | $fileLocation = $path . DIRECTORY_SEPARATOR . $baseName; 78 | 79 | $fileCreated = Storage::disk($disk) 80 | ->put($fileLocation, ''); 81 | 82 | if (!$fileCreated) { 83 | abort(500, 'Could not create file'); 84 | } 85 | $filepondId = $this->filepond->getServerIdFromPath($fileLocation); 86 | 87 | return Response::make($filepondId, 200, [ 88 | 'Content-Type' => 'text/plain', 89 | ]); 90 | } 91 | 92 | /** 93 | * Handle a single chunk 94 | * 95 | * @param Request $request 96 | * @return \Illuminate\Http\Response 97 | * @throws FileNotFoundException 98 | */ 99 | public function chunk(Request $request) 100 | { 101 | // Retrieve upload ID 102 | $encryptedPath = $request->input('patch'); 103 | if (!$encryptedPath) { 104 | abort(400, 'No id given'); 105 | } 106 | 107 | try { 108 | $finalFilePath = Crypt::decryptString($encryptedPath); 109 | $id = basename($finalFilePath); 110 | } catch (DecryptException $e) { 111 | abort(400, 'Invalid encryption for id'); 112 | } 113 | 114 | // Retrieve disk 115 | $disk = config('filepond.temporary_files_disk', 'local'); 116 | 117 | // Load chunks directory 118 | $basePath = config('filepond.chunks_path') . DIRECTORY_SEPARATOR . $id; 119 | 120 | // Get patch info 121 | $offset = $request->server('HTTP_UPLOAD_OFFSET'); 122 | $length = $request->server('HTTP_UPLOAD_LENGTH'); 123 | 124 | // Validate patch info 125 | if (!is_numeric($offset) || !is_numeric($length)) { 126 | abort(400, 'Invalid chunk length or offset'); 127 | } 128 | 129 | // Store chunk 130 | Storage::disk($disk) 131 | ->put($basePath . DIRECTORY_SEPARATOR . 'patch.' . $offset, $request->getContent(), ['mimetype' => 'application/octet-stream']); 132 | 133 | $this->persistFileIfDone($disk, $basePath, $length, $finalFilePath); 134 | 135 | return Response::make('', 204); 136 | } 137 | 138 | /** 139 | * This checks if all chunks have been uploaded and if they have, it creates the final file 140 | * 141 | * @param $disk 142 | * @param $basePath 143 | * @param $length 144 | * @param $finalFilePath 145 | * @throws FileNotFoundException 146 | */ 147 | private function persistFileIfDone($disk, $basePath, $length, $finalFilePath) 148 | { 149 | $storage = Storage::disk($disk); 150 | // Check total chunks size 151 | $size = 0; 152 | $chunks = $storage 153 | ->files($basePath); 154 | 155 | foreach ($chunks as $chunk) { 156 | $size += $storage 157 | ->size($chunk); 158 | } 159 | 160 | // Process finished upload 161 | if ($size < $length) { 162 | return; 163 | } 164 | 165 | // Sort chunks 166 | $chunks = collect($chunks); 167 | $chunks = $chunks->keyBy(function ($chunk) { 168 | return substr($chunk, strrpos($chunk, '.') + 1); 169 | }); 170 | $chunks = $chunks->sortKeys(); 171 | 172 | // Append each chunk to the final file 173 | $tmpFile = tmpfile(); 174 | $tmpFileName = stream_get_meta_data($tmpFile)['uri']; 175 | // Append each chunk to the final file 176 | foreach ($chunks as $chunk) { 177 | // Get chunk contents 178 | $chunkContents = $storage->readStream($chunk); 179 | 180 | // Stream data from chunk to tmp file 181 | stream_copy_to_stream($chunkContents, $tmpFile); 182 | } 183 | // We can also pass ['mimetype' => $storage->mimeType($finalFilePath)] since the 184 | // $finalFilePath now contains the extension of the file 185 | $storage->put($finalFilePath, $tmpFile); 186 | $storage->deleteDirectory($basePath); 187 | 188 | if (file_exists($tmpFileName)) { 189 | unlink($tmpFileName); 190 | } 191 | } 192 | 193 | /** 194 | * Takes the given encrypted filepath and deletes 195 | * it if it hasn't been tampered with 196 | * 197 | * @param Request $request 198 | * 199 | * @return mixed 200 | */ 201 | public function delete(Request $request) 202 | { 203 | $filePath = $this->filepond->getPathFromServerId($request->getContent()); 204 | $folderPath = dirname($filePath); 205 | if (Storage::disk(config('filepond.temporary_files_disk', 'local'))->deleteDirectory($folderPath)) { 206 | return Response::make('', 200, [ 207 | 'Content-Type' => 'text/plain', 208 | ]); 209 | } 210 | 211 | return Response::make('', 500, [ 212 | 'Content-Type' => 'text/plain', 213 | ]); 214 | } 215 | } 216 | 217 | -------------------------------------------------------------------------------- /src/LaravelFilepondServiceProvider.php: -------------------------------------------------------------------------------- 1 | registerRoutes(); 13 | $this->publishes([ 14 | $this->getConfigFile() => config_path('filepond.php'), 15 | ], 'filepond'); 16 | } 17 | 18 | /** 19 | * {@inheritdoc} 20 | */ 21 | public function register() 22 | { 23 | $this->mergeConfigFrom( 24 | $this->getConfigFile(), 25 | 'filepond' 26 | ); 27 | } 28 | 29 | /** 30 | * Register Filepond routes. 31 | * 32 | * @return void 33 | */ 34 | protected function registerRoutes() 35 | { 36 | Route::group([ 37 | 'prefix' => config('filepond.route_prefix', 'filepond'), 38 | 'middleware' => config('filepond.middleware', null), 39 | ], function () { 40 | $this->loadRoutesFrom(__DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'routes' . DIRECTORY_SEPARATOR . 'web.php'); 41 | }); 42 | } 43 | 44 | /** 45 | * @return string 46 | */ 47 | protected function getConfigFile(): string 48 | { 49 | return __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'config' . DIRECTORY_SEPARATOR . 'filepond.php'; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/Feature/SingleFileUploadTest.php: -------------------------------------------------------------------------------- 1 | postJson('/filepond/api/process', [ 21 | 'file' => UploadedFile::fake()->create('test.txt', 1), 22 | ]); 23 | 24 | $response->assertStatus(200); 25 | $serverId = $response->content(); 26 | $this->assertGreaterThan(50, strlen($serverId)); 27 | 28 | /** @var Filepond $filepond */ 29 | $filepond = app(Filepond::class); 30 | $pathFromServerId = $filepond->getPathFromServerId($serverId); 31 | 32 | $this->assertStringStartsWith($tmpPath, $pathFromServerId, 'tmp file was not created in the temporary_files_path directory'); 33 | 34 | Storage::disk($diskName)->assertExists($pathFromServerId); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 |