diff --git a/README.md b/README.md index bb2d525..9b8d2bc 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ project at the moment is [tus](https://tus.io/). - [Blueimp](#blueimp-driver) - [DropzoneJS](#dropzonejs-driver) - [Flow.js](#flow-js-driver) + - [Plupload](#plupload-driver) - [Resumable.js](#resumable-js-driver) - [simple-uploader.js](#simple-uploader-js-driver) - [Identifiers](#identifiers) @@ -152,6 +153,7 @@ Service | Driver name | Chunk [Blueimp](#blueimp-driver) | `blueimp` | yes | yes [DropzoneJS](#dropzonejs-driver) | `dropzone` | yes | no [Flow.js](#flow-js-driver) | `flow-js` | yes | yes +[Plupload](#plupload-driver) | `plupload` | yes | no [Resumable.js](#resumable-js-driver) | `resumable-js` | yes | yes [simple-uploader.js](#simple-uploader-js-driver) | `simple-uploader-js` | yes | yes @@ -177,6 +179,12 @@ This driver handles requests made by the DropzoneJS client library. This driver handles requests made by the Flow.js client library. +### Plupload driver + +[website](https://github.com/moxiecode/plupload) + +This driver handles requests made by the Plupload client library. + ### Resumable.js driver [website](http://resumablejs.com/) diff --git a/examples/plupload.blade.php b/examples/plupload.blade.php new file mode 100644 index 0000000..7d57de0 --- /dev/null +++ b/examples/plupload.blade.php @@ -0,0 +1,60 @@ + + + + + + + Plupload + + +

Plupload

+ +
+ [Select files] + [Upload files] +
+ + + + + + + diff --git a/src/Driver/PluploadUploadDriver.php b/src/Driver/PluploadUploadDriver.php new file mode 100644 index 0000000..38e377f --- /dev/null +++ b/src/Driver/PluploadUploadDriver.php @@ -0,0 +1,118 @@ +identifier = $identifier; + } + + + /** + * @inheritDoc + */ + public function handle(Request $request, StorageConfig $config, Closure $fileUploaded = null): Response + { + if ($this->isRequestMethodIn($request, [Request::METHOD_POST])) { + return $this->save($request, $config, $fileUploaded); + } + + throw new MethodNotAllowedHttpException([ + Request::METHOD_POST, + ]); + } + + /** + * @param \Illuminate\Http\Request $request + * @param \CodingSocks\ChunkUploader\StorageConfig $config + * @param \Closure|null $fileUploaded + * + * @return mixed + */ + private function save(Request $request, StorageConfig $config, ?Closure $fileUploaded) + { + $file = $request->file('file'); + + $this->validateUploadedFile($file); + + $this->validateChunkRequest($request); + + return $this->saveChunk($file, $request, $config, $fileUploaded); + } + + /** + * @param \Illuminate\Http\Request $request + */ + private function validateChunkRequest(Request $request): void + { + $request->validate([ + 'name' => 'required', + 'chunk' => 'required|integer', + 'chunks' => 'required|integer', + ]); + } + + /** + * @param \Illuminate\Http\UploadedFile $file + * @param \Illuminate\Http\Request $request + * @param \CodingSocks\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 PluploadRange($request); + } catch (InvalidArgumentException $e) { + throw new BadRequestHttpException($e->getMessage(), $e); + } + + $uuid = $this->identifier->generateFileIdentifier($range->getTotal(), $file->getClientOriginalName()); + + $chunks = $this->storeChunk($config, $range, $file, $uuid); + + if (!$range->isLast()) { + return new PercentageJsonResponse($range->getPercentage()); + } + + $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($range->getPercentage()); + } +} diff --git a/src/Range/PluploadRange.php b/src/Range/PluploadRange.php new file mode 100644 index 0000000..8f15597 --- /dev/null +++ b/src/Range/PluploadRange.php @@ -0,0 +1,101 @@ +request; + } + + $this->current = (float) $request->get(self::$CHUNK_NUMBER_PARAMETER_NAME); + $this->total = (float) $request->get(self::$TOTAL_CHUNK_NUMBER_PARAMETER_NAME); + + if ($this->current < 0) { + throw new InvalidArgumentException( + sprintf('`%s` must be greater than or equal to zero', self::$CHUNK_NUMBER_PARAMETER_NAME) + ); + } + if ($this->total < 1) { + throw new InvalidArgumentException( + sprintf('`%s` must be greater than zero', self::$TOTAL_CHUNK_NUMBER_PARAMETER_NAME) + ); + } + if ($this->current >= $this->total) { + throw new InvalidArgumentException( + sprintf('`%s` must be less than `%s`', self::$CHUNK_NUMBER_PARAMETER_NAME, self::$TOTAL_CHUNK_NUMBER_PARAMETER_NAME) + ); + } + } + + /** + * {@inheritDoc} + */ + public function getStart(): float + { + return $this->current; + } + + /** + * {@inheritDoc} + */ + public function getEnd(): float + { + return $this->current + 1; + } + + /** + * {@inheritDoc} + */ + public function getTotal(): float + { + return $this->total; + } + + /** + * {@inheritDoc} + */ + public function isFirst(): bool + { + return $this->current === 0.0; + } + + /** + * {@inheritDoc} + */ + public function isLast(): bool + { + return $this->current >= ($this->total - 1); + } + + /** + * @return float + */ + public function getPercentage(): float + { + return floor(($this->current + 1) / $this->total * 100); + } +} diff --git a/src/UploadManager.php b/src/UploadManager.php index b1ec436..84bdadf 100644 --- a/src/UploadManager.php +++ b/src/UploadManager.php @@ -6,6 +6,7 @@ use CodingSocks\ChunkUploader\Driver\DropzoneUploadDriver; use CodingSocks\ChunkUploader\Driver\FlowJsUploadDriver; use CodingSocks\ChunkUploader\Driver\MonolithUploadDriver; +use CodingSocks\ChunkUploader\Driver\PluploadUploadDriver; use CodingSocks\ChunkUploader\Driver\ResumableJsUploadDriver; use CodingSocks\ChunkUploader\Driver\SimpleUploaderJsUploadDriver; use Illuminate\Support\Manager; @@ -35,6 +36,14 @@ public function createFlowJsDriver() return new FlowJsUploadDriver($this->app['config']['chunk-uploader.resumable-js']); } + public function createPluploadDriver() + { + /** @var \Illuminate\Support\Manager $identityManager */ + $identityManager = $this->app['chunk-uploader.identity-manager']; + + return new PluploadUploadDriver($identityManager->driver()); + } + public function createResumableJsDriver() { return new ResumableJsUploadDriver($this->app['config']['chunk-uploader.resumable-js']); diff --git a/tests/Driver/PluploadUploadDriverTest.php b/tests/Driver/PluploadUploadDriverTest.php new file mode 100644 index 0000000..1539e5a --- /dev/null +++ b/tests/Driver/PluploadUploadDriverTest.php @@ -0,0 +1,222 @@ +app->make('config')->set('chunk-uploader.identifier', 'nop'); + $this->app->make('config')->set('chunk-uploader.uploader', 'plupload'); + $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(PluploadUploadDriver::class, $manager->driver()); + } + + public function notAllowedRequestMethods() + { + return [ + 'GET' => [Request::METHOD_GET], + '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 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 [ + 'name' => ['name'], + 'chunk' => ['chunk'], + 'chunks' => ['chunks'], + ]; + } + + /** + * @dataProvider excludedPostParameterProvider + */ + public function testPostParameterValidation($exclude) + { + $arr = [ + 'name' => '0jWZTB1ZDfRQU6VTcXy0mJnL9xKMeEz3HoSPU0Zf.txt', + 'chunk' => 1, + 'chunks' => 2, + ]; + + 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, [ + 'name' => 'test.txt', + 'chunk' => '0', + 'chunks' => '2', + ], [], [ + 'file' => $file, + ]); + + $response = $this->createTestResponse($this->handler->handle($request)); + $response->assertSuccessful(); + $response->assertJson(['done' => 50]); + + Storage::disk('local')->assertExists('chunks/2_test.txt/0-1'); + + 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, [ + 'name' => 'test.txt', + 'chunk' => '0', + 'chunks' => '2', + ], [], [ + '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/2_test.txt', '0-1'); + + $file = UploadedFile::fake()->create('test.txt', 100); + $request = Request::create('', Request::METHOD_POST, [ + 'name' => 'test.txt', + 'chunk' => '1', + 'chunks' => '2', + ], [], [ + 'file' => $file, + ]); + + $response = $this->createTestResponse($this->handler->handle($request)); + $response->assertSuccessful(); + $response->assertJson(['done' => 100]); + + Storage::disk('local')->assertExists('chunks/2_test.txt/1-2'); + 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/2_test.txt', '0-1'); + + $file = UploadedFile::fake()->create('test.txt', 100); + $request = Request::create('', Request::METHOD_POST, [ + 'name' => 'test.txt', + 'chunk' => '1', + 'chunks' => '2', + ], [], [ + '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/Range/PluploadRangeTest.php b/tests/Range/PluploadRangeTest.php new file mode 100644 index 0000000..9ea3973 --- /dev/null +++ b/tests/Range/PluploadRangeTest.php @@ -0,0 +1,109 @@ + [-1, 10, '`chunk` must be greater than or equal to zero'], + 'Number of chunks size smaller than zero' => [0, 0, '`chunks` must be greater than zero'], + 'Index smaller than zero' => [10, 10, '`chunk` must be less than `chunks`'], + ]; + } + + /** + * @dataProvider invalidArgumentProvider + * + * @param $chunk + * @param $chunks + * @param $expectedExceptionMessage + */ + public function testArgumentValidation($chunk, $chunks, $expectedExceptionMessage) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + $this->createRequestBodyRange($chunk, $chunks); + } + + public function testIsFirst() + { + $range = $this->createRequestBodyRange(0, 20); + $this->assertTrue($range->isFirst()); + } + + public function testIsLast() + { + $range = $this->createRequestBodyRange(19, 20); + $this->assertTrue($range->isLast()); + } + + public function testIsFirstAndIsLast() + { + $range = $this->createRequestBodyRange(0, 1); + $this->assertTrue($range->isFirst()); + $this->assertTrue($range->isLast()); + } + + public function testGetTotal() + { + $range = $this->createRequestBodyRange(4, 20); + $this->assertEquals(20, $range->getTotal()); + } + + public function testGetStart() + { + $range = $this->createRequestBodyRange(4, 20); + $this->assertEquals(4, $range->getStart()); + } + + public function testGetEnd() + { + $range = $this->createRequestBodyRange(4, 20); + $this->assertEquals(5, $range->getEnd()); + } + + public function testGetPercentage() + { + $range = $this->createRequestBodyRange(4, 20); + $this->assertEquals(25, $range->getPercentage()); + } + + public function testCreateFromRequest() + { + $request = new Request([], [ + 'chunk' => 4, + 'chunks' => 10, + ]); + + $range = new PluploadRange($request); + + $this->assertEquals(4, $range->getStart()); + $this->assertEquals(5, $range->getEnd()); + $this->assertEquals(10, $range->getTotal()); + } + + /** + * @param float $chunk + * @param float $chunks + * + * @return \CodingSocks\ChunkUploader\Range\PluploadRange + */ + private function createRequestBodyRange(float $chunk, float $chunks) + { + $request = new ParameterBag([ + 'chunk' => (string) $chunk, + 'chunks' => (string) $chunks, + ]); + + return new PluploadRange($request); + } +}