diff --git a/.gitignore b/.gitignore index c105493..7a1e76a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ vendor/* composer.lock -.idea \ No newline at end of file +.idea +.phpunit.cache/* +.phpunit.*.cache \ No newline at end of file diff --git a/composer.json b/composer.json index db97502..b67d234 100644 --- a/composer.json +++ b/composer.json @@ -8,12 +8,12 @@ } ], "require": { - "php": ">=5.3.0", + "php": ">=8.1.0", "cakephp/filesystem": "^3.0", - "monolog/monolog": "^1.17" + "monolog/monolog": "^2.0" }, "require-dev": { - "phpunit/phpunit": "~4.0" + "phpunit/phpunit": "~10.0" }, "license": "MIT", "autoload": { diff --git a/phpunit.xml b/phpunit.xml index 9447c87..851d307 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,18 +1,8 @@ - - - - ./test/ - - - \ No newline at end of file + + + + ./test/ + + + diff --git a/readme.md b/readme.md index d3e3462..ce08d66 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,5 @@ # PHP backend for resumable.js - ## Installation To install, use composer: @@ -21,11 +20,23 @@ use Dilab\Resumable; $request = new SimpleRequest(); $response = new SimpleResponse(); +// optional instanceId to seperate uploads from diffrent users like if two users want to upload untitled.jpg there would be no conflict anymore +$instanceId = session_id(); -$resumable = new Resumable($request, $response); +$resumable = new Resumable($request, $response, $instanceId); $resumable->tempFolder = 'tmps'; $resumable->uploadFolder = 'uploads'; -$resumable->process(); +$status = $resumable->process(); + +return match ($status){ + 200 => ['message' => 'OK'], // Uploading of chunk is complete. + 201 => [ + 'message' => 'File uploaded', + 'file' => $_REQUEST['resumableFilename'] + ],// Uploading of whole file is complete. + 204 => ['message' => 'Chunk not found'], + default => ['message' => 'An error occurred'] //status => 404 + }; ``` diff --git a/src/Network/SimpleResponse.php b/src/Network/SimpleResponse.php index 715d8a2..5d37b96 100644 --- a/src/Network/SimpleResponse.php +++ b/src/Network/SimpleResponse.php @@ -11,12 +11,14 @@ class SimpleResponse implements Response */ public function header($statusCode) { - if (200==$statusCode) { - return header($_SERVER["SERVER_PROTOCOL"]." 200 Ok"); - } else if (404==$statusCode) { - return header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found"); + if($statusCode >= 500) { + $statusCode = 204; } - return header($_SERVER["SERVER_PROTOCOL"]." 204 No Content"); + if (!in_array($statusCode, [200,201,204,404])) { + $statusCode = 404; + } + http_response_code($statusCode); + return $statusCode; } } diff --git a/src/Resumable.php b/src/Resumable.php index f2ae54c..a487237 100644 --- a/src/Resumable.php +++ b/src/Resumable.php @@ -24,6 +24,8 @@ class Resumable protected $response; + protected $instanceId; + protected $params; protected $chunkFile; @@ -45,15 +47,17 @@ class Resumable 'filename' => 'filename', 'chunkNumber' => 'chunkNumber', 'chunkSize' => 'chunkSize', - 'totalSize' => 'totalSize' + 'totalSize' => 'totalSize', + 'totalChunks' => 'totalChunks' ]; const WITHOUT_EXTENSION = true; - public function __construct(Request $request, Response $response) + public function __construct(Request $request, Response $response, string|null $instanceId = null) { $this->request = $request; $this->response = $response; + $this->instanceId = $instanceId; $this->log = new Logger('debug'); $this->log->pushHandler(new StreamHandler('debug.log', Logger::DEBUG)); @@ -81,9 +85,9 @@ public function process() { if (!empty($this->resumableParams())) { if (!empty($this->request->file())) { - $this->handleChunk(); + return $this->handleChunk(); } else { - $this->handleTestChunk(); + return $this->handleTestChunk(); } } } @@ -174,10 +178,17 @@ public function handleTestChunk() $identifier = $this->resumableParam($this->resumableOption['identifier']); $filename = $this->resumableParam($this->resumableOption['filename']); $chunkNumber = $this->resumableParam($this->resumableOption['chunkNumber']); + $chunkSize = $this->resumableParam($this->resumableOption['chunkSize']); + $totalChunks = $this->resumableParam($this->resumableOption['totalChunks']); if (!$this->isChunkUploaded($identifier, $filename, $chunkNumber)) { return $this->response->header(204); } else { + if ($this->isFileUploadComplete($filename, $identifier, $totalChunks)) { + $this->isUploadComplete = true; + $this->createFileAndDeleteTmp($identifier, $filename); + return $this->response->header(201); + } return $this->response->header(200); } @@ -190,16 +201,17 @@ public function handleChunk() $filename = $this->resumableParam($this->resumableOption['filename']); $chunkNumber = $this->resumableParam($this->resumableOption['chunkNumber']); $chunkSize = $this->resumableParam($this->resumableOption['chunkSize']); - $totalSize = $this->resumableParam($this->resumableOption['totalSize']); + $totalChunks = $this->resumableParam($this->resumableOption['totalChunks']); if (!$this->isChunkUploaded($identifier, $filename, $chunkNumber)) { $chunkFile = $this->tmpChunkDir($identifier) . DIRECTORY_SEPARATOR . $this->tmpChunkFilename($filename, $chunkNumber); $this->moveUploadedFile($file['tmp_name'], $chunkFile); } - if ($this->isFileUploadComplete($filename, $identifier, $chunkSize, $totalSize)) { + if ($this->isFileUploadComplete($filename, $identifier, $totalChunks)) { $this->isUploadComplete = true; $this->createFileAndDeleteTmp($identifier, $filename); + return $this->response->header(201); } return $this->response->header(200); @@ -221,7 +233,12 @@ private function createFileAndDeleteTmp($identifier, $filename) } // replace filename reference by the final file - $this->filepath = $this->uploadFolder . DIRECTORY_SEPARATOR . $finalFilename; + $this->filepath = $this->uploadFolder . DIRECTORY_SEPARATOR; + if (!empty($this->instanceId)) { + $this->filepath .= $this->instanceId . DIRECTORY_SEPARATOR; + } + $this->filepath .= $finalFilename; + $this->extension = $this->findExtension($this->filepath); if ($this->createFileFromChunks($chunkFiles, $this->filepath) && $this->deleteTmpFolder) { @@ -249,13 +266,9 @@ public function resumableParams() } } - public function isFileUploadComplete($filename, $identifier, $chunkSize, $totalSize) + public function isFileUploadComplete($filename, $identifier, $totalChunks) { - if ($chunkSize <= 0) { - return false; - } - $numOfChunks = intval($totalSize / $chunkSize) + ($totalSize % $chunkSize == 0 ? 0 : 1); - for ($i = 1; $i < $numOfChunks; $i++) { + for ($i = 1; $i <= $totalChunks; $i++) { if (!$this->isChunkUploaded($identifier, $filename, $i)) { return false; } @@ -271,13 +284,38 @@ public function isChunkUploaded($identifier, $filename, $chunkNumber) public function tmpChunkDir($identifier) { - $tmpChunkDir = $this->tempFolder . DIRECTORY_SEPARATOR . $identifier; - if (!file_exists($tmpChunkDir)) { - mkdir($tmpChunkDir); + $tmpChunkDir = $this->tempFolder. DIRECTORY_SEPARATOR; + if (!empty($this->instanceId)){ + $tmpChunkDir .= $this->instanceId . DIRECTORY_SEPARATOR; } + $tmpChunkDir .= $identifier; + $this->ensureDirExists($tmpChunkDir); return $tmpChunkDir; } + /** + * make directory if it doesn't exists (Immune against the race condition) + * + * + * since the resuamble is usually used with simultaneously uploads, + * this sometimes resulted in directory creation btween the *is_dir* check + * and *mkdir* then following race condition. + * in this setup it will shut down the mkdir error + * then try to check if directory is created after that + * + * @param string $path the directoryPath to ensure + * @return void + * @throws \Exception + */ + private function ensureDirExists($path) + { + umask(0); + if ( is_dir($path) || @mkdir($path, 0775, true) || is_dir($path)) { + return; + } + throw new \Exception("could not mkdir $path"); + } + public function tmpChunkFilename($filename, $chunkNumber) { return $filename . '.' . str_pad($chunkNumber, 4, 0, STR_PAD_LEFT); @@ -299,6 +337,10 @@ public function createFileFromChunks($chunkFiles, $destFile) natsort($chunkFiles); + if (!empty($this->instanceId)) { + $this->ensureDirExists(dirname($destFile)); + } + $handle = $this->getExclusiveFileHandle($destFile); if (!$handle) { return false; @@ -319,6 +361,9 @@ public function createFileFromChunks($chunkFiles, $destFile) public function moveUploadedFile($file, $destFile) { + //workaround cakephp error regarding: TMP not defined + define("TMP",sys_get_temp_dir()); + $file = new File($file); if ($file->exists()) { return $file->copy($destFile); diff --git a/test/src/Network/SimpleRequestTest.php b/test/src/Network/SimpleRequestTest.php index 9fa3301..e6d8843 100644 --- a/test/src/Network/SimpleRequestTest.php +++ b/test/src/Network/SimpleRequestTest.php @@ -8,14 +8,14 @@ * @package Dilab\Network * @property $request Request */ -class SimpleRequestTest extends \PHPUnit_Framework_TestCase +class SimpleRequestTest extends \PHPUnit\Framework\TestCase { - protected function setUp() + protected function setUp() : void { $this->request = new SimpleRequest(); } - public function tearDown() + public function tearDown() : void { unset($this->request); parent::tearDown(); diff --git a/test/src/Network/SimpleResponseTest.php b/test/src/Network/SimpleResponseTest.php index 8772214..d567c4d 100644 --- a/test/src/Network/SimpleResponseTest.php +++ b/test/src/Network/SimpleResponseTest.php @@ -8,28 +8,28 @@ * @package Dilab\Network * @property $response Response */ -class SimpleResponseTest extends \PHPUnit_Framework_TestCase +class SimpleResponseTest extends \PHPUnit\Framework\TestCase { - protected function setUp() + protected function setUp() : void { $this->response = new SimpleResponse(); } - public function tearDown() + public function tearDown() : void { unset($this->response); parent::tearDown(); } - public function headerProvider() + public static function headerProvider() { - return array( - array(404,404), - array(204,204), - array(200,200), - array(500,204), - ); + return [ + [404,404], + [204,204], + [200,200], + [500,204], + ]; } /** diff --git a/test/src/ResumableTest.php b/test/src/ResumableTest.php index 635dedc..4afe706 100644 --- a/test/src/ResumableTest.php +++ b/test/src/ResumableTest.php @@ -12,13 +12,13 @@ * @property $request Request * @property $response Response */ -class ResumbableTest extends \PHPUnit_Framework_TestCase +class ResumableTest extends \PHPUnit\Framework\TestCase { public $resumbable; protected $provider; - protected function setUp() + protected function setUp() : void { $this->request = $this->getMockBuilder('Dilab\Network\SimpleRequest') ->getMock(); @@ -27,7 +27,7 @@ protected function setUp() ->getMock(); } - public function tearDown() + public function tearDown() : void { unset($this->request); unset($this->response); @@ -45,21 +45,21 @@ public function testProcessHandleChunk() 'resumableRelativePath'=> 'upload', ); - $this->request->method('is')->will($this->returnValue(true)); + $this->request->method('is')->willReturn(true); $this->request->method('file') - ->will($this->returnValue(array( + ->willReturn([ 'name'=> 'mock.png', - 'tmp_name'=> 'test/files/mock.png.003', + 'tmp_name'=> 'test/files/mock.png.0003', 'error'=> 0, 'size'=> 27000, - ))); + ]); $this->request->method('data')->willReturn($resumableParams); $this->resumbable = $this->getMockBuilder('Dilab\Resumable') ->setConstructorArgs(array($this->request,$this->response)) - ->setMethods(array('handleChunk')) + ->onlyMethods(array('handleChunk')) ->getMock(); $this->resumbable->expects($this->once()) @@ -80,15 +80,15 @@ public function testProcessHandleTestChunk() 'resumableRelativePath'=> 'upload', ); - $this->request->method('is')->will($this->returnValue(true)); + $this->request->method('is')->willReturn(true); - $this->request->method('file')->will($this->returnValue(array())); + $this->request->method('file')->willReturn([]); $this->request->method('data')->willReturn($resumableParams); $this->resumbable = $this->getMockBuilder('Dilab\Resumable') ->setConstructorArgs(array($this->request,$this->response)) - ->setMethods(array('handleTestChunk')) + ->onlyMethods(array('handleTestChunk')) ->getMock(); $this->resumbable->expects($this->once()) @@ -101,12 +101,12 @@ public function testProcessHandleTestChunk() public function testHandleTestChunk() { $this->request->method('is') - ->will($this->returnValue(true)); + ->willReturn(true); $this->request->method('data') ->willReturn(array( 'resumableChunkNumber'=> 1, - 'resumableTotalChunks'=> 600, + 'resumableTotalSize'=> 600, 'resumableChunkSize'=> 200, 'resumableIdentifier'=> 'identifier', 'resumableFilename'=> 'mock.png', @@ -125,7 +125,8 @@ public function testHandleTestChunk() public function testHandleChunk() { $resumableParams = array( 'resumableChunkNumber'=> 3, - 'resumableTotalChunks'=> 600, + // 'resumableTotalChunks'=> 600, + 'resumableTotalSize'=> 600, 'resumableChunkSize'=> 200, 'resumableIdentifier'=> 'identifier', 'resumableFilename'=> 'mock.png', @@ -134,7 +135,7 @@ public function testHandleChunk() { $this->request->method('is') - ->will($this->returnValue(true)); + ->willReturn(true); $this->request->method('data') ->willReturn($resumableParams); @@ -142,7 +143,7 @@ public function testHandleChunk() { $this->request->method('file') ->willReturn(array( 'name'=> 'mock.png', - 'tmp_name'=> 'test/files/mock.png.003', + 'tmp_name'=> 'test/files/mock.png.0003', 'error'=> 0, 'size'=> 27000, )); @@ -154,7 +155,7 @@ public function testHandleChunk() { $this->resumbable->handleChunk(); $this->assertFileExists('test/uploads/mock.png'); - file_exists('test/tmp/identifier/mock.png.003') && unlink('test/tmp/identifier/mock.png.003'); + file_exists('test/tmp/identifier/mock.png.0003') && unlink('test/tmp/identifier/mock.png.0003'); unlink('test/uploads/mock.png'); } @@ -173,7 +174,7 @@ public function testResumableParamsGetRequest() ->getMock(); $this->request->method('is') - ->will($this->returnValue(true)); + ->willReturn(true); $this->request->method('data')->willReturn($resumableParams); @@ -181,7 +182,7 @@ public function testResumableParamsGetRequest() $this->assertEquals($resumableParams, $this->resumbable->resumableParams()); } - public function isFileUploadCompleteProvider() + public static function isFileUploadCompleteProvider() { return array( array('mock.png', 'files', 20, 60, true),