diff --git a/README.md b/README.md index 5361b31..84f0a73 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ project at the moment is [tus](https://tus.io/). - [Monolith](#monolith-driver) - [Blueimp](#blueimp-driver) - [DropzoneJS](#dropzonejs-driver) + - [Resumable.js](#resumable-js-driver) - [Identifiers](#identifiers) - [Session identifier](#session-identifier) - [Contribution](#contribution) @@ -72,7 +73,7 @@ class MyController extends Controller { public function myFunction(Request $request, UploadHandler $handler) { - $handler->handle($request); + return $handler->handle($request); } } ``` @@ -86,7 +87,7 @@ class MyController extends Controller public function myFunction(Request $request) { $handler = app()->make(UploadHandler::class); - $handler->handle($request); + return $handler->handle($request); } } ``` @@ -142,11 +143,12 @@ If you wrote a custom driver that others might find useful, please consider addi Below is a list of available drivers along with their individual specs: -Service | Driver name | Chunk upload | Resumable ----------------------------------|-------------|--------------|----------- -[Monolith](#monolith-driver) | `monolith` | no | no -[Blueimp](#blueimp-driver) | `blueimp` | yes | yes -[DropzoneJS](#dropzonejs-driver) | `dropzone` | yes | no +Service | Driver name | Chunk upload | Resumable +-------------------------------------|----------------|--------------|----------- +[Monolith](#monolith-driver) | `monolith` | no | no +[Blueimp](#blueimp-driver) | `blueimp` | yes | yes +[DropzoneJS](#dropzonejs-driver) | `dropzone` | yes | no +[Resumable.js](#resumable-js-driver) | `resumable-js` | yes | yes ### Monolith driver @@ -164,6 +166,12 @@ This driver handles requests made by the Blueimp jQuery File Upload client libra This driver handles requests made by the DropzoneJS client library. +### Resumable.js driver + +[website](http://resumablejs.com/) + +This driver handles requests made by the Resumable.js client library. + ## Identifiers In some cases an identifier is needed for the uploaded file when the client side library does not provide one. diff --git a/config/chunk-uploader.php b/config/chunk-uploader.php index fb31f8a..04c98f5 100644 --- a/config/chunk-uploader.php +++ b/config/chunk-uploader.php @@ -12,7 +12,7 @@ | throughout your application here. By default, the module is setup for | monolith upload. | - | Supported: "monolith", "blueimp", "dropzone" + | Supported: "monolith", "blueimp", "dropzone", "resumable-js" | */ @@ -118,4 +118,49 @@ ], + /* + |-------------------------------------------------------------------------- + | Resumable.js Options + |-------------------------------------------------------------------------- + | + | Here you may configure the options for the Resumable.js driver. + | + */ + + 'resumable-js' => [ + + // The name of the multipart request parameter to use for the file chunk + 'param' => 'file', + + // HTTP method for chunk test request. + 'test-method' => Illuminate\Http\Request::METHOD_GET, + // HTTP method to use when sending chunks to the server (POST, PUT, PATCH). + 'upload-method' => Illuminate\Http\Request::METHOD_POST, + + // Extra prefix added before the name of each parameter included in the multipart POST or in the test GET. + 'parameter-namespace' => '', + + 'parameter-names' => [ + // The name of the chunk index (base-1) in the current upload POST parameter to use for the file chunk. + 'chunk-number' => 'resumableChunkNumber', + // The name of the total number of chunks POST parameter to use for the file chunk. + 'total-chunks' => 'resumableTotalChunks', + // The name of the general chunk size POST parameter to use for the file chunk. + 'chunk-size' => 'resumableChunkSize', + // The name of the total file size number POST parameter to use for the file chunk. + 'total-size' => 'resumableTotalSize', + // The name of the unique identifier POST parameter to use for the file chunk. + 'identifier' => 'resumableIdentifier', + // The name of the original file name POST parameter to use for the file chunk. + 'file-name' => 'resumableFilename', + // The name of the file's relative path POST parameter to use for the file chunk. + 'relative-path' => 'resumableRelativePath', + // The name of the current chunk size POST parameter to use for the file chunk. + 'current-chunk-size' => 'resumableCurrentChunkSize', + // The name of the file type POST parameter to use for the file chunk. + 'type' => 'resumableType', + ], + + ], + ]; diff --git a/src/Driver/BlueimpUploadDriver.php b/src/Driver/BlueimpUploadDriver.php index 01f5ab2..17be67a 100644 --- a/src/Driver/BlueimpUploadDriver.php +++ b/src/Driver/BlueimpUploadDriver.php @@ -32,6 +32,12 @@ class BlueimpUploadDriver extends UploadDriver */ private $identifier; + /** + * BlueimpUploadDriver constructor. + * + * @param array $config + * @param \LaraCrafts\ChunkUploader\Identifier\Identifier $identifier + */ public function __construct($config, Identifier $identifier) { $this->fileParam = $config['param']; @@ -84,7 +90,13 @@ public function info(): Response ]); } - public function download(Request $request, StorageConfig $config) + /** + * @param \Illuminate\Http\Request $request + * @param \LaraCrafts\ChunkUploader\StorageConfig $config + * + * @return \Symfony\Component\HttpFoundation\Response + */ + public function download(Request $request, StorageConfig $config): Response { $download = $request->query('download', false); if ($download !== false) { @@ -93,18 +105,16 @@ public function download(Request $request, StorageConfig $config) return $this->fileResponse($filename, $config); } + $request->validate([$this->fileParam => 'required']); $filename = $request->query($this->fileParam); - $directory = $config->getChunkDirectory() . '/' . $filename; - /** @var \Illuminate\Filesystem\FilesystemAdapter $disk */ - $disk = Storage::disk($config->getDisk()); - if (! $disk->exists($directory)) { + if (!$this->chunkExists($config, $filename)) { return new JsonResponse([ 'file' => null, ]); } - $chunk = Arr::last($disk->files($directory)); + $chunk = Arr::last($this->chunks($config, $filename)); $size = explode('-', basename($chunk))[1] + 1; return new JsonResponse([ @@ -117,8 +127,7 @@ public function download(Request $request, StorageConfig $config) /** * @param \Illuminate\Http\Request $request - * @param \LaraCrafts\ChunkUploader\Identifier\Identifier $identifier - * @param StorageConfig $config + * @param \LaraCrafts\ChunkUploader\StorageConfig $config * @param \Closure|null $fileUploaded * * @return \Symfony\Component\HttpFoundation\Response @@ -139,18 +148,20 @@ public function save(Request $request, StorageConfig $config, Closure $fileUploa throw new BadRequestHttpException($e->getMessage(), $e); } - $filename = $this->identifier->generateUploadedFileIdentifierName($file); + $uuid = $this->identifier->generateUploadedFileIdentifierName($file); - $chunks = $this->storeChunk($config, $range, $file, $filename); + $chunks = $this->storeChunk($config, $range, $file, $uuid); - if (! $range->isLast()) { + if (!$range->isLast()) { return new PercentageJsonResponse($range->getPercentage()); } - $path = $this->mergeChunks($config, $chunks, $filename); + $targetFilename = $file->hashName(); + + $path = $this->mergeChunks($config, $chunks, $targetFilename); - if (! empty($config->sweep())) { - Storage::disk($config->getDisk())->deleteDirectory($filename); + if ($config->sweep()) { + $this->deleteChunkDirectory($config, $uuid); } $this->triggerFileUploadedEvent($config->getDisk(), $path, $fileUploaded); @@ -159,11 +170,12 @@ public function save(Request $request, StorageConfig $config, Closure $fileUploa } /** - * @param Request $request - * @param StorageConfig $config - * @return Response + * @param \Illuminate\Http\Request $request + * @param \LaraCrafts\ChunkUploader\StorageConfig $config + * + * @return \Symfony\Component\HttpFoundation\Response */ - public function delete(Request $request, StorageConfig $config) + public function delete(Request $request, StorageConfig $config): Response { $filename = $request->post($this->fileParam); diff --git a/src/Driver/DropzoneUploadDriver.php b/src/Driver/DropzoneUploadDriver.php index cbdaa67..8660981 100644 --- a/src/Driver/DropzoneUploadDriver.php +++ b/src/Driver/DropzoneUploadDriver.php @@ -5,11 +5,9 @@ use Closure; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; -use Illuminate\Support\Facades\Storage; use InvalidArgumentException; use LaraCrafts\ChunkUploader\Helper\ChunkHelpers; -use LaraCrafts\ChunkUploader\Identifier\Identifier; -use LaraCrafts\ChunkUploader\Range\RequestBodyRange; +use LaraCrafts\ChunkUploader\Range\ZeroBasedRequestBodyRange; use LaraCrafts\ChunkUploader\Response\PercentageJsonResponse; use LaraCrafts\ChunkUploader\StorageConfig; use Symfony\Component\HttpFoundation\Response; @@ -25,6 +23,11 @@ class DropzoneUploadDriver extends UploadDriver */ private $fileParam; + /** + * DropzoneUploadDriver constructor. + * + * @param array $config + */ public function __construct($config) { $this->fileParam = $config['param']; @@ -46,8 +49,7 @@ public function handle(Request $request, StorageConfig $config, Closure $fileUpl /** * @param \Illuminate\Http\Request $request - * @param \LaraCrafts\ChunkUploader\Identifier\Identifier $identifier - * @param StorageConfig $config + * @param \LaraCrafts\ChunkUploader\StorageConfig $config * @param \Closure|null $fileUploaded * * @return \Symfony\Component\HttpFoundation\Response @@ -68,11 +70,11 @@ public function save(Request $request, StorageConfig $config, Closure $fileUploa } /** - * @param Request $request + * @param \Illuminate\Http\Request $request * * @return bool */ - private function isMonolithRequest(Request $request) + private function isMonolithRequest(Request $request): bool { return $request->post('dzuuid') === null && $request->post('dzchunkindex') === null @@ -83,9 +85,9 @@ private function isMonolithRequest(Request $request) } /** - * @param Request $request + * @param \Illuminate\Http\Request $request */ - private function validateChunkRequest(Request $request) + private function validateChunkRequest(Request $request): void { $request->validate([ 'dzuuid' => 'required', @@ -98,12 +100,11 @@ private function validateChunkRequest(Request $request) } /** - * @param UploadedFile $file - * @param Identifier $identifier - * @param StorageConfig $config + * @param \Illuminate\Http\UploadedFile $file + * @param \LaraCrafts\ChunkUploader\StorageConfig $config * @param \Closure|null $fileUploaded * - * @return Response + * @return \Symfony\Component\HttpFoundation\Response */ private function saveMonolith(UploadedFile $file, StorageConfig $config, Closure $fileUploaded = null): Response { @@ -117,17 +118,17 @@ private function saveMonolith(UploadedFile $file, StorageConfig $config, Closure } /** - * @param UploadedFile $file - * @param Request $request - * @param StorageConfig $config + * @param \Illuminate\Http\UploadedFile $file + * @param \Illuminate\Http\Request $request + * @param \LaraCrafts\ChunkUploader\StorageConfig $config * @param \Closure|null $fileUploaded * - * @return Response + * @return \Symfony\Component\HttpFoundation\Response */ private function saveChunk(UploadedFile $file, Request $request, StorageConfig $config, Closure $fileUploaded = null): Response { try { - $range = new RequestBodyRange( + $range = new ZeroBasedRequestBodyRange( $request, 'dzchunkindex', 'dztotalchunkcount', @@ -138,23 +139,20 @@ private function saveChunk(UploadedFile $file, Request $request, StorageConfig $ throw new BadRequestHttpException($e->getMessage(), $e); } - $filename = $request->post('dzuuid'); + $uuid = $request->post('dzuuid'); - // On windows you can not create a file whose name ends with a dot - if ($file->getClientOriginalExtension()) { - $filename .= '.' . $file->getClientOriginalExtension(); - } - - $chunks = $this->storeChunk($config, $range, $file, $filename); + $chunks = $this->storeChunk($config, $range, $file, $uuid); if (!$range->isFinished($chunks)) { return new PercentageJsonResponse($range->getPercentage($chunks)); } - $path = $this->mergeChunks($config, $chunks, $filename); + $targetFilename = $file->hashName(); + + $path = $this->mergeChunks($config, $chunks, $targetFilename); - if (!empty($config->sweep())) { - Storage::disk($config->getDisk())->deleteDirectory($filename); + if ($config->sweep()) { + $this->deleteChunkDirectory($config, $uuid); } $this->triggerFileUploadedEvent($config->getDisk(), $path, $fileUploaded); diff --git a/src/Driver/MonolithUploadDriver.php b/src/Driver/MonolithUploadDriver.php index b01f562..7289bac 100644 --- a/src/Driver/MonolithUploadDriver.php +++ b/src/Driver/MonolithUploadDriver.php @@ -17,6 +17,11 @@ class MonolithUploadDriver extends UploadDriver */ private $fileParam; + /** + * MonolithUploadDriver constructor. + * + * @param array $config + */ public function __construct($config) { $this->fileParam = $config['param']; @@ -48,7 +53,6 @@ public function handle(Request $request, StorageConfig $config, Closure $fileUpl /** * @param \Illuminate\Http\Request $request - * @param \LaraCrafts\ChunkUploader\Identifier\Identifier $identifier * @param \LaraCrafts\ChunkUploader\StorageConfig $config * @param \Closure|null $fileUploaded * @@ -88,7 +92,7 @@ public function download(Request $request, StorageConfig $config): Response * * @return \Symfony\Component\HttpFoundation\Response */ - public function delete(Request $request, StorageConfig $config) + public function delete(Request $request, StorageConfig $config): Response { $filename = $request->post($this->fileParam, $request->route($this->fileParam)); diff --git a/src/Driver/ResumableJsUploadDriver.php b/src/Driver/ResumableJsUploadDriver.php new file mode 100644 index 0000000..d4b0139 --- /dev/null +++ b/src/Driver/ResumableJsUploadDriver.php @@ -0,0 +1,203 @@ +fileParam = $config['param']; + + $this->uploadMethod = $config['upload-method']; + $this->testMethod = $config['test-method']; + + $this->parameterNamespace = $config['parameter-namespace']; + $this->parameterNames = $config['parameter-names']; + } + + /** + * @inheritDoc + */ + public function handle(Request $request, StorageConfig $config, Closure $fileUploaded = null): Response + { + if ($this->isRequestMethodIn($request, [$this->testMethod])) { + return $this->resume($request, $config); + } + + if ($this->isRequestMethodIn($request, [$this->uploadMethod])) { + return $this->save($request, $config, $fileUploaded); + } + + throw new MethodNotAllowedHttpException([ + $this->uploadMethod, + $this->testMethod, + ]); + } + + /** + * @param \Illuminate\Http\Request $request + * @param \LaraCrafts\ChunkUploader\StorageConfig $config + * + * @return \Symfony\Component\HttpFoundation\Response + */ + public function resume(Request $request, StorageConfig $config): Response + { + $this->validateChunkRequest($request); + + try { + $range = new OneBasedRequestBodyRange( + $request->query, + $this->buildParameterName('chunk-number'), + $this->buildParameterName('total-chunks'), + $this->buildParameterName('chunk-size'), + $this->buildParameterName('total-size') + ); + } catch (InvalidArgumentException $e) { + throw new BadRequestHttpException($e->getMessage(), $e); + } + + $filename = $request->query($this->buildParameterName('identifier')); + $chunkname = $this->buildChunkname($range); + + if (! $this->chunkExists($config, $filename, $chunkname)) { + throw new NotFoundHttpException(); + } + + return new JsonResponse(['OK']); + } + + /** + * @param \Illuminate\Http\Request $request + * @param \LaraCrafts\ChunkUploader\StorageConfig $config + * @param \Closure|null $fileUploaded + * + * @return \Symfony\Component\HttpFoundation\Response + */ + public function save(Request $request, StorageConfig $config, Closure $fileUploaded = null): Response + { + $file = $request->file($this->fileParam); + + $this->validateUploadedFile($file); + + $this->validateChunkRequest($request); + + return $this->saveChunk($file, $request, $config, $fileUploaded); + } + + /** + * @param \Illuminate\Http\Request $request + */ + private function validateChunkRequest(Request $request): void + { + $validation = []; + + foreach ($this->parameterNames as $key => $_) { + $validation[$this->buildParameterName($key)] = 'required'; + } + + $request->validate($validation); + } + + /** + * @param \Illuminate\Http\UploadedFile $file + * @param \Illuminate\Http\Request $request + * @param \LaraCrafts\ChunkUploader\StorageConfig $config + * @param \Closure|null $fileUploaded + * + * @return \Symfony\Component\HttpFoundation\Response + */ + private function saveChunk(UploadedFile $file, Request $request, StorageConfig $config, Closure $fileUploaded = null): Response + { + try { + $range = new OneBasedRequestBodyRange( + $request, + $this->buildParameterName('chunk-number'), + $this->buildParameterName('total-chunks'), + $this->buildParameterName('chunk-size'), + $this->buildParameterName('total-size') + ); + } catch (InvalidArgumentException $e) { + throw new BadRequestHttpException($e->getMessage(), $e); + } + + $uuid = $request->post($this->buildParameterName('identifier')); + + $chunks = $this->storeChunk($config, $range, $file, $uuid); + + if (!$range->isFinished($chunks)) { + return new PercentageJsonResponse($range->getPercentage($chunks)); + } + + $targetFilename = $file->hashName(); + + $path = $this->mergeChunks($config, $chunks, $targetFilename); + + if ($config->sweep()) { + $this->deleteChunkDirectory($config, $uuid); + } + + $this->triggerFileUploadedEvent($config->getDisk(), $path, $fileUploaded); + + return new PercentageJsonResponse(100); + } + + /** + * @param $key string + * + * @return string + */ + private function buildParameterName(string $key): string + { + if (! array_key_exists($key, $this->parameterNames)) { + throw new InvalidArgumentException(sprintf('`%s` is an invalid key for parameter name', $key)); + } + + return $this->parameterNamespace . $this->parameterNames[$key]; + } +} diff --git a/src/Driver/UploadDriver.php b/src/Driver/UploadDriver.php index edbbda3..ef11778 100644 --- a/src/Driver/UploadDriver.php +++ b/src/Driver/UploadDriver.php @@ -30,15 +30,15 @@ abstract public function handle(Request $request, StorageConfig $config, Closure /** * @param string $filename - * @param array $config + * @param \LaraCrafts\ChunkUploader\StorageConfig $storageConfig * * @return \Symfony\Component\HttpFoundation\Response */ - public function fileResponse(string $filename, StorageConfig $config): Response + public function fileResponse(string $filename, StorageConfig $storageConfig): Response { /** @var \Illuminate\Filesystem\FilesystemAdapter $disk */ - $disk = Storage::disk($config->getDisk()); - $prefix = $config->getMergedDirectory() . '/'; + $disk = Storage::disk($storageConfig->getDisk()); + $prefix = $storageConfig->getMergedDirectory() . '/'; if (! $disk->exists($prefix . $filename)) { throw new NotFoundHttpException($filename . ' file not found on server'); @@ -49,6 +49,14 @@ public function fileResponse(string $filename, StorageConfig $config): Response return new BinaryFileResponse($path, 200, [], true, ResponseHeaderBag::DISPOSITION_ATTACHMENT); } + /** + * Check if the request type of the given request is in the specified list. + * + * @param \Illuminate\Http\Request $request + * @param array $methods + * + * @return bool + */ public function isRequestMethodIn(Request $request, array $methods): bool { foreach ($methods as $method) { @@ -60,7 +68,15 @@ public function isRequestMethodIn(Request $request, array $methods): bool return false; } - protected function triggerFileUploadedEvent($disk, $path, Closure $fileUploaded = null) + /** + * Dispatch a {@link \LaraCrafts\ChunkUploader\Event\FileUploaded} event. + * Also call the given {@link \Closure} if not null. + * + * @param $disk + * @param $path + * @param \Closure|null $fileUploaded + */ + protected function triggerFileUploadedEvent($disk, $path, Closure $fileUploaded = null): void { if ($fileUploaded !== null) { $fileUploaded($disk, $path); @@ -77,7 +93,7 @@ protected function triggerFileUploadedEvent($disk, $path, Closure $fileUploaded * @throws \Symfony\Component\HttpKernel\Exception\BadRequestHttpException when given file is null. * @throws \LaraCrafts\ChunkUploader\Exception\InternalServerErrorHttpException when given file is invalid. */ - protected function validateUploadedFile(UploadedFile $file = null) + protected function validateUploadedFile(UploadedFile $file = null): void { if (null === $file) { throw new BadRequestHttpException('File not found in request body'); diff --git a/src/Exception/RequestEntityTooLargeHttpException.php b/src/Exception/RequestEntityTooLargeHttpException.php index 5cca512..13b733e 100644 --- a/src/Exception/RequestEntityTooLargeHttpException.php +++ b/src/Exception/RequestEntityTooLargeHttpException.php @@ -9,10 +9,9 @@ class RequestEntityTooLargeHttpException extends HttpException /** * RequestEntityTooLargeHttpException constructor. * - * @param null $message - * @param int $statusCode + * @param string|null $message * @param \Exception|null $previous - * @param int|null $code + * @param integer $code */ public function __construct($message = null, \Exception $previous = null, int $code = 0) { diff --git a/src/Helper/ChunkHelpers.php b/src/Helper/ChunkHelpers.php index 3e96f95..37f6979 100644 --- a/src/Helper/ChunkHelpers.php +++ b/src/Helper/ChunkHelpers.php @@ -16,11 +16,13 @@ trait ChunkHelpers protected $bufferSize = 4096; /** - * @param StorageConfig $config + * Combine all the given chunks to one single file with the given filename. + * + * @param \LaraCrafts\ChunkUploader\StorageConfig $config * @param array $chunks * @param string $targetFilename * - * @return string + * @return string The path of the created file. */ public function mergeChunks(StorageConfig $config, array $chunks, string $targetFilename): string { @@ -47,19 +49,29 @@ public function mergeChunks(StorageConfig $config, array $chunks, string $target } /** - * @param StorageConfig $config - * @param Range $range - * @param UploadedFile $file + * Delete a directory with the given name from the chunk directory. + * + * @param \LaraCrafts\ChunkUploader\StorageConfig $config + * @param string $uuid + */ + public function deleteChunkDirectory(StorageConfig $config, string $uuid): void + { + $directory = $config->getChunkDirectory() . '/' . $uuid; + Storage::disk($config->getDisk())->deleteDirectory($directory); + } + + /** + * Persist an uploaded chunk in a directory with the given name in the chunk directory. + * + * @param \LaraCrafts\ChunkUploader\StorageConfig $config + * @param \LaraCrafts\ChunkUploader\Range\Range $range + * @param \Illuminate\Http\UploadedFile $file * @param string $uuid * @return array */ - public function storeChunk(StorageConfig $config, Range $range, UploadedFile $file, string $uuid) + public function storeChunk(StorageConfig $config, Range $range, UploadedFile $file, string $uuid): array { - $len = strlen($range->getTotal()); - $chunkname = implode('-', [ - str_pad($range->getStart(), $len, '0', STR_PAD_LEFT), - str_pad($range->getEnd(), $len, '0', STR_PAD_LEFT), - ]); + $chunkname = $this->buildChunkname($range); $directory = $config->getChunkDirectory() . '/' . $uuid; $file->storeAs($directory, $chunkname, [ @@ -68,4 +80,57 @@ public function storeChunk(StorageConfig $config, Range $range, UploadedFile $fi return Storage::disk($config->getDisk())->files($directory); } + + /** + * List all chunks from a directory with the given name. + * + * @param \LaraCrafts\ChunkUploader\StorageConfig $config + * @param string $uuid + * + * @return array + */ + public function chunks(StorageConfig $config, string $uuid): array + { + $directory = $config->getChunkDirectory() . '/' . $uuid; + return Storage::disk($config->getDisk())->files($directory); + } + + /** + * Create a chunkname which contains range details. + * + * @param \LaraCrafts\ChunkUploader\Range\Range $range + * + * @return string + */ + public function buildChunkname(Range $range): string + { + $len = strlen($range->getTotal()); + return implode('-', [ + str_pad($range->getStart(), $len, '0', STR_PAD_LEFT), + str_pad($range->getEnd(), $len, '0', STR_PAD_LEFT), + ]); + } + + /** + * Check if a chunk exists. + * + * When chunkname is given it checks the exact chunk. Otherwise only the folder has to exists. + * + * @param \LaraCrafts\ChunkUploader\StorageConfig $config + * @param string $uuid + * @param string|null $chunkname + * + * @return bool + */ + public function chunkExists(StorageConfig $config, string $uuid, string $chunkname = null): bool + { + $directory = $config->getChunkDirectory() . '/' . $uuid; + $disk = Storage::disk($config->getDisk()); + + if (! $disk->exists($directory)) { + return false; + } + + return $chunkname === null || $disk->exists($directory . '/' . $chunkname); + } } diff --git a/src/Identifier/Identifier.php b/src/Identifier/Identifier.php index 73bcbab..7cf063f 100644 --- a/src/Identifier/Identifier.php +++ b/src/Identifier/Identifier.php @@ -22,13 +22,6 @@ public function generateUploadedFileIdentifierName(UploadedFile $file): string { $data = $file->getClientOriginalName(); - $filename = $this->generateIdentifier($data); - - // On windows you can not create a file whose name ends with a dot - if ($file->getClientOriginalExtension()) { - $filename .= '.' . $file->getClientOriginalExtension(); - } - - return $filename; + return $this->generateIdentifier($data) . '.' . $file->guessExtension(); } } diff --git a/src/Range/OneBasedRequestBodyRange.php b/src/Range/OneBasedRequestBodyRange.php new file mode 100644 index 0000000..f14c74f --- /dev/null +++ b/src/Range/OneBasedRequestBodyRange.php @@ -0,0 +1,92 @@ +numberOfChunks <= 0) { + throw new InvalidArgumentException(sprintf('`%s` must be greater than zero', $numberOfChunksKey)); + } + } + + /** + * @param string $indexKey + * @param string $numberOfChunksKey + */ + protected function validateIndexKey(string $indexKey, string $numberOfChunksKey): void + { + if ($this->index < 1) { + throw new InvalidArgumentException(sprintf('`%s` must be greater than zero', $indexKey)); + } + if ($this->index > $this->numberOfChunks) { + throw new InvalidArgumentException(sprintf('`%s` must be smaller than or equal to `%s`', $indexKey, $numberOfChunksKey)); + } + } + + /** + * @param string $indexKey + * @param string $numberOfChunksKey + * @param string $chunkSizeKey + * @param string $totalSizeKey + */ + protected function validateTotalSize(string $indexKey, string $numberOfChunksKey, string $chunkSizeKey, string $totalSizeKey): void + { + if ($this->totalSize < 1) { + throw new InvalidArgumentException(sprintf('`%s` must be greater than zero', $totalSizeKey)); + } elseif ($this->totalSize <= ($this->index - 1) * $this->chunkSize) { + throw new InvalidArgumentException( + sprintf('`%s` must be greater than or equal to the multiple of `%s` and `%s`', $totalSizeKey, $chunkSizeKey, $indexKey) + ); + } elseif ($this->totalSize > $this->numberOfChunks * $this->chunkSize) { + throw new InvalidArgumentException( + sprintf('`%s` must be smaller than or equal to the multiple of `%s` and `%s`', $totalSizeKey, $chunkSizeKey, $numberOfChunksKey) + ); + } + } + + /** + * {@inheritDoc} + */ + public function getStart(): float + { + return ($this->index - 1) * $this->chunkSize; + } + + /** + * {@inheritDoc} + */ + public function getEnd(): float + { + $end = ($this->index * $this->chunkSize) - 1; + + $sizeIndex = $this->totalSize - 1; + if ($end > ($sizeIndex)) { + return $sizeIndex; + } + + return $end; + } + + /** + * {@inheritDoc} + */ + public function isFirst(): bool + { + return $this->index === 1; + } + + /** + * {@inheritDoc} + */ + public function isLast(): bool + { + return $this->index === $this->numberOfChunks; + } +} diff --git a/src/Range/RequestBodyRange.php b/src/Range/RequestBodyRange.php index 48b1f09..135c35c 100644 --- a/src/Range/RequestBodyRange.php +++ b/src/Range/RequestBodyRange.php @@ -5,15 +5,15 @@ use Illuminate\Http\Request; use InvalidArgumentException; -class RequestBodyRange implements Range +abstract class RequestBodyRange implements Range { - private $index; + protected $index; - private $numberOfChunks; + protected $numberOfChunks; - private $totalSize; + protected $totalSize; - private $chunkSize; + protected $chunkSize; /** * RequestRange constructor. @@ -36,53 +36,55 @@ public function __construct($request, string $indexKey, string $numberOfChunksKe // Must be double (which is an alias for float) for 32 bit systems $this->totalSize = (double) $request->get($totalSizeKey); + $this->validateNumberOfChunks($numberOfChunksKey); + $this->validateIndexKey($indexKey, $numberOfChunksKey); + $this->validateChunkSize($chunkSizeKey); + $this->validateTotalSize($indexKey, $numberOfChunksKey, $chunkSizeKey, $totalSizeKey); + } + + /** + * @param string $numberOfChunksKey + */ + protected function validateNumberOfChunks(string $numberOfChunksKey): void + { if ($this->numberOfChunks <= 0) { throw new InvalidArgumentException(sprintf('`%s` must be greater than zero', $numberOfChunksKey)); } - if ($this->index < 0) { - throw new InvalidArgumentException(sprintf('`%s` must be greater than or equal to zero', $indexKey)); - } - if ($this->index >= $this->numberOfChunks) { - throw new InvalidArgumentException(sprintf('`%s` must be smaller than `%s`', $indexKey, $numberOfChunksKey)); - } + } + + /** + * @param string $indexKey + * @param string $numberOfChunksKey + */ + abstract protected function validateIndexKey(string $indexKey, string $numberOfChunksKey): void; + + /** + * @param string $chunkSizeKey + */ + protected function validateChunkSize(string $chunkSizeKey): void + { if ($this->chunkSize < 1) { throw new InvalidArgumentException(sprintf('`%s` must be greater than zero', $chunkSizeKey)); } - if ($this->totalSize < 1) { - throw new InvalidArgumentException(sprintf('`%s` must be greater than zero', $totalSizeKey)); - } elseif ($this->totalSize <= $this->index * $this->chunkSize) { - throw new InvalidArgumentException( - sprintf('`%s` must be greater than the multiple of `%s` and `%s`', $totalSizeKey, $chunkSizeKey, $indexKey) - ); - } elseif ($this->totalSize > $this->numberOfChunks * $this->chunkSize) { - throw new InvalidArgumentException( - sprintf('`%s` must be smaller than or equal to the multiple of `%s` and `%s`', $totalSizeKey, $chunkSizeKey, $numberOfChunksKey) - ); - } } /** - * {@inheritDoc} + * @param string $indexKey + * @param string $numberOfChunksKey + * @param string $chunkSizeKey + * @param string $totalSizeKey */ - public function getStart(): float - { - return $this->index * $this->chunkSize; - } + abstract protected function validateTotalSize(string $indexKey, string $numberOfChunksKey, string $chunkSizeKey, string $totalSizeKey): void; /** * {@inheritDoc} */ - public function getEnd(): float - { - $end = (($this->index + 1) * $this->chunkSize) - 1; - - $sizeIndex = $this->totalSize - 1; - if ($end > ($sizeIndex)) { - return $sizeIndex; - } + abstract public function getStart(): float; - return $end; - } + /** + * {@inheritDoc} + */ + abstract public function getEnd(): float; /** * {@inheritDoc} @@ -95,18 +97,12 @@ public function getTotal(): float /** * {@inheritDoc} */ - public function isFirst(): bool - { - return $this->index === 0; - } + abstract public function isFirst(): bool; /** * {@inheritDoc} */ - public function isLast(): bool - { - return $this->index === $this->numberOfChunks - 1; - } + abstract public function isLast(): bool; /** * @param $uploadedChunks diff --git a/src/Range/ZeroBasedRequestBodyRange.php b/src/Range/ZeroBasedRequestBodyRange.php new file mode 100644 index 0000000..1752c4e --- /dev/null +++ b/src/Range/ZeroBasedRequestBodyRange.php @@ -0,0 +1,82 @@ +index < 0) { + throw new InvalidArgumentException(sprintf('`%s` must be greater than or equal to zero', $indexKey)); + } + if ($this->index >= $this->numberOfChunks) { + throw new InvalidArgumentException(sprintf('`%s` must be smaller than `%s`', $indexKey, $numberOfChunksKey)); + } + } + + /** + * @param string $indexKey + * @param string $numberOfChunksKey + * @param string $chunkSizeKey + * @param string $totalSizeKey + */ + protected function validateTotalSize(string $indexKey, string $numberOfChunksKey, string $chunkSizeKey, string $totalSizeKey): void + { + if ($this->totalSize < 1) { + throw new InvalidArgumentException(sprintf('`%s` must be greater than zero', $totalSizeKey)); + } elseif ($this->totalSize <= $this->index * $this->chunkSize) { + throw new InvalidArgumentException( + sprintf('`%s` must be greater than the multiple of `%s` and `%s`', $totalSizeKey, $chunkSizeKey, $indexKey) + ); + } elseif ($this->totalSize > $this->numberOfChunks * $this->chunkSize) { + throw new InvalidArgumentException( + sprintf('`%s` must be smaller than or equal to the multiple of `%s` and `%s`', $totalSizeKey, $chunkSizeKey, $numberOfChunksKey) + ); + } + } + + /** + * {@inheritDoc} + */ + public function getStart(): float + { + return $this->index * $this->chunkSize; + } + + /** + * {@inheritDoc} + */ + public function getEnd(): float + { + $end = (($this->index + 1) * $this->chunkSize) - 1; + + $sizeIndex = $this->totalSize - 1; + if ($end > ($sizeIndex)) { + return $sizeIndex; + } + + return $end; + } + + /** + * {@inheritDoc} + */ + public function isFirst(): bool + { + return $this->index === 0; + } + + /** + * {@inheritDoc} + */ + public function isLast(): bool + { + return $this->index === ($this->numberOfChunks - 1); + } +} diff --git a/src/UploadManager.php b/src/UploadManager.php index 2272942..f53fac9 100644 --- a/src/UploadManager.php +++ b/src/UploadManager.php @@ -6,6 +6,7 @@ use LaraCrafts\ChunkUploader\Driver\BlueimpUploadDriver; use LaraCrafts\ChunkUploader\Driver\DropzoneUploadDriver; use LaraCrafts\ChunkUploader\Driver\MonolithUploadDriver; +use LaraCrafts\ChunkUploader\Driver\ResumableJsUploadDriver; class UploadManager extends Manager { @@ -27,6 +28,11 @@ public function createDropzoneDriver() return new DropzoneUploadDriver($this->app['config']['chunk-uploader.dropzone']); } + public function createResumableJsDriver() + { + return new ResumableJsUploadDriver($this->app['config']['chunk-uploader.resumable-js']); + } + /** * Get the default driver name. * diff --git a/tests/Driver/BlueimpUploadDriverTest.php b/tests/Driver/BlueimpUploadDriverTest.php index 73a72be..8cba11e 100644 --- a/tests/Driver/BlueimpUploadDriverTest.php +++ b/tests/Driver/BlueimpUploadDriverTest.php @@ -137,8 +137,9 @@ public function testUploadWhenFileParameterIsInvalid() public function testUploadFirstChunk() { + $file = UploadedFile::fake()->create('test.txt', 100); $request = Request::create('', Request::METHOD_POST, [], [], [ - 'file' => UploadedFile::fake()->create('test.txt', 100), + 'file' => $file, ], [ 'HTTP_CONTENT_RANGE' => 'bytes 0-99/200', ]); @@ -150,15 +151,16 @@ public function testUploadFirstChunk() Storage::disk('local')->assertExists('chunks/2494cefe4d234bd331aeb4514fe97d810efba29b.txt/000-099'); - Event::assertNotDispatched(FileUploaded::class, function ($event) { - return $event->file = 'merged/2494cefe4d234bd331aeb4514fe97d810efba29b.txt'; + Event::assertNotDispatched(FileUploaded::class, function ($event) use ($file) { + return $event->file = $file->hashName('merged'); }); } public function testUploadFirstChunkWithCallback() { + $file = UploadedFile::fake()->create('test.txt', 100); $request = Request::create('', Request::METHOD_POST, [], [], [ - 'file' => UploadedFile::fake()->create('test.txt', 100), + 'file' => $file, ], [ 'HTTP_CONTENT_RANGE' => 'bytes 0-99/200', ]); @@ -167,8 +169,8 @@ public function testUploadFirstChunkWithCallback() $this->handler->handle($request, $callback); - Event::assertNotDispatched(FileUploaded::class, function ($event) { - return $event->file = 'merged/2494cefe4d234bd331aeb4514fe97d810efba29b.txt'; + Event::assertNotDispatched(FileUploaded::class, function ($event) use ($file) { + return $event->file = $file->hashName('merged'); }); } @@ -176,8 +178,9 @@ public function testUploadLastChunk() { $this->createFakeLocalFile('chunks/2494cefe4d234bd331aeb4514fe97d810efba29b.txt', '000'); + $file = UploadedFile::fake()->create('test.txt', 100); $request = Request::create('', Request::METHOD_POST, [], [], [ - 'file' => UploadedFile::fake()->create('test.txt', 100), + 'file' => $file, ], [ 'HTTP_CONTENT_RANGE' => 'bytes 100-199/200', ]); @@ -188,10 +191,10 @@ public function testUploadLastChunk() $response->assertJson(['done' => 100]); Storage::disk('local')->assertExists('chunks/2494cefe4d234bd331aeb4514fe97d810efba29b.txt/100-199'); - Storage::disk('local')->assertExists('merged/2494cefe4d234bd331aeb4514fe97d810efba29b.txt'); + Storage::disk('local')->assertExists($file->hashName('merged')); - Event::assertDispatched(FileUploaded::class, function ($event) { - return $event->file = 'merged/2494cefe4d234bd331aeb4514fe97d810efba29b.txt'; + Event::assertDispatched(FileUploaded::class, function ($event) use ($file) { + return $event->file = $file->hashName('merged'); }); } @@ -199,8 +202,9 @@ public function testUploadLastChunkWithCallback() { $this->createFakeLocalFile('chunks/2494cefe4d234bd331aeb4514fe97d810efba29b.txt', '000'); + $file = UploadedFile::fake()->create('test.txt', 100); $request = Request::create('', Request::METHOD_POST, [], [], [ - 'file' => UploadedFile::fake()->create('test.txt', 100), + 'file' => $file, ], [ 'HTTP_CONTENT_RANGE' => 'bytes 100-199/200', ]); @@ -208,13 +212,13 @@ public function testUploadLastChunkWithCallback() $callback = $this->createClosureMock( $this->once(), 'local', - 'merged/2494cefe4d234bd331aeb4514fe97d810efba29b.txt' + $file->hashName('merged') ); $this->handler->handle($request, $callback); - Event::assertDispatched(FileUploaded::class, function ($event) { - return $event->file = 'merged/2494cefe4d234bd331aeb4514fe97d810efba29b.txt'; + Event::assertDispatched(FileUploaded::class, function ($event) use ($file) { + return $event->file = $file->hashName('merged'); }); } diff --git a/tests/Driver/DropzoneUploadDriverTest.php b/tests/Driver/DropzoneUploadDriverTest.php index bfd0a18..1b27320 100644 --- a/tests/Driver/DropzoneUploadDriverTest.php +++ b/tests/Driver/DropzoneUploadDriverTest.php @@ -143,6 +143,7 @@ public function testPostParameterValidation($exclude) public function testUploadFirstChunk() { + $file = UploadedFile::fake()->create('test.txt', 100); $request = Request::create('', Request::METHOD_POST, [ 'dzuuid' => '2494cefe4d234bd331aeb4514fe97d810efba29b', 'dzchunkindex' => 0, @@ -151,7 +152,7 @@ public function testUploadFirstChunk() 'dztotalchunkcount' => 2, 'dzchunkbyteoffset' => 100, ], [], [ - 'file' => UploadedFile::fake()->create('test.txt', 100), + 'file' => $file, ]); /** @var \Illuminate\Foundation\Testing\TestResponse $response */ @@ -159,15 +160,16 @@ public function testUploadFirstChunk() $response->assertSuccessful(); $response->assertJson(['done' => 50]); - Storage::disk('local')->assertExists('chunks/2494cefe4d234bd331aeb4514fe97d810efba29b.txt/000-099'); + Storage::disk('local')->assertExists('chunks/2494cefe4d234bd331aeb4514fe97d810efba29b/000-099'); - Event::assertNotDispatched(FileUploaded::class, function ($event) { - return $event->file = 'merged/2494cefe4d234bd331aeb4514fe97d810efba29b.txt'; + Event::assertNotDispatched(FileUploaded::class, function ($event) use ($file) { + return $event->file = $file->hashName('merged'); }); } public function testUploadFirstChunkWithCallback() { + $file = UploadedFile::fake()->create('test.txt', 100); $request = Request::create('', Request::METHOD_POST, [ 'dzuuid' => '2494cefe4d234bd331aeb4514fe97d810efba29b', 'dzchunkindex' => 0, @@ -176,22 +178,23 @@ public function testUploadFirstChunkWithCallback() 'dztotalchunkcount' => 2, 'dzchunkbyteoffset' => 100, ], [], [ - 'file' => UploadedFile::fake()->create('test.txt', 100), + 'file' => $file, ]); $callback = $this->createClosureMock($this->never()); $this->handler->handle($request, $callback); - Event::assertNotDispatched(FileUploaded::class, function ($event) { - return $event->file = 'merged/2494cefe4d234bd331aeb4514fe97d810efba29b.txt'; + Event::assertNotDispatched(FileUploaded::class, function ($event) use ($file) { + return $event->file = $file->hashName('merged'); }); } public function testUploadLastChunk() { - $this->createFakeLocalFile('chunks/2494cefe4d234bd331aeb4514fe97d810efba29b.txt', '000'); + $this->createFakeLocalFile('chunks/2494cefe4d234bd331aeb4514fe97d810efba29b', '000'); + $file = UploadedFile::fake()->create('test.txt', 100); $request = Request::create('', Request::METHOD_POST, [ 'dzuuid' => '2494cefe4d234bd331aeb4514fe97d810efba29b', 'dzchunkindex' => 1, @@ -200,7 +203,7 @@ public function testUploadLastChunk() 'dztotalchunkcount' => 2, 'dzchunkbyteoffset' => 100, ], [], [ - 'file' => UploadedFile::fake()->create('test.txt', 100), + 'file' => $file, ]); /** @var \Illuminate\Foundation\Testing\TestResponse $response */ @@ -208,18 +211,19 @@ public function testUploadLastChunk() $response->assertSuccessful(); $response->assertJson(['done' => 100]); - Storage::disk('local')->assertExists('chunks/2494cefe4d234bd331aeb4514fe97d810efba29b.txt/100-199'); - Storage::disk('local')->assertExists('merged/2494cefe4d234bd331aeb4514fe97d810efba29b.txt'); + Storage::disk('local')->assertExists('chunks/2494cefe4d234bd331aeb4514fe97d810efba29b/100-199'); + Storage::disk('local')->assertExists($file->hashName('merged')); - Event::assertDispatched(FileUploaded::class, function ($event) { - return $event->file = 'merged/2494cefe4d234bd331aeb4514fe97d810efba29b.txt'; + Event::assertDispatched(FileUploaded::class, function ($event) use ($file) { + return $event->file = $file->hashName('merged'); }); } public function testUploadLastChunkWithCallback() { - $this->createFakeLocalFile('chunks/2494cefe4d234bd331aeb4514fe97d810efba29b.txt', '000'); + $this->createFakeLocalFile('chunks/2494cefe4d234bd331aeb4514fe97d810efba29b', '000'); + $file = UploadedFile::fake()->create('test.txt', 100); $request = Request::create('', Request::METHOD_POST, [ 'dzuuid' => '2494cefe4d234bd331aeb4514fe97d810efba29b', 'dzchunkindex' => 1, @@ -228,19 +232,19 @@ public function testUploadLastChunkWithCallback() 'dztotalchunkcount' => 2, 'dzchunkbyteoffset' => 100, ], [], [ - 'file' => UploadedFile::fake()->create('test.txt', 100), + 'file' => $file, ]); $callback = $this->createClosureMock( $this->once(), 'local', - 'merged/2494cefe4d234bd331aeb4514fe97d810efba29b.txt' + $file->hashName('merged') ); $this->handler->handle($request, $callback); - Event::assertDispatched(FileUploaded::class, function ($event) { - return $event->file = 'merged/2494cefe4d234bd331aeb4514fe97d810efba29b.txt'; + Event::assertDispatched(FileUploaded::class, function ($event) use ($file) { + return $event->file = $file->hashName('merged'); }); } } diff --git a/tests/Driver/ResumableJsUploadDriverTest.php b/tests/Driver/ResumableJsUploadDriverTest.php new file mode 100644 index 0000000..3672349 --- /dev/null +++ b/tests/Driver/ResumableJsUploadDriverTest.php @@ -0,0 +1,300 @@ +app->make('config')->set('chunk-uploader.uploader', 'resumable-js'); + $this->app->make('config')->set('chunk-uploader.sweep', false); + $this->handler = $this->app->make(UploadHandler::class); + + Storage::fake('local'); + Event::fake(); + } + + public function testDriverInstance() + { + $manager = $this->app->make('chunk-uploader.upload-manager'); + + $this->assertInstanceOf(ResumableJsUploadDriver::class, $manager->driver()); + } + + public function notAllowedRequestMethods() + { + return [ + 'HEAD' => [Request::METHOD_HEAD], + 'PUT' => [Request::METHOD_PUT], + 'PATCH' => [Request::METHOD_PATCH], + 'DELETE' => [Request::METHOD_DELETE], + 'PURGE' => [Request::METHOD_PURGE], + 'OPTIONS' => [Request::METHOD_OPTIONS], + 'TRACE' => [Request::METHOD_TRACE], + 'CONNECT' => [Request::METHOD_CONNECT], + ]; + } + + /** + * @dataProvider notAllowedRequestMethods + */ + public function testMethodNotAllowed($requestMethod) + { + $request = Request::create('', $requestMethod); + + $this->expectException(MethodNotAllowedHttpException::class); + + $this->createTestResponse($this->handler->handle($request)); + } + + public function testResumeWhenChunkDoesNotExists() + { + $this->createFakeLocalFile('chunks/200-0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zftxt', '000-099'); + + $request = Request::create('', Request::METHOD_GET, [ + 'resumableChunkNumber' => 2, + 'resumableTotalChunks' => 2, + 'resumableChunkSize' => 100, + 'resumableTotalSize' => 200, + 'resumableIdentifier' => '200-0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zftxt', + 'resumableFilename' => '0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zf.txt', + 'resumableRelativePath' => '0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zf.txt', + 'resumableCurrentChunkSize' => 100, + 'resumableType' => 'text/plain', + ]); + + $this->expectException(NotFoundHttpException::class); + + $this->createTestResponse($this->handler->handle($request)); + } + + public function testResume() + { + $this->createFakeLocalFile('chunks/200-0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zftxt', '000-099'); + + $request = Request::create('', Request::METHOD_GET, [ + 'resumableChunkNumber' => 1, + 'resumableTotalChunks' => 2, + 'resumableChunkSize' => 100, + 'resumableTotalSize' => 200, + 'resumableIdentifier' => '200-0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zftxt', + 'resumableFilename' => '0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zf.txt', + 'resumableRelativePath' => '0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zf.txt', + 'resumableCurrentChunkSize' => 100, + 'resumableType' => 'text/plain', + ]); + + $response = $this->createTestResponse($this->handler->handle($request)); + $response->assertSuccessful(); + } + + public function testUploadWhenFileParameterIsEmpty() + { + $request = Request::create('', Request::METHOD_POST); + + $this->expectException(BadRequestHttpException::class); + + $this->handler->handle($request); + } + + public function testUploadWhenFileParameterIsInvalid() + { + $file = Mockery::mock(UploadedFile::class) + ->makePartial(); + $file->shouldReceive('isValid') + ->andReturn(false); + + $request = Request::create('', Request::METHOD_POST, [], [], [ + 'file' => $file, + ]); + + $this->expectException(InternalServerErrorHttpException::class); + + $this->handler->handle($request); + } + + public function excludedPostParameterProvider() + { + return [ + 'resumableChunkNumber' => ['resumableChunkNumber'], + 'resumableTotalChunks' => ['resumableTotalChunks'], + 'resumableChunkSize' => ['resumableChunkSize'], + 'resumableTotalSize' => ['resumableTotalSize'], + 'resumableIdentifier' => ['resumableIdentifier'], + 'resumableFilename' => ['resumableFilename'], + 'resumableRelativePath' => ['resumableRelativePath'], + 'resumableCurrentChunkSize' => ['resumableCurrentChunkSize'], + 'resumableType' => ['resumableType'], + ]; + } + + /** + * @dataProvider excludedPostParameterProvider + */ + public function testPostParameterValidation($exclude) + { + $arr = [ + 'resumableChunkNumber' => 1, + 'resumableTotalChunks' => 2, + 'resumableChunkSize' => 100, + 'resumableTotalSize' => 200, + 'resumableIdentifier' => '200-0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zftxt', + 'resumableFilename' => '0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zf.txt', + 'resumableRelativePath' => '0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zf.txt', + 'resumableCurrentChunkSize' => 100, + 'resumableType' => 'text/plain', + ]; + + unset($arr[$exclude]); + + $request = Request::create('', Request::METHOD_POST, $arr, [], [ + 'file' => UploadedFile::fake() + ->create('test.txt', 100), + ]); + + $this->expectException(ValidationException::class); + + $this->handler->handle($request); + } + + public function testUploadFirstChunk() + { + $file = UploadedFile::fake()->create('test.txt', 100); + $request = Request::create('', Request::METHOD_POST, [ + 'resumableChunkNumber' => 1, + 'resumableTotalChunks' => 2, + 'resumableChunkSize' => 100, + 'resumableTotalSize' => 200, + 'resumableIdentifier' => '200-0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zftxt', + 'resumableFilename' => '0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zf.txt', + 'resumableRelativePath' => '0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zf.txt', + 'resumableCurrentChunkSize' => 100, + 'resumableType' => 'text/plain', + ], [], [ + 'file' => $file, + ]); + + /** @var \Illuminate\Foundation\Testing\TestResponse $response */ + $response = $this->createTestResponse($this->handler->handle($request)); + $response->assertSuccessful(); + $response->assertJson(['done' => 50]); + + Storage::disk('local')->assertExists('chunks/200-0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zftxt/000-099'); + + Event::assertNotDispatched(FileUploaded::class, function ($event) use ($file) { + return $event->file = $file->hashName('merged'); + }); + } + + public function testUploadFirstChunkWithCallback() + { + $file = UploadedFile::fake()->create('test.txt', 100); + $request = Request::create('', Request::METHOD_POST, [ + 'resumableChunkNumber' => 1, + 'resumableTotalChunks' => 2, + 'resumableChunkSize' => 100, + 'resumableTotalSize' => 200, + 'resumableIdentifier' => '200-0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zftxt', + 'resumableFilename' => '0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zf.txt', + 'resumableRelativePath' => '0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zf.txt', + 'resumableCurrentChunkSize' => 100, + 'resumableType' => 'text/plain', + ], [], [ + 'file' => $file, + ]); + + $callback = $this->createClosureMock($this->never()); + + $this->handler->handle($request, $callback); + + Event::assertNotDispatched(FileUploaded::class, function ($event) use ($file) { + return $event->file = $file->hashName('merged'); + }); + } + + public function testUploadLastChunk() + { + $this->createFakeLocalFile('chunks/200-0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zftxt', '000-099'); + + $file = UploadedFile::fake()->create('test.txt', 100); + $request = Request::create('', Request::METHOD_POST, [ + 'resumableChunkNumber' => 2, + 'resumableTotalChunks' => 2, + 'resumableChunkSize' => 100, + 'resumableTotalSize' => 200, + 'resumableIdentifier' => '200-0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zftxt', + 'resumableFilename' => '0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zf.txt', + 'resumableRelativePath' => '0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zf.txt', + 'resumableCurrentChunkSize' => 100, + 'resumableType' => 'text/plain', + ], [], [ + 'file' => $file, + ]); + + /** @var \Illuminate\Foundation\Testing\TestResponse $response */ + $response = $this->createTestResponse($this->handler->handle($request)); + $response->assertSuccessful(); + $response->assertJson(['done' => 100]); + + Storage::disk('local')->assertExists('chunks/200-0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zftxt/100-199'); + Storage::disk('local')->assertExists($file->hashName('merged')); + + Event::assertDispatched(FileUploaded::class, function ($event) use ($file) { + return $event->file = $file->hashName('merged'); + }); + } + + public function testUploadLastChunkWithCallback() + { + $this->createFakeLocalFile('chunks/200-0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zftxt', '000-099'); + + $file = UploadedFile::fake()->create('test.txt', 100); + $request = Request::create('', Request::METHOD_POST, [ + 'resumableChunkNumber' => 2, + 'resumableTotalChunks' => 2, + 'resumableChunkSize' => 100, + 'resumableTotalSize' => 200, + 'resumableIdentifier' => '200-0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zftxt', + 'resumableFilename' => '0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zf.txt', + 'resumableRelativePath' => '0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zf.txt', + 'resumableCurrentChunkSize' => 100, + 'resumableType' => 'text/plain', + ], [], [ + 'file' => $file, + ]); + + $callback = $this->createClosureMock( + $this->once(), + 'local', + $file->hashName('merged') + ); + + $this->handler->handle($request, $callback); + + Event::assertDispatched(FileUploaded::class, function ($event) use ($file) { + return $event->file = $file->hashName('merged'); + }); + } +} diff --git a/tests/Identifier/SessionIdentifierTest.php b/tests/Identifier/SessionIdentifierTest.php index fd8bc0d..c6f3b83 100644 --- a/tests/Identifier/SessionIdentifierTest.php +++ b/tests/Identifier/SessionIdentifierTest.php @@ -47,6 +47,6 @@ public function testUploadedFileIdentifierNameWithoutExtension() { $file = UploadedFile::fake()->create('test', 100); $identifier = $this->identifier->generateUploadedFileIdentifierName($file); - $this->assertEquals('3b7b99bf70a98a544319cf3bad9e912e1b89984d', $identifier); + $this->assertEquals('3b7b99bf70a98a544319cf3bad9e912e1b89984d.bin', $identifier); } } diff --git a/tests/Range/OneBasedRequestBodyRangeTest.php b/tests/Range/OneBasedRequestBodyRangeTest.php new file mode 100644 index 0000000..8cf3f0a --- /dev/null +++ b/tests/Range/OneBasedRequestBodyRangeTest.php @@ -0,0 +1,145 @@ + [5, 0, 20, 190, '`numberOfChunks` must be greater than zero'], + 'Number of chunks size smaller than zero' => [5, -1, 20, 190, '`numberOfChunks` must be greater than zero'], + 'Index smaller than zero' => [0, 10, 20, 190, '`index` must be greater than'], + 'Index greater than the number of chunks' => [15, 10, 20, 190, '`index` must be smaller than or equal to `numberOfChunks`'], + 'Chunk size equal to zero' => [5, 10, 0, 190, '`chunkSize` must be greater than zero'], + 'Chunk size smaller than zero' => [5, 10, -1, 190, '`chunkSize` must be greater than zero'], + 'Total size equal to zero' => [5, 10, 20, 0, '`totalSize` must be greater than zero'], + 'Total size smaller than zero' => [5, 10, 20, -1, '`totalSize` must be greater than zero'], + 'Total size too small' => [5, 10, 20, 80, '`totalSize` must be greater than or equal to the multiple of `chunkSize` and `index`'], + 'Total size too big' => [5, 10, 20, 201, '`totalSize` must be smaller than or equal to the multiple of `chunkSize` and `numberOfChunks`'], + ]; + } + + /** + * @dataProvider invalidArgumentProvider + * + * @param $index + * @param $numberOfChunks + * @param $chunkSize + * @param $totalSize + * @param $expectedExceptionMessage + */ + public function testArgumentValidation($index, $numberOfChunks, $chunkSize, $totalSize, $expectedExceptionMessage) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + $this->createRequestBodyRange($index, $numberOfChunks, $chunkSize, $totalSize); + } + + public function testIsFirst() + { + $range = $this->createRequestBodyRange(1, 2, 1, 2); + $this->assertTrue($range->isFirst()); + + $range = $this->createRequestBodyRange(2, 2, 1, 2); + $this->assertFalse($range->isFirst()); + } + + public function testIsLast() + { + $range = $this->createRequestBodyRange(2, 2, 1, 2); + $this->assertTrue($range->isLast()); + + $range = $this->createRequestBodyRange(1, 2, 1, 2); + $this->assertFalse($range->isLast()); + } + + public function testIsFirstAndIsLast() + { + $range = $this->createRequestBodyRange(1, 1, 1, 1); + $this->assertTrue($range->isLast()); + $this->assertTrue($range->isLast()); + } + + public function testGetTotal() + { + $range = $this->createRequestBodyRange(4, 10, 20, 190); + $this->assertEquals(190, $range->getTotal()); + } + + public function testGetStart() + { + $range = $this->createRequestBodyRange(5, 10, 20, 190); + $this->assertEquals(80, $range->getStart()); + } + + public function testGetEnd() + { + $range = $this->createRequestBodyRange(5, 10, 20, 190); + $this->assertEquals(99, $range->getEnd()); + + $range = $this->createRequestBodyRange(10, 10, 20, 190); + $this->assertEquals(189, $range->getEnd()); + } + + public function testGetPercentage() + { + $range = $this->createRequestBodyRange(4, 10, 20, 190); + $this->assertEquals(100, $range->getPercentage(range(0, 9))); + + $range = $this->createRequestBodyRange(4, 10, 20, 190); + $this->assertEquals(90, $range->getPercentage(range(0, 8))); + } + + public function testIsFinished() + { + $range = $this->createRequestBodyRange(4, 10, 20, 190); + $this->assertTrue($range->isFinished(range(0, 9))); + + $range = $this->createRequestBodyRange(4, 10, 20, 190); + $this->assertFalse($range->isFinished(range(0, 8))); + } + + public function testCreateFromRequest() + { + $request = new Request([], [ + 'index' => 5, + 'numberOfChunks' => 10, + 'chunkSize' => 20, + 'totalSize' => 190, + ]); + + $range = new OneBasedRequestBodyRange($request, 'index', 'numberOfChunks', 'chunkSize', 'totalSize'); + + $this->assertEquals(80, $range->getStart()); + $this->assertEquals(99, $range->getEnd()); + $this->assertEquals(190, $range->getTotal()); + } + + /** + * @param int $index + * @param int $numberOfChunks + * @param int $chunkSize + * @param float $totalSize + * + * @return \LaraCrafts\ChunkUploader\Range\OneBasedRequestBodyRange + */ + private function createRequestBodyRange(int $index, int $numberOfChunks, int $chunkSize, float $totalSize) + { + $request = new ParameterBag([ + 'index' => (string) $index, + 'numberOfChunks' => (string) $numberOfChunks, + 'chunkSize' => (string) $chunkSize, + 'totalSize' => (string) $totalSize, + ]); + + return new OneBasedRequestBodyRange($request, 'index', 'numberOfChunks', 'chunkSize', 'totalSize'); + } +} diff --git a/tests/Range/RequestBodyRangeTest.php b/tests/Range/ZeroBasedRequestBodyRangeTest.php similarity index 88% rename from tests/Range/RequestBodyRangeTest.php rename to tests/Range/ZeroBasedRequestBodyRangeTest.php index 19e0d1d..bce10dc 100644 --- a/tests/Range/RequestBodyRangeTest.php +++ b/tests/Range/ZeroBasedRequestBodyRangeTest.php @@ -4,11 +4,11 @@ use Illuminate\Http\Request; use InvalidArgumentException; -use LaraCrafts\ChunkUploader\Range\RequestBodyRange; +use LaraCrafts\ChunkUploader\Range\ZeroBasedRequestBodyRange; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\ParameterBag; -class RequestBodyRangeTest extends TestCase +class ZeroBasedRequestBodyRangeTest extends TestCase { public function invalidArgumentProvider() { @@ -117,7 +117,7 @@ public function testCreateFromRequest() 'totalSize' => 190, ]); - $range = new RequestBodyRange($request, 'index', 'numberOfChunks', 'chunkSize', 'totalSize'); + $range = new ZeroBasedRequestBodyRange($request, 'index', 'numberOfChunks', 'chunkSize', 'totalSize'); $this->assertEquals(80, $range->getStart()); $this->assertEquals(99, $range->getEnd()); @@ -125,14 +125,14 @@ public function testCreateFromRequest() } /** - * @param $index - * @param $numberOfChunks - * @param $chunkSize - * @param $totalSize + * @param int $index + * @param int $numberOfChunks + * @param int $chunkSize + * @param float $totalSize * - * @return \LaraCrafts\ChunkUploader\Range\RequestBodyRange + * @return \LaraCrafts\ChunkUploader\Range\ZeroBasedRequestBodyRange */ - private function createRequestBodyRange($index, $numberOfChunks, $chunkSize, $totalSize) + private function createRequestBodyRange(int $index, int $numberOfChunks, int $chunkSize, float $totalSize) { $request = new ParameterBag([ 'index' => (string) $index, @@ -141,6 +141,6 @@ private function createRequestBodyRange($index, $numberOfChunks, $chunkSize, $to 'totalSize' => (string) $totalSize, ]); - return new RequestBodyRange($request, 'index', 'numberOfChunks', 'chunkSize', 'totalSize'); + return new ZeroBasedRequestBodyRange($request, 'index', 'numberOfChunks', 'chunkSize', 'totalSize'); } }