├── .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 |