Skip to content

Commit

Permalink
Implemented support for chunked uploads (#40)
Browse files Browse the repository at this point in the history
Co-authored-by: Simon van Thuijl <[email protected]>
Co-authored-by: Paul Mohr
  • Loading branch information
SvanThuijl and Simon van Thuijl committed Mar 10, 2021
1 parent 233b67c commit ee957ab
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 19 deletions.
12 changes: 12 additions & 0 deletions config/filepond.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];
1 change: 1 addition & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
13 changes: 1 addition & 12 deletions src/Filepond.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
}
141 changes: 134 additions & 7 deletions src/Http/Controllers/FilepondController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
*/
Expand All @@ -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
*/
Expand All @@ -76,3 +202,4 @@ public function delete(Request $request)
]);
}
}

0 comments on commit ee957ab

Please sign in to comment.