From ee957ab7e4bfe9636b6b780b270e00276afed754 Mon Sep 17 00:00:00 2001 From: SvanThuijl Date: Wed, 10 Mar 2021 12:02:20 -0400 Subject: [PATCH] Implemented support for chunked uploads (#40) Co-authored-by: Simon van Thuijl Co-authored-by: Paul Mohr --- config/filepond.php | 12 ++ routes/web.php | 1 + src/Filepond.php | 13 +- src/Http/Controllers/FilepondController.php | 141 +++++++++++++++++++- 4 files changed, 148 insertions(+), 19 deletions(-) diff --git a/config/filepond.php b/config/filepond.php index 45cdba0..2b6e6c7 100755 --- a/config/filepond.php +++ b/config/filepond.php @@ -33,5 +33,17 @@ 'temporary_files_path' => env('FILEPOND_TEMP_PATH', 'filepond'), 'temporary_files_disk' => env('FILEPOND_TEMP_DISK', 'local'), + /* + |-------------------------------------------------------------------------- + | Chunks path + |-------------------------------------------------------------------------- + | + | When using chunks, we want to place them inside of this folder. + | Make sure it is writeable. + | Chunks use the same disk as the temporary files do. + | + */ + 'chunks_path' => env('FILEPOND_CHUNKS_PATH', 'filepond' . DIRECTORY_SEPARATOR . 'chunks'), + 'input_name' => 'file', ]; diff --git a/routes/web.php b/routes/web.php index b7e5258..c366ab4 100755 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,7 @@ use Sopamo\LaravelFilepond\Http\Controllers\FilepondController; Route::prefix('api')->group(function () { + Route::patch('/', [FilepondController::class, 'chunk'])->name('filepond.chunk'); Route::post('/process', [FilepondController::class, 'upload'])->name('filepond.upload'); Route::delete('/process', [FilepondController::class, 'delete'])->name('filepond.delete'); }); diff --git a/src/Filepond.php b/src/Filepond.php index 7b6b90e..e0530cd 100755 --- a/src/Filepond.php +++ b/src/Filepond.php @@ -35,21 +35,10 @@ public function getPathFromServerId($serverId) } $filePath = Crypt::decryptString($serverId); - if (! Str::startsWith($filePath, $this->getBasePath())) { + if (! Str::startsWith($filePath, config('filepond.temporary_files_path', 'filepond'))) { throw new InvalidPathException(); } return $filePath; } - - /** - * Get the storage base path for files. - * - * @return string - */ - public function getBasePath() - { - return Storage::disk(config('filepond.temporary_files_disk', 'local')) - ->path(config('filepond.temporary_files_path', 'filepond')); - } } diff --git a/src/Http/Controllers/FilepondController.php b/src/Http/Controllers/FilepondController.php index 104b0a3..8057435 100755 --- a/src/Http/Controllers/FilepondController.php +++ b/src/Http/Controllers/FilepondController.php @@ -2,8 +2,11 @@ namespace Sopamo\LaravelFilepond\Http\Controllers; +use Illuminate\Contracts\Encryption\DecryptException; +use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; +use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Response; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -25,7 +28,7 @@ public function __construct(Filepond $filepond) * Uploads the file to the temporary directory * and returns an encrypted path to the file * - * @param Request $request + * @param Request $request * * @return \Illuminate\Http\Response */ @@ -34,31 +37,154 @@ public function upload(Request $request) $input = $request->file(config('filepond.input_name')); if ($input === null) { - return Response::make(config('filepond.input_name') . ' is required', 422, [ - 'Content-Type' => 'text/plain', - ]); + return $this->handleChunkInitialization(); } $file = is_array($input) ? $input[0] : $input; $path = config('filepond.temporary_files_path', 'filepond'); $disk = config('filepond.temporary_files_disk', 'local'); - if (! ($newFile = $file->storeAs($path . DIRECTORY_SEPARATOR . Str::random(), $file->getClientOriginalName(), $disk))) { + if (!($newFile = $file->storeAs($path . DIRECTORY_SEPARATOR . Str::random(), $file->getClientOriginalName(), $disk))) { return Response::make('Could not save file', 500, [ 'Content-Type' => 'text/plain', ]); } - return Response::make($this->filepond->getServerIdFromPath(Storage::disk($disk)->path($newFile)), 200, [ + return Response::make($this->filepond->getServerIdFromPath($newFile), 200, [ 'Content-Type' => 'text/plain', ]); } + /** + * This handles the case where filepond wants to start uploading chunks of a file + * See: https://pqina.nl/filepond/docs/patterns/api/server/ + * + * @param Request $request + * @return \Illuminate\Http\Response + */ + private function handleChunkInitialization() + { + $randomId = Str::random(); + $path = config('filepond.temporary_files_path', 'filepond'); + $disk = config('filepond.temporary_files_disk', 'local'); + + $fileLocation = $path . DIRECTORY_SEPARATOR . $randomId; + + $fileCreated = Storage::disk($disk) + ->put($fileLocation, ''); + + if (!$fileCreated) { + abort(500, 'Could not create file'); + } + $filepondId = $this->filepond->getServerIdFromPath($fileLocation); + + return Response::make($filepondId, 200, [ + 'Content-Type' => 'text/plain', + ]); + } + + /** + * Handle a single chunk + * + * @param Request $request + * @return \Illuminate\Http\Response + * @throws FileNotFoundException + */ + public function chunk(Request $request) + { + // Retrieve upload ID + $encryptedPath = $request->input('patch'); + if (!$encryptedPath) { + abort(400, 'No id given'); + } + + try { + $finalFilePath = Crypt::decryptString($encryptedPath); + $id = basename($finalFilePath); + } catch (DecryptException $e) { + abort(400, 'Invalid encryption for id'); + } + + // Retrieve disk + $disk = config('filepond.temporary_files_disk', 'local'); + + // Load chunks directory + $basePath = config('filepond.chunks_path') . DIRECTORY_SEPARATOR . $id; + + // Get patch info + $offset = $request->server('HTTP_UPLOAD_OFFSET'); + $length = $request->server('HTTP_UPLOAD_LENGTH'); + + // Validate patch info + if (!is_numeric($offset) || !is_numeric($length)) { + abort(400, 'Invalid chunk length or offset'); + } + + // Store chunk + Storage::disk($disk) + ->put($basePath . DIRECTORY_SEPARATOR . 'patch.' . $offset, $request->getContent()); + + $this->persistFileIfDone($disk, $basePath, $length, $finalFilePath); + + return Response::make('', 204); + } + + /** + * This checks if all chunks have been uploaded and if they have, it creates the final file + * + * @param $disk + * @param $basePath + * @param $length + * @param $finalFilePath + * @throws FileNotFoundException + */ + private function persistFileIfDone($disk, $basePath, $length, $finalFilePath) + { + // Check total chunks size + $size = 0; + $chunks = Storage::disk($disk) + ->files($basePath); + + foreach ($chunks as $chunk) { + $size += Storage::disk($disk) + ->size($chunk); + } + + // Process finished upload + if ($size < $length) { + return; + } + + // Sort chunks + $chunks = collect($chunks); + $chunks = $chunks->keyBy(function ($chunk) { + return substr($chunk, strrpos($chunk, '.') + 1); + }); + $chunks = $chunks->sortKeys(); + + // Append each chunk to the final file + foreach ($chunks as $chunk) { + // Get chunk contents + $chunkContents = Storage::disk($disk) + ->get($chunk); + + // Laravel's local disk implementation is quite inefficient for appending data to existing files + // We might want to create a workaround for local disks which is more efficient + Storage::disk($disk)->append($finalFilePath, $chunkContents, ''); + + // Remove chunk + Storage::disk($disk) + ->delete($chunk); + } + Storage::disk($disk) + ->deleteDir($basePath); + } + /** * Takes the given encrypted filepath and deletes * it if it hasn't been tampered with * - * @param Request $request + * @param Request $request * * @return mixed */ @@ -76,3 +202,4 @@ public function delete(Request $request) ]); } } +