├── phpunit ├── test ├── bootstrap.php └── Unit │ ├── FlowUnitCase.php │ ├── UploaderTest.php │ ├── RequestTest.php │ ├── ConfigTest.php │ ├── FustyRequestTest.php │ └── FileTest.php ├── src └── Flow │ ├── FileLockException.php │ ├── FileOpenException.php │ ├── Mongo │ ├── MongoConfig.php │ ├── MongoUploader.php │ ├── README.md │ └── MongoFile.php │ ├── ConfigInterface.php │ ├── Uploader.php │ ├── FustyRequest.php │ ├── RequestInterface.php │ ├── Basic.php │ ├── Config.php │ ├── Request.php │ └── File.php ├── phpstan.neon.dist ├── .gitignore ├── CHANGELOG.md ├── phpunit.xml.dist ├── composer.json ├── LICENSE ├── .github └── workflows │ └── tests.yml ├── travis.phpunit.xml ├── README.md └── .php-cs-fixer.dist.php /phpunit: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | php ./vendor/phpunit/phpunit/phpunit "$@" 4 | 5 | -------------------------------------------------------------------------------- /test/bootstrap.php: -------------------------------------------------------------------------------- 1 | =5.4 5 | * if chunk was not found, 204 status is returned instead of 404 -------------------------------------------------------------------------------- /src/Flow/Mongo/MongoConfig.php: -------------------------------------------------------------------------------- 1 | gridFS; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | ./test/Unit/ 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/Flow/Mongo/MongoUploader.php: -------------------------------------------------------------------------------- 1 | find([ 20 | 'flowUpdated' => ['$lt' => new \MongoDB\BSON\UTCDateTime(time() - $expirationTime)], 21 | 'flowStatus' => 'uploading' 22 | ]); 23 | foreach ($result as $file) { 24 | $gridFs->delete($file['_id']); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Flow/ConfigInterface.php: -------------------------------------------------------------------------------- 1 | =8.0" 23 | }, 24 | "require-dev": { 25 | "mikey179/vfsstream": "^1.2", 26 | "friendsofphp/php-cs-fixer": "^3.65", 27 | "phpunit/phpunit": ">=9.0", 28 | "mongodb/mongodb": "^1.4.0", 29 | "ext-mongodb": "*", 30 | "phpstan/phpstan": "^2.0" 31 | }, 32 | "suggest": { 33 | "mongodb/mongodb":"Required to use this package with Mongo DB" 34 | }, 35 | "autoload": { 36 | "psr-0": { 37 | "Flow": "src" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-0": { 42 | "Unit": "test" 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Flow/Uploader.php: -------------------------------------------------------------------------------- 1 | $expirationTime) { 34 | unlink($path); 35 | } 36 | } 37 | 38 | closedir($handle); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Aidas Klimas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/Flow/FustyRequest.php: -------------------------------------------------------------------------------- 1 | isFusty = null === $this->getTotalSize() && $this->getFileName() && $this->getFile(); 21 | 22 | if ($this->isFusty) { 23 | $this->params['flowTotalSize'] = $this->file['size'] ?? 0; 24 | $this->params['flowTotalChunks'] = 1; 25 | $this->params['flowChunkNumber'] = 1; 26 | $this->params['flowChunkSize'] = $this->params['flowTotalSize']; 27 | $this->params['flowCurrentChunkSize'] = $this->params['flowTotalSize']; 28 | } 29 | } 30 | 31 | /** 32 | * Checks if request is formed by fusty flow 33 | */ 34 | public function isFustyFlowRequest(): bool 35 | { 36 | return $this->isFusty; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | php-tests: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: true 10 | matrix: 11 | php: [8.3, 8.2, 8.1, 8.0] 12 | dependency-version: [prefer-stable] 13 | os: [ubuntu-latest] 14 | 15 | name: ${{ matrix.os }} - PHP${{ matrix.php }} - ${{ matrix.dependency-version }} 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Cache dependencies 22 | uses: actions/cache@v4 23 | with: 24 | path: ~/.composer/cache/files 25 | key: dependencies-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} 26 | 27 | - name: Setup PHP 28 | uses: shivammathur/setup-php@v2 29 | with: 30 | php-version: ${{ matrix.php }} 31 | extensions: dom, curl, libxml, mbstring, zip, intl, mongodb 32 | coverage: none 33 | 34 | - name: Install dependencies 35 | run: | 36 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction 37 | 38 | - name: Execute tests 39 | run: vendor/bin/phpunit 40 | 41 | -------------------------------------------------------------------------------- /test/Unit/FlowUnitCase.php: -------------------------------------------------------------------------------- 1 | requestArr = [ 20 | 'flowChunkNumber' => 1, 21 | 'flowChunkSize' => 1048576, 22 | 'flowCurrentChunkSize' => 10, 23 | 'flowTotalSize' => 100, 24 | 'flowIdentifier' => '13632-prettifyjs', 25 | 'flowFilename' => 'prettify.js', 26 | 'flowRelativePath' => 'home/prettify.js', 27 | 'flowTotalChunks' => 42 28 | ]; 29 | 30 | $this->filesArr = [ 31 | 'file' => [ 32 | 'name' => 'someFile.gif', 33 | 'type' => 'image/gif', 34 | 'size' => '10', 35 | 'tmp_name' => '/tmp/abc1234', 36 | 'error' => UPLOAD_ERR_OK 37 | ] 38 | ]; 39 | } 40 | 41 | protected function tearDown(): void 42 | { 43 | $_REQUEST = []; 44 | $_FILES = []; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Flow/RequestInterface.php: -------------------------------------------------------------------------------- 1 | $config, 23 | ]); 24 | } 25 | 26 | $file = new File($config, $request); 27 | 28 | if ('GET' === $_SERVER['REQUEST_METHOD']) { 29 | if ($file->checkChunk()) { 30 | header('HTTP/1.1 200 Ok'); 31 | } else { 32 | // The 204 response MUST NOT include a message-body, and thus is always terminated by the first empty line after the header fields. 33 | header('HTTP/1.1 204 No Content'); 34 | 35 | return false; 36 | } 37 | } else { 38 | if ($file->validateChunk()) { 39 | $file->saveChunk(); 40 | } else { 41 | // error, invalid chunk upload request, retry 42 | header('HTTP/1.1 400 Bad Request'); 43 | 44 | return false; 45 | } 46 | } 47 | 48 | return (bool) ($file->validateFile() && $file->save($destination)); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Flow/Mongo/README.md: -------------------------------------------------------------------------------- 1 | Usage 2 | -------------- 3 | 4 | * Must use 'forceChunkSize=true' on client side. 5 | * Chunk preprocessor not supported. 6 | * One should ensure indices on the gridfs files collection on the property 'flowIdentifier'. 7 | 8 | Besides the points above, the usage is analogous to the 'normal' flow-php: 9 | 10 | ```php 11 | $config = new \Flow\Mongo\MongoConfig($yourGridFs); 12 | $file = new \Flow\Mongo\MongoFile($config); 13 | 14 | if ($_SERVER['REQUEST_METHOD'] === 'GET') { 15 | if ($file->checkChunk()) { 16 | header("HTTP/1.1 200 Ok"); 17 | } else { 18 | header("HTTP/1.1 204 No Content"); 19 | return ; 20 | } 21 | } else { 22 | if ($file->validateChunk()) { 23 | $file->saveChunk(); 24 | } else { 25 | // error, invalid chunk upload request, retry 26 | header("HTTP/1.1 400 Bad Request"); 27 | return ; 28 | } 29 | } 30 | if ($file->validateFile()) { 31 | // File upload was completed 32 | $id = $file->saveToGridFs(['your metadata'=>'value']); 33 | if($id) { 34 | //do custom post processing here, $id is the MongoId of the gridfs file 35 | } 36 | } else { 37 | // This is not a final chunk, continue to upload 38 | } 39 | ``` 40 | 41 | Delete unfinished files 42 | ----------------------- 43 | 44 | For this you should set up cron, which would check each chunk upload time. 45 | If chunk is uploaded long time ago, then chunk should be deleted. 46 | 47 | Helper method for checking this: 48 | ```php 49 | \Flow\Mongo\MongoUploader::pruneChunks($yourGridFs); 50 | ``` 51 | -------------------------------------------------------------------------------- /test/Unit/UploaderTest.php: -------------------------------------------------------------------------------- 1 | vfs = new vfsStreamDirectory('chunks'); 31 | vfsStreamWrapper::setRoot($this->vfs); 32 | } 33 | 34 | /** 35 | * @covers ::pruneChunks 36 | */ 37 | public function testUploader_pruneChunks() 38 | { 39 | //// Setup test 40 | 41 | $newDir = vfsStream::newDirectory('1'); 42 | $newDir->lastModified(time() - 31); 43 | $newDir->lastModified(time()); 44 | 45 | $fileFirst = vfsStream::newFile('file31'); 46 | $fileFirst->lastModified(time() - 31); 47 | $fileSecond = vfsStream::newFile('random_file'); 48 | $fileSecond->lastModified(time() - 30); 49 | $upDir = vfsStream::newFile('..'); 50 | 51 | $this->vfs->addChild($newDir); 52 | $this->vfs->addChild($fileFirst); 53 | $this->vfs->addChild($fileSecond); 54 | $this->vfs->addChild($upDir); 55 | 56 | //// Actual test 57 | 58 | Uploader::pruneChunks($this->vfs->url(), 30); 59 | $this->assertTrue(file_exists($newDir->url())); 60 | $this->assertFalse(file_exists($fileFirst->url())); 61 | $this->assertTrue(file_exists($fileSecond->url())); 62 | } 63 | 64 | /** 65 | * @covers ::pruneChunks 66 | */ 67 | public function testUploader_exception() 68 | { 69 | try { 70 | @Uploader::pruneChunks('not/existing/dir', 30); 71 | $this->fail(); 72 | } catch (FileOpenException $e) { 73 | $this->assertSame('failed to open folder: not/existing/dir', $e->getMessage()); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /travis.phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | ./test/Unit/ 17 | 18 | 19 | 20 | 21 | 22 | ./src/Flow/ 23 | 24 | ./src/Flow/Basic.php 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | Flow 46 | 47 | 48 | TtscpSyNYUnuG2LkxtWCQmAtBk8vWAMsI 49 | 50 | 51 | https://coveralls.io/api/v1/jobs 52 | 53 | 54 | build 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /src/Flow/Config.php: -------------------------------------------------------------------------------- 1 | config['tempDir'] = $path; 19 | 20 | return $this; 21 | } 22 | 23 | /** 24 | * Get path to temporary directory for chunks storage 25 | */ 26 | public function getTempDir(): string 27 | { 28 | return $this->config['tempDir'] ?? ''; 29 | } 30 | 31 | /** 32 | * Set chunk identifier 33 | */ 34 | public function setHashNameCallback(callable | array $callback): static 35 | { 36 | $this->config['hashNameCallback'] = $callback; 37 | 38 | return $this; 39 | } 40 | 41 | /** 42 | * Generate chunk identifier 43 | */ 44 | public function getHashNameCallback(): callable | array 45 | { 46 | return $this->config['hashNameCallback'] ?? ['\Flow\Config', 'hashNameCallback']; 47 | } 48 | 49 | /** 50 | * Callback to pre-process chunk 51 | */ 52 | public function setPreprocessCallback(callable | array $callback): static 53 | { 54 | $this->config['preprocessCallback'] = $callback; 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * Callback to pre-process chunk 61 | */ 62 | public function getPreprocessCallback(): callable | array | null 63 | { 64 | return $this->config['preprocessCallback'] ?? null; 65 | } 66 | 67 | /** 68 | * Delete chunks on save 69 | */ 70 | public function setDeleteChunksOnSave(bool $delete): static 71 | { 72 | $this->config['deleteChunksOnSave'] = $delete; 73 | 74 | return $this; 75 | } 76 | 77 | /** 78 | * Delete chunks on save 79 | */ 80 | public function getDeleteChunksOnSave(): bool 81 | { 82 | return $this->config['deleteChunksOnSave'] ?? true; 83 | } 84 | 85 | /** 86 | * Generate chunk identifier 87 | */ 88 | public static function hashNameCallback(RequestInterface $request): string 89 | { 90 | return sha1($request->getIdentifier()); 91 | } 92 | 93 | /** 94 | * Only defined for MongoConfig 95 | */ 96 | public function getGridFs(): ?Bucket 97 | { 98 | return null; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Flow/Request.php: -------------------------------------------------------------------------------- 1 | params = $_REQUEST; 14 | } 15 | 16 | if (null === $file && isset($_FILES['file'])) { 17 | $this->file = $_FILES['file']; 18 | } 19 | } 20 | 21 | /** 22 | * Get parameter value 23 | * 24 | * 25 | * @return string|int|null 26 | */ 27 | public function getParam(string $name) 28 | { 29 | return $this->params[$name] ?? null; 30 | } 31 | 32 | /** 33 | * Get uploaded file name 34 | * 35 | */ 36 | public function getFileName(): ?string 37 | { 38 | return $this->getParam('flowFilename'); 39 | } 40 | 41 | /** 42 | * Get total file size in bytes 43 | */ 44 | public function getTotalSize(): ?int 45 | { 46 | return $this->getParam('flowTotalSize'); 47 | } 48 | 49 | /** 50 | * Get file unique identifier 51 | */ 52 | public function getIdentifier(): ?string 53 | { 54 | return $this->getParam('flowIdentifier'); 55 | } 56 | 57 | /** 58 | * Get file relative path 59 | */ 60 | public function getRelativePath(): ?string 61 | { 62 | return $this->getParam('flowRelativePath'); 63 | } 64 | 65 | /** 66 | * Get total chunks number 67 | */ 68 | public function getTotalChunks(): ?int 69 | { 70 | return $this->getParam('flowTotalChunks'); 71 | } 72 | 73 | /** 74 | * Get default chunk size 75 | */ 76 | public function getDefaultChunkSize(): ?int 77 | { 78 | return $this->getParam('flowChunkSize'); 79 | } 80 | 81 | /** 82 | * Get current uploaded chunk number, starts with 1 83 | */ 84 | public function getCurrentChunkNumber(): ?int 85 | { 86 | return $this->getParam('flowChunkNumber'); 87 | } 88 | 89 | /** 90 | * Get current uploaded chunk size 91 | */ 92 | public function getCurrentChunkSize(): ?int 93 | { 94 | return $this->getParam('flowCurrentChunkSize'); 95 | } 96 | 97 | /** 98 | * Return $_FILES request 99 | */ 100 | public function getFile(): ?array 101 | { 102 | return $this->file; 103 | } 104 | 105 | /** 106 | * Checks if request is formed by fusty flow 107 | */ 108 | public function isFustyFlowRequest(): bool 109 | { 110 | return false; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /test/Unit/RequestTest.php: -------------------------------------------------------------------------------- 1 | requestArr; 22 | 23 | $request = new Request(); 24 | $this->assertSame('prettify.js', $request->getFileName()); 25 | $this->assertSame(100, $request->getTotalSize()); 26 | $this->assertSame('13632-prettifyjs', $request->getIdentifier()); 27 | $this->assertSame('home/prettify.js', $request->getRelativePath()); 28 | $this->assertSame(42, $request->getTotalChunks()); 29 | $this->assertSame(1048576, $request->getDefaultChunkSize()); 30 | $this->assertSame(1, $request->getCurrentChunkNumber()); 31 | $this->assertSame(10, $request->getCurrentChunkSize()); 32 | $this->assertSame(null, $request->getFile()); 33 | $this->assertFalse($request->isFustyFlowRequest()); 34 | } 35 | 36 | /** 37 | * @covers ::__construct 38 | * @covers ::getParam 39 | * @covers ::getFileName 40 | * @covers ::getTotalSize 41 | * @covers ::getIdentifier 42 | * @covers ::getRelativePath 43 | * @covers ::getTotalChunks 44 | * @covers ::getDefaultChunkSize 45 | * @covers ::getCurrentChunkNumber 46 | * @covers ::getCurrentChunkSize 47 | * @covers ::getFile 48 | * @covers ::isFustyFlowRequest 49 | */ 50 | public function testRequest_construct_withCustomRequest() 51 | { 52 | $request = new Request($this->requestArr); 53 | 54 | $this->assertSame('prettify.js', $request->getFileName()); 55 | $this->assertSame(100, $request->getTotalSize()); 56 | $this->assertSame('13632-prettifyjs', $request->getIdentifier()); 57 | $this->assertSame('home/prettify.js', $request->getRelativePath()); 58 | $this->assertSame(42, $request->getTotalChunks()); 59 | $this->assertSame(1048576, $request->getDefaultChunkSize()); 60 | $this->assertSame(1, $request->getCurrentChunkNumber()); 61 | $this->assertSame(10, $request->getCurrentChunkSize()); 62 | $this->assertSame(null, $request->getFile()); 63 | $this->assertFalse($request->isFustyFlowRequest()); 64 | } 65 | 66 | /** 67 | * @covers ::__construct 68 | */ 69 | public function testRequest_construct_withFILES() 70 | { 71 | $_FILES = $this->filesArr; 72 | 73 | $request = new Request(); 74 | $this->assertSame($this->filesArr['file'], $request->getFile()); 75 | } 76 | 77 | /** 78 | * @covers ::__construct 79 | */ 80 | public function testRequest_construct_withCustFiles() 81 | { 82 | $request = new Request(null, $this->filesArr['file']); 83 | $this->assertSame($this->filesArr['file'], $request->getFile()); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /test/Unit/ConfigTest.php: -------------------------------------------------------------------------------- 1 | '/some/dir', 28 | 'deleteChunksOnSave' => true, 29 | 'hashNameCallback' => ['\SomeNs\SomeClass', 'someMethod'], 30 | 'preprocessCallback' => ['\SomeNs\SomeClass', 'preProcess'], 31 | ]; 32 | 33 | $config = new Config($exampleConfig); 34 | 35 | $this->assertSame($exampleConfig['tempDir'], $config->getTempDir()); 36 | $this->assertSame($exampleConfig['deleteChunksOnSave'], $config->getDeleteChunksOnSave()); 37 | $this->assertSame($exampleConfig['hashNameCallback'], $config->getHashNameCallback()); 38 | $this->assertSame($exampleConfig['preprocessCallback'], $config->getPreprocessCallback()); 39 | } 40 | 41 | /** 42 | * @covers ::getTempDir 43 | * @covers ::getDeleteChunksOnSave 44 | * @covers ::getHashNameCallback 45 | * @covers ::getPreprocessCallback 46 | * @covers ::__construct 47 | */ 48 | public function testConfig_construct_default() 49 | { 50 | $config = new Config(); 51 | 52 | $this->assertSame('', $config->getTempDir()); 53 | $this->assertSame(true, $config->getDeleteChunksOnSave()); 54 | $this->assertSame(['\Flow\Config', 'hashNameCallback'], $config->getHashNameCallback()); 55 | $this->assertSame(null, $config->getPreprocessCallback()); 56 | } 57 | 58 | /** 59 | * @covers ::setTempDir 60 | * @covers ::getTempDir 61 | */ 62 | public function testConfig_setTempDir() 63 | { 64 | $dir = '/some/dir'; 65 | $config = new Config(); 66 | 67 | $config->setTempDir($dir); 68 | $this->assertSame($dir, $config->getTempDir()); 69 | } 70 | 71 | /** 72 | * @covers ::setHashNameCallback 73 | * @covers ::getHashNameCallback 74 | */ 75 | public function testConfig_setHashNameCallback() 76 | { 77 | $callback = ['\SomeNs\SomeClass', 'someMethod']; 78 | $config = new Config(); 79 | 80 | $config->setHashNameCallback($callback); 81 | $this->assertSame($callback, $config->getHashNameCallback()); 82 | } 83 | 84 | /** 85 | * @covers ::setPreprocessCallback 86 | * @covers ::getPreprocessCallback 87 | */ 88 | public function testConfig_setPreprocessCallback() 89 | { 90 | $callback = ['\SomeNs\SomeClass', 'someOtherMethod']; 91 | $config = new Config(); 92 | 93 | $config->setPreprocessCallback($callback); 94 | $this->assertSame($callback, $config->getPreprocessCallback()); 95 | } 96 | 97 | /** 98 | * @covers ::setDeleteChunksOnSave 99 | * @covers ::getDeleteChunksOnSave 100 | */ 101 | public function testConfig_setDeleteChunksOnSave() 102 | { 103 | $config = new Config(); 104 | 105 | $config->setDeleteChunksOnSave(false); 106 | $this->assertFalse($config->getDeleteChunksOnSave()); 107 | } 108 | 109 | public function testConfig_hashNameCallback() 110 | { 111 | $request = new Request($this->requestArr); 112 | 113 | $expHash = sha1($request->getIdentifier()); 114 | $this->assertSame($expHash, Config::hashNameCallback($request)); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | flow.js php server [![Build Status](https://travis-ci.org/flowjs/flow-php-server.png?branch=master)](https://travis-ci.org/flowjs/flow-php-server) [![Coverage Status](https://coveralls.io/repos/flowjs/flow-php-server/badge.png?branch=master)](https://coveralls.io/r/flowjs/flow-php-server?branch=master) 2 | ======================= 3 | 4 | PHP library for handling chunk uploads. Library contains helper methods for: 5 | * Testing if uploaded file chunk exists. 6 | * Validating file chunk 7 | * Creating separate chunks folder 8 | * Validating uploaded chunks 9 | * Merging all chunks to a single file 10 | 11 | This library is compatible with HTML5 file upload library: https://github.com/flowjs/flow.js 12 | 13 | How to get started? 14 | -------------- 15 | Setup Composer: https://getcomposer.org/doc/00-intro.md 16 | 17 | Run this command in your project: 18 | ``` 19 | composer require flowjs/flow-php-server 20 | ``` 21 | This will create a vendor directory for you, which contains an autoload.php file. 22 | 23 | Create a new php file named `upload.php`: 24 | ```php 25 | //Path to autoload.php from current location 26 | require_once './vendor/autoload.php'; 27 | 28 | $config = new \Flow\Config(); 29 | $config->setTempDir('./chunks_temp_folder'); 30 | $request = new \Flow\Request(); 31 | $uploadFolder = './final_file_destination/'; // Folder where the file will be stored 32 | $uploadFileName = uniqid()."_".$request->getFileName(); // The name the file will have on the server 33 | $uploadPath = $uploadFolder.$uploadFileName; 34 | if (\Flow\Basic::save($uploadPath, $config, $request)) { 35 | // file saved successfully and can be accessed at $uploadPath 36 | } else { 37 | // This is not a final chunk or request is invalid, continue to upload. 38 | } 39 | ``` 40 | 41 | Make sure that `./chunks_temp_folder` path exists and is writable. All chunks will be saved in this folder. 42 | 43 | If you are stuck with this example, please read this issue: [How to use the flow-php-server](https://github.com/flowjs/flow-php-server/issues/3#issuecomment-46979467) 44 | 45 | Advanced Usage 46 | -------------- 47 | 48 | ```php 49 | $config = new \Flow\Config(); 50 | $config->setTempDir('./chunks_temp_folder'); 51 | $file = new \Flow\File($config); 52 | 53 | if ($_SERVER['REQUEST_METHOD'] === 'GET') { 54 | if ($file->checkChunk()) { 55 | header("HTTP/1.1 200 Ok"); 56 | } else { 57 | header("HTTP/1.1 204 No Content"); 58 | return ; 59 | } 60 | } else { 61 | if ($file->validateChunk()) { 62 | $file->saveChunk(); 63 | } else { 64 | // error, invalid chunk upload request, retry 65 | header("HTTP/1.1 400 Bad Request"); 66 | return ; 67 | } 68 | } 69 | if ($file->validateFile() && $file->save('./final_file_name')) { 70 | // File upload was completed 71 | } else { 72 | // This is not a final chunk, continue to upload 73 | } 74 | ``` 75 | 76 | Delete unfinished files 77 | ----------------------- 78 | 79 | For this you should setup cron, which would check each chunk upload time. 80 | If chunk is uploaded long time ago, then chunk should be deleted. 81 | 82 | Helper method for checking this: 83 | ```php 84 | \Flow\Uploader::pruneChunks('./chunks_folder'); 85 | ``` 86 | 87 | Cron task can be avoided by using random function execution. 88 | ```php 89 | if (1 == mt_rand(1, 100)) { 90 | \Flow\Uploader::pruneChunks('./chunks_folder'); 91 | } 92 | ``` 93 | 94 | Contribution 95 | ------------ 96 | 97 | Your participation in development is very welcome! 98 | 99 | To ensure consistency throughout the source code, keep these rules in mind as you are working: 100 | * All features or bug fixes must be tested by one or more specs. 101 | * Your code should follow [PSR-2](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md) coding style guide 102 | -------------------------------------------------------------------------------- /test/Unit/FustyRequestTest.php: -------------------------------------------------------------------------------- 1 | vfs = new vfsStreamDirectory('chunks'); 34 | vfsStreamWrapper::setRoot($this->vfs); 35 | } 36 | 37 | /** 38 | * @covers ::__construct 39 | * @covers ::isFustyFlowRequest 40 | */ 41 | public function testFustyRequest_construct() 42 | { 43 | $firstChunk = vfsStream::newFile('temp_file'); 44 | $firstChunk->setContent('1234567890'); 45 | $this->vfs->addChild($firstChunk); 46 | 47 | $fileInfo = [ 48 | 'size' => 10, 49 | 'error' => UPLOAD_ERR_OK, 50 | 'tmp_name' => $firstChunk->url() 51 | ]; 52 | 53 | $request = [ 54 | 'flowIdentifier' => '13632-prettifyjs', 55 | 'flowFilename' => 'prettify.js', 56 | 'flowRelativePath' => 'home/prettify.js' 57 | ]; 58 | 59 | $fustyRequest = new FustyRequest($request, $fileInfo); 60 | 61 | $this->assertSame('prettify.js', $fustyRequest->getFileName()); 62 | $this->assertSame('13632-prettifyjs', $fustyRequest->getIdentifier()); 63 | $this->assertSame('home/prettify.js', $fustyRequest->getRelativePath()); 64 | $this->assertSame(1, $fustyRequest->getCurrentChunkNumber()); 65 | $this->assertTrue($fustyRequest->isFustyFlowRequest()); 66 | $this->assertSame(10, $fustyRequest->getTotalSize()); 67 | $this->assertSame(10, $fustyRequest->getDefaultChunkSize()); 68 | $this->assertSame(10, $fustyRequest->getCurrentChunkSize()); 69 | $this->assertSame(1, $fustyRequest->getTotalChunks()); 70 | } 71 | 72 | public function testFustyRequest_ValidateUpload() 73 | { 74 | $firstChunk = vfsStream::newFile('temp_file'); 75 | $firstChunk->setContent('1234567890'); 76 | $this->vfs->addChild($firstChunk); 77 | 78 | $fileInfo = [ 79 | 'size' => 10, 80 | 'error' => UPLOAD_ERR_OK, 81 | 'tmp_name' => $firstChunk->url() 82 | ]; 83 | 84 | $request = [ 85 | 'flowIdentifier' => '13632-prettifyjs', 86 | 'flowFilename' => 'prettify.js', 87 | 'flowRelativePath' => 'home/prettify.js' 88 | ]; 89 | 90 | $fustyRequest = new FustyRequest($request, $fileInfo); 91 | 92 | $config = new Config(); 93 | $config->setTempDir($this->vfs->url()); 94 | 95 | $file = $this->createPartialMock(File::class, ['_move_uploaded_file']); 96 | $file->__construct($config, $fustyRequest); 97 | 98 | /** @noinspection PhpUndefinedMethodInspection */ 99 | $file->expects($this->once()) 100 | ->method('_move_uploaded_file') 101 | ->willReturnCallback(static function ($filename, $destination) { 102 | return rename($filename, $destination); 103 | }); 104 | 105 | //// Actual test 106 | 107 | $this->assertTrue($file->validateChunk()); 108 | $this->assertFalse($file->validateFile()); 109 | 110 | $this->assertTrue($file->saveChunk()); 111 | $this->assertTrue($file->validateFile()); 112 | $path = $this->vfs->url().DIRECTORY_SEPARATOR.'new'; 113 | $this->assertTrue($file->save($path)); 114 | $this->assertEquals(10, filesize($path)); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Flow/File.php: -------------------------------------------------------------------------------- 1 | request = new Request(); 16 | } 17 | 18 | $this->identifier = \call_user_func($this->config->getHashNameCallback(), $this->request); 19 | } 20 | 21 | /** 22 | * Get file identifier 23 | */ 24 | public function getIdentifier(): string 25 | { 26 | return $this->identifier; 27 | } 28 | 29 | /** 30 | * Return chunk path 31 | */ 32 | public function getChunkPath(int $index): string 33 | { 34 | return $this->config->getTempDir().DIRECTORY_SEPARATOR.basename($this->identifier).'_'.(int) $index; 35 | } 36 | 37 | /** 38 | * Check if chunk exist 39 | */ 40 | public function checkChunk(): bool 41 | { 42 | return file_exists($this->getChunkPath($this->request->getCurrentChunkNumber())); 43 | } 44 | 45 | /** 46 | * Validate file request 47 | */ 48 | public function validateChunk(): bool 49 | { 50 | $file = $this->request->getFile(); 51 | 52 | if (! $file) { 53 | return false; 54 | } 55 | 56 | if (! isset($file['tmp_name']) || ! isset($file['size']) || ! isset($file['error'])) { 57 | return false; 58 | } 59 | 60 | if ($this->request->getCurrentChunkSize() != $file['size']) { 61 | return false; 62 | } 63 | 64 | return ! (UPLOAD_ERR_OK !== $file['error']); 65 | } 66 | 67 | /** 68 | * Save chunk 69 | */ 70 | public function saveChunk(): bool 71 | { 72 | $file = $this->request->getFile(); 73 | 74 | return $this->_move_uploaded_file($file['tmp_name'], $this->getChunkPath($this->request->getCurrentChunkNumber())); 75 | } 76 | 77 | /** 78 | * Check if file upload is complete 79 | */ 80 | public function validateFile(): bool 81 | { 82 | $totalChunks = $this->request->getTotalChunks(); 83 | $totalChunksSize = 0; 84 | 85 | for ($i = $totalChunks; $i >= 1; $i--) { 86 | $file = $this->getChunkPath($i); 87 | if (! file_exists($file)) { 88 | return false; 89 | } 90 | $totalChunksSize += filesize($file); 91 | } 92 | 93 | return $this->request->getTotalSize() == $totalChunksSize; 94 | } 95 | 96 | /** 97 | * Merge all chunks to single file 98 | * 99 | * @param string $destination final file location 100 | * 101 | * @throws FileLockException 102 | * @throws FileOpenException 103 | * @throws \Exception 104 | * 105 | * @return bool indicates if file was saved 106 | */ 107 | public function save(string $destination): bool 108 | { 109 | $fh = fopen($destination, 'wb'); 110 | if (! $fh) { 111 | throw new FileOpenException('failed to open destination file: '.$destination); 112 | } 113 | 114 | if (! flock($fh, LOCK_EX | LOCK_NB, $blocked)) { 115 | // @codeCoverageIgnoreStart 116 | if ($blocked) { 117 | // Concurrent request has requested a lock. 118 | // File is being processed at the moment. 119 | // Warning: lock is not checked in windows. 120 | return false; 121 | } 122 | // @codeCoverageIgnoreEnd 123 | 124 | throw new FileLockException('failed to lock file: '.$destination); 125 | } 126 | 127 | $totalChunks = $this->request->getTotalChunks(); 128 | 129 | try { 130 | $preProcessChunk = $this->config->getPreprocessCallback(); 131 | 132 | for ($i = 1; $i <= $totalChunks; $i++) { 133 | $file = $this->getChunkPath($i); 134 | $chunk = fopen($file, 'rb'); 135 | 136 | if (! $chunk) { 137 | throw new FileOpenException('failed to open chunk: '.$file); 138 | } 139 | 140 | if (null !== $preProcessChunk) { 141 | \call_user_func($preProcessChunk, $chunk); 142 | } 143 | 144 | stream_copy_to_stream($chunk, $fh); 145 | fclose($chunk); 146 | } 147 | } catch (\Exception $e) { 148 | flock($fh, LOCK_UN); 149 | fclose($fh); 150 | 151 | throw $e; 152 | } 153 | 154 | if ($this->config->getDeleteChunksOnSave()) { 155 | $this->deleteChunks(); 156 | } 157 | 158 | flock($fh, LOCK_UN); 159 | fclose($fh); 160 | 161 | return true; 162 | } 163 | 164 | /** 165 | * Delete chunks dir 166 | */ 167 | public function deleteChunks(): static 168 | { 169 | $totalChunks = $this->request->getTotalChunks(); 170 | 171 | for ($i = 1; $i <= $totalChunks; $i++) { 172 | $path = $this->getChunkPath($i); 173 | if (file_exists($path)) { 174 | unlink($path); 175 | } 176 | } 177 | 178 | return $this; 179 | } 180 | 181 | /** 182 | * This method is used only for testing 183 | * 184 | * @private 185 | * @codeCoverageIgnore 186 | */ 187 | public function _move_uploaded_file(string $filePath, string $destinationPath): bool 188 | { 189 | return move_uploaded_file($filePath, $destinationPath); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/Flow/Mongo/MongoFile.php: -------------------------------------------------------------------------------- 1 | config->getGridFs()->getChunksCollection()->findOne([ 42 | 'files_id' => $this->getGridFsFile()['_id'], 43 | 'n' => ((int) $index - 1) 44 | ]); 45 | } 46 | 47 | public function checkChunk(): bool 48 | { 49 | return $this->chunkExists($this->request->getCurrentChunkNumber()); 50 | } 51 | 52 | /** 53 | * Save chunk 54 | * @param $additionalUpdateOptions array additional options for the mongo update/upsert operation. 55 | * 56 | * @throws Exception if upload size is invalid or some other unexpected error occurred. 57 | */ 58 | public function saveChunk(array $additionalUpdateOptions = []): bool 59 | { 60 | try { 61 | $file = $this->request->getFile(); 62 | 63 | $chunkQuery = [ 64 | 'files_id' => $this->getGridFsFile()['_id'], 65 | 'n' => (int) ($this->request->getCurrentChunkNumber()) - 1, 66 | ]; 67 | $chunk = $chunkQuery; 68 | $data = file_get_contents($file['tmp_name']); 69 | $actualChunkSize = \strlen($data); 70 | if ($actualChunkSize > $this->request->getDefaultChunkSize() || 71 | ($actualChunkSize < $this->request->getDefaultChunkSize() && 72 | $this->request->getCurrentChunkNumber() != $this->request->getTotalChunks()) 73 | ) { 74 | throw new Exception("Invalid upload! (size: {$actualChunkSize})"); 75 | } 76 | $chunk['data'] = new Binary($data, Binary::TYPE_GENERIC); 77 | $this->config->getGridFs()->getChunksCollection()->replaceOne($chunkQuery, $chunk, array_merge(['upsert' => true], $additionalUpdateOptions)); 78 | unlink($file['tmp_name']); 79 | 80 | $this->ensureIndices(); 81 | 82 | return true; 83 | } catch (Exception $e) { 84 | // try to remove a possibly (partly) stored chunk: 85 | if (isset($chunkQuery)) { // @phpstan-ignore-line 86 | $this->config->getGridFs()->getChunksCollection()->deleteMany($chunkQuery); 87 | } 88 | 89 | throw $e; 90 | } 91 | } 92 | 93 | public function validateFile(): bool 94 | { 95 | $totalChunks = (int) ($this->request->getTotalChunks()); 96 | $storedChunks = $this->config->getGridFs()->getChunksCollection() 97 | ->countDocuments(['files_id' => $this->getGridFsFile()['_id']]); 98 | 99 | return $totalChunks === $storedChunks; 100 | } 101 | 102 | /** 103 | * Merge all chunks to single file 104 | * @param $metadata array additional metadata for final file 105 | * @throws Exception 106 | * @return ObjectId|bool of saved file or false if file was already saved 107 | */ 108 | public function saveToGridFs(?array $metadata = null) 109 | { 110 | $file = $this->getGridFsFile(); 111 | $file['flowStatus'] = 'finished'; 112 | $file['metadata'] = $metadata; 113 | $result = $this->config->getGridFs()->getFilesCollection()->findOneAndReplace($this->getGridFsFileQuery(), $file); 114 | // on second invocation no more file can be found, as the flowStatus changed: 115 | if (null === $result) { 116 | return false; 117 | } 118 | 119 | return $file['_id']; 120 | 121 | } 122 | 123 | public function save(string $destination): bool 124 | { 125 | throw new Exception("Must not use 'save' on MongoFile - use 'saveToGridFs'!"); 126 | } 127 | 128 | public function deleteChunks(): static 129 | { 130 | // nothing to do, as chunks are directly part of the final file 131 | 132 | return $this; 133 | } 134 | 135 | public function ensureIndices(): static 136 | { 137 | $chunksCollection = $this->config->getGridFs()->getChunksCollection(); 138 | $indexKeys = ['files_id' => 1, 'n' => 1]; 139 | $indexOptions = ['unique' => true, 'background' => true]; 140 | $chunksCollection->createIndex($indexKeys, $indexOptions); 141 | 142 | return $this; 143 | } 144 | 145 | /** 146 | * return array 147 | */ 148 | protected function getGridFsFile() 149 | { 150 | if (! $this->uploadGridFsFile) { 151 | $gridFsFileQuery = $this->getGridFsFileQuery(); 152 | $changed = $gridFsFileQuery; 153 | $changed['flowUpdated'] = new UTCDateTime(); 154 | $this->uploadGridFsFile = $this->config->getGridFs()->getFilesCollection()->findOneAndReplace( 155 | $gridFsFileQuery, 156 | $changed, 157 | ['upsert' => true, 'returnDocument' => FindOneAndReplace::RETURN_DOCUMENT_AFTER] 158 | ); 159 | } 160 | 161 | return $this->uploadGridFsFile; 162 | } 163 | 164 | protected function getGridFsFileQuery(): array 165 | { 166 | return [ 167 | 'flowIdentifier' => $this->request->getIdentifier(), 168 | 'flowStatus' => 'uploading', 169 | 'filename' => $this->request->getFileName(), 170 | 'chunkSize' => (int) ($this->request->getDefaultChunkSize()), 171 | 'length' => (int) ($this->request->getTotalSize()) 172 | ]; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | setRiskyAllowed(true) 11 | ->setRules([ 12 | '@PSR2' => true, 13 | 'align_multiline_comment' => true, 14 | 'array_indentation' => true, 15 | 'array_push' => true, 16 | 'array_syntax' => true, 17 | 'backtick_to_shell_exec' => true, 18 | 'binary_operator_spaces' => true, 19 | 'blank_line_after_namespace' => true, 20 | 'blank_line_after_opening_tag' => true, 21 | 'blank_line_before_statement' => true, 22 | 'blank_lines_before_namespace' => true, 23 | 'cast_spaces' => true, 24 | 'class_attributes_separation' => true, 25 | 'class_definition' => true, 26 | 'clean_namespace' => true, 27 | 'combine_consecutive_issets' => true, 28 | 'combine_consecutive_unsets' => true, 29 | 'combine_nested_dirname' => true, 30 | 'compact_nullable_type_declaration' => true, 31 | 'concat_space' => true, 32 | 'constant_case' => true, 33 | 'declare_equal_normalize' => true, 34 | 'dir_constant' => true, 35 | 'elseif' => true, 36 | 'encoding' => true, 37 | 'ereg_to_preg' => true, 38 | 'explicit_indirect_variable' => true, 39 | 'explicit_string_variable' => true, 40 | 'fopen_flag_order' => true, 41 | 'full_opening_tag' => true, 42 | 'function_declaration' => true, 43 | 'function_to_constant' => true, 44 | 'general_phpdoc_tag_rename' => true, 45 | 'implode_call' => true, 46 | 'include' => true, 47 | 'indentation_type' => true, 48 | 'is_null' => true, 49 | 'line_ending' => true, 50 | 'linebreak_after_opening_tag' => true, 51 | 'list_syntax' => true, 52 | 'logical_operators' => true, 53 | 'lowercase_cast' => true, 54 | 'lowercase_keywords' => true, 55 | 'lowercase_static_reference' => true, 56 | 'magic_constant_casing' => true, 57 | 'magic_method_casing' => true, 58 | 'method_argument_space' => true, 59 | 'method_chaining_indentation' => true, 60 | 'modernize_types_casting' => true, 61 | 'multiline_whitespace_before_semicolons' => true, 62 | 'native_function_casing' => true, 63 | 'native_function_invocation' => true, 64 | 'native_type_declaration_casing' => true, 65 | 'new_with_parentheses' => true, 66 | 'no_alias_functions' => true, 67 | 'no_alias_language_construct_call' => true, 68 | 'no_alternative_syntax' => true, 69 | 'no_blank_lines_after_phpdoc' => true, 70 | 'no_break_comment' => true, 71 | 'no_closing_tag' => true, 72 | 'no_empty_comment' => true, 73 | 'no_empty_phpdoc' => true, 74 | 'no_empty_statement' => true, 75 | 'no_homoglyph_names' => true, 76 | 'no_leading_import_slash' => true, 77 | 'no_leading_namespace_whitespace' => true, 78 | 'no_mixed_echo_print' => true, 79 | 'no_multiline_whitespace_around_double_arrow' => true, 80 | 'no_php4_constructor' => true, 81 | 'no_short_bool_cast' => true, 82 | 'no_spaces_after_function_name' => true, 83 | 'no_superfluous_phpdoc_tags' => true, 84 | 'no_trailing_whitespace' => true, 85 | 'no_trailing_whitespace_in_comment' => true, 86 | 'no_unneeded_braces' => true, 87 | 'no_unneeded_control_parentheses' => true, 88 | 'no_unneeded_final_method' => true, 89 | 'no_unreachable_default_argument_value' => true, 90 | 'no_unset_cast' => true, 91 | 'no_unset_on_property' => true, 92 | 'no_unused_imports' => true, 93 | 'no_useless_else' => true, 94 | 'no_useless_return' => true, 95 | 'no_useless_sprintf' => true, 96 | 'no_whitespace_before_comma_in_array' => true, 97 | 'no_whitespace_in_blank_line' => true, 98 | 'non_printable_character' => true, 99 | 'normalize_index_brace' => true, 100 | 'not_operator_with_successor_space' => true, 101 | 'nullable_type_declaration_for_default_null_value' => true, 102 | 'object_operator_without_whitespace' => true, 103 | 'ordered_class_elements' => true, 104 | 'ordered_imports' => true, 105 | 'ordered_interfaces' => true, 106 | 'ordered_traits' => true, 107 | 'phpdoc_no_useless_inheritdoc' => true, 108 | 'phpdoc_order' => true, 109 | 'phpdoc_return_self_reference' => true, 110 | 'phpdoc_scalar' => true, 111 | 'phpdoc_tag_casing' => true, 112 | 'protected_to_private' => true, 113 | 'psr_autoloading' => true, 114 | 'return_type_declaration' => true, 115 | 'semicolon_after_instruction' => true, 116 | 'set_type_to_cast' => true, 117 | 'short_scalar_cast' => true, 118 | 'simple_to_complex_string_variable' => true, 119 | 'simplified_if_return' => true, 120 | 'simplified_null_return' => true, 121 | 'single_blank_line_at_eof' => true, 122 | 'single_class_element_per_statement' => true, 123 | 'single_import_per_statement' => true, 124 | 'single_line_after_imports' => true, 125 | 'single_line_comment_style' => true, 126 | 'single_quote' => true, 127 | 'single_trait_insert_per_statement' => true, 128 | 'spaces_inside_parentheses' => false, 129 | 'standardize_not_equals' => true, 130 | 'static_lambda' => true, 131 | 'string_line_ending' => true, 132 | 'switch_case_semicolon_to_colon' => true, 133 | 'switch_case_space' => true, 134 | 'switch_continue_to_break' => true, 135 | 'ternary_operator_spaces' => true, 136 | 'ternary_to_elvis_operator' => true, 137 | 'ternary_to_null_coalescing' => true, 138 | 'trim_array_spaces' => true, 139 | 'type_declaration_spaces' => true, 140 | 'unary_operator_spaces' => true, 141 | 'visibility_required' => true, 142 | 'whitespace_after_comma_in_array' => true, 143 | 'yoda_style' => true, 144 | ]) 145 | ->setFinder(PhpCsFixer\Finder::create() 146 | ->exclude('vendor') 147 | ->in(__DIR__.'\src') 148 | ->in(__DIR__.'\test') 149 | ) 150 | ; 151 | 152 | -------------------------------------------------------------------------------- /test/Unit/FileTest.php: -------------------------------------------------------------------------------- 1 | vfs = new vfsStreamDirectory('chunks'); 44 | vfsStreamWrapper::setRoot($this->vfs); 45 | 46 | // Setup Config 47 | $this->config = new Config(); 48 | $this->config->setTempDir($this->vfs->url()); 49 | } 50 | 51 | /** 52 | * @covers ::__construct 53 | * @covers ::getIdentifier 54 | */ 55 | public function testFile_construct_withRequest() 56 | { 57 | $request = new Request($this->requestArr); 58 | $file = new File($this->config, $request); 59 | 60 | $expIdentifier = sha1($this->requestArr['flowIdentifier']); 61 | $this->assertSame($expIdentifier, $file->getIdentifier()); 62 | } 63 | 64 | /** 65 | * @covers ::__construct 66 | * @covers ::getIdentifier 67 | */ 68 | public function testFile_construct_noRequest() 69 | { 70 | $_REQUEST = $this->requestArr; 71 | 72 | $file = new File($this->config); 73 | 74 | $expIdentifier = sha1($this->requestArr['flowIdentifier']); 75 | $this->assertSame($expIdentifier, $file->getIdentifier()); 76 | } 77 | 78 | /** 79 | * @covers ::getChunkPath 80 | */ 81 | public function testFile_construct_getChunkPath() 82 | { 83 | $request = new Request($this->requestArr); 84 | $file = new File($this->config, $request); 85 | 86 | $expPath = $this->vfs->url().DIRECTORY_SEPARATOR.sha1($this->requestArr['flowIdentifier']).'_1'; 87 | $this->assertSame($expPath, $file->getChunkPath(1)); 88 | } 89 | 90 | /** 91 | * @covers ::checkChunk 92 | */ 93 | public function testFile_construct_checkChunk() 94 | { 95 | $request = new Request($this->requestArr); 96 | $file = new File($this->config, $request); 97 | 98 | $this->assertFalse($file->checkChunk()); 99 | 100 | $chunkName = sha1($request->getIdentifier()).'_'.$request->getCurrentChunkNumber(); 101 | $firstChunk = vfsStream::newFile($chunkName); 102 | $this->vfs->addChild($firstChunk); 103 | 104 | $this->assertTrue($file->checkChunk()); 105 | } 106 | 107 | /** 108 | * @covers ::validateChunk 109 | */ 110 | public function testFile_validateChunk() 111 | { 112 | // No $_FILES 113 | $request = new Request($this->requestArr); 114 | $file = new File($this->config, $request); 115 | 116 | $this->assertFalse($file->validateChunk()); 117 | 118 | // No 'file' key $_FILES 119 | $fileInfo = []; 120 | $request = new Request($this->requestArr, $fileInfo); 121 | $file = new File($this->config, $request); 122 | 123 | $this->assertFalse($file->validateChunk()); 124 | 125 | // Upload OK 126 | $fileInfo = [ 127 | 'size' => 10, 128 | 'error' => UPLOAD_ERR_OK, 129 | 'tmp_name' => '' 130 | ]; 131 | $request = new Request($this->requestArr, $fileInfo); 132 | $file = new File($this->config, $request); 133 | $this->assertTrue($file->validateChunk()); 134 | 135 | // Chunk size doesn't match 136 | $fileInfo = [ 137 | 'size' => 9, 138 | 'error' => UPLOAD_ERR_OK, 139 | 'tmp_name' => '' 140 | ]; 141 | $request = new Request($this->requestArr, $fileInfo); 142 | $file = new File($this->config, $request); 143 | $this->assertFalse($file->validateChunk()); 144 | 145 | // Upload error 146 | $fileInfo = [ 147 | 'size' => 10, 148 | 'error' => UPLOAD_ERR_EXTENSION, 149 | 'tmp_name' => '' 150 | ]; 151 | $request = new Request($this->requestArr, $fileInfo); 152 | $file = new File($this->config, $request); 153 | $this->assertFalse($file->validateChunk()); 154 | } 155 | 156 | /** 157 | * @covers ::validateFile 158 | */ 159 | public function testFile_validateFile() 160 | { 161 | $this->requestArr['flowTotalSize'] = 10; 162 | $this->requestArr['flowTotalChunks'] = 3; 163 | 164 | $request = new Request($this->requestArr); 165 | $file = new File($this->config, $request); 166 | $chunkPrefix = sha1($request->getIdentifier()).'_'; 167 | 168 | // No chunks uploaded yet 169 | $this->assertFalse($file->validateFile()); 170 | 171 | // First chunk 172 | $firstChunk = vfsStream::newFile($chunkPrefix.'1'); 173 | $firstChunk->setContent('123'); 174 | $this->vfs->addChild($firstChunk); 175 | 176 | // Uploaded not yet complete 177 | $this->assertFalse($file->validateFile()); 178 | 179 | // Second chunk 180 | $secondChunk = vfsStream::newFile($chunkPrefix.'2'); 181 | $secondChunk->setContent('456'); 182 | $this->vfs->addChild($secondChunk); 183 | 184 | // Uploaded not yet complete 185 | $this->assertFalse($file->validateFile()); 186 | 187 | // Third chunk 188 | $lastChunk = vfsStream::newFile($chunkPrefix.'3'); 189 | $lastChunk->setContent('7890'); 190 | $this->vfs->addChild($lastChunk); 191 | 192 | // All chunks uploaded 193 | $this->assertTrue($file->validateFile()); 194 | 195 | //// Test false values 196 | 197 | // File size doesn't match 198 | $lastChunk->setContent('789'); 199 | $this->assertFalse($file->validateFile()); 200 | 201 | // Correct file size and expect true 202 | $this->requestArr['flowTotalSize'] = 9; 203 | $request = new Request($this->requestArr); 204 | $file = new File($this->config, $request); 205 | $this->assertTrue($file->validateFile()); 206 | } 207 | 208 | /** 209 | * @covers ::deleteChunks 210 | */ 211 | public function testFile_deleteChunks() 212 | { 213 | //// Setup test 214 | 215 | $this->requestArr['flowTotalChunks'] = 4; 216 | 217 | $fileInfo = []; 218 | $request = new Request($this->requestArr, $fileInfo); 219 | $file = new File($this->config, $request); 220 | $chunkPrefix = sha1($request->getIdentifier()).'_'; 221 | 222 | $firstChunk = vfsStream::newFile($chunkPrefix. 1); 223 | $this->vfs->addChild($firstChunk); 224 | 225 | $secondChunk = vfsStream::newFile($chunkPrefix. 3); 226 | $this->vfs->addChild($secondChunk); 227 | 228 | $thirdChunk = vfsStream::newFile('other'); 229 | $this->vfs->addChild($thirdChunk); 230 | 231 | //// Actual test 232 | 233 | $this->assertTrue(file_exists($firstChunk->url())); 234 | $this->assertTrue(file_exists($secondChunk->url())); 235 | $this->assertTrue(file_exists($thirdChunk->url())); 236 | 237 | $file->deleteChunks(); 238 | $this->assertFalse(file_exists($firstChunk->url())); 239 | $this->assertFalse(file_exists($secondChunk->url())); 240 | $this->assertTrue(file_exists($thirdChunk->url())); 241 | } 242 | 243 | /** 244 | * @covers ::saveChunk 245 | */ 246 | public function testFile_saveChunk() 247 | { 248 | // Setup temporary file 249 | $tmpDir = new vfsStreamDirectory('tmp'); 250 | $tmpFile = vfsStream::newFile('tmpFile'); 251 | $tmpFile->setContent('1234567890'); 252 | $tmpDir->addChild($tmpFile); 253 | $this->vfs->addChild($tmpDir); 254 | $this->filesArr['file']['tmp_name'] = $tmpFile->url(); 255 | 256 | // Mock File to use rename instead of move_uploaded_file 257 | $request = new Request($this->requestArr, $this->filesArr['file']); 258 | $file = $this->createPartialMock(File::class, ['_move_uploaded_file']); 259 | $file->expects($this->once()) 260 | ->method('_move_uploaded_file') 261 | ->willReturnCallback(static function (string $filename, string $destination): bool { 262 | return rename($filename, $destination); 263 | }); 264 | $file->__construct($this->config, $request); 265 | 266 | 267 | // Expected destination file 268 | $expDstFile = $this->vfs->url().DIRECTORY_SEPARATOR.sha1($request->getIdentifier()).'_1'; 269 | 270 | //// Accrual test 271 | $this->assertFalse(file_exists($expDstFile)); 272 | $this->assertTrue(file_exists($tmpFile->url())); 273 | 274 | /** @noinspection PhpUndefinedMethodInspection */ 275 | $this->assertTrue($file->saveChunk()); 276 | 277 | $this->assertTrue(file_exists($expDstFile)); 278 | //$this->assertFalse(file_exists($tmpFile->url())); 279 | 280 | $this->assertSame('1234567890', file_get_contents($expDstFile)); 281 | } 282 | 283 | /** 284 | * @covers ::save 285 | */ 286 | public function testFile_save() 287 | { 288 | //// Setup test 289 | 290 | $this->requestArr['flowTotalChunks'] = 3; 291 | $this->requestArr['flowTotalSize'] = 10; 292 | 293 | $request = new Request($this->requestArr); 294 | $file = new File($this->config, $request); 295 | 296 | $chunkPrefix = sha1($request->getIdentifier()).'_'; 297 | 298 | $chunk = vfsStream::newFile($chunkPrefix.'1', 0777); 299 | $chunk->setContent('0123'); 300 | $this->vfs->addChild($chunk); 301 | 302 | $chunk = vfsStream::newFile($chunkPrefix.'2', 0777); 303 | $chunk->setContent('456'); 304 | $this->vfs->addChild($chunk); 305 | 306 | $chunk = vfsStream::newFile($chunkPrefix.'3', 0777); 307 | $chunk->setContent('789'); 308 | $this->vfs->addChild($chunk); 309 | 310 | $filePath = $this->vfs->url().DIRECTORY_SEPARATOR.'file'; 311 | 312 | //// Actual test 313 | 314 | $this->assertTrue($file->save($filePath)); 315 | $this->assertTrue(file_exists($filePath)); 316 | $this->assertEquals($request->getTotalSize(), filesize($filePath)); 317 | } 318 | 319 | /** 320 | * @covers ::save 321 | */ 322 | public function testFile_save_lock() 323 | { 324 | //// Setup test 325 | 326 | $request = new Request($this->requestArr); 327 | $file = new File($this->config, $request); 328 | 329 | $dstFile = $this->vfs->url().DIRECTORY_SEPARATOR.'file'; 330 | 331 | // Lock file 332 | $fh = fopen($dstFile, 'wb'); 333 | $this->assertTrue(flock($fh, LOCK_EX)); 334 | 335 | //// Actual test 336 | 337 | try { 338 | // practically on a normal file system exception would not be thrown, this happens 339 | // because vfsStreamWrapper does not support locking with block 340 | $file->save($dstFile); 341 | $this->fail(); 342 | } catch (FileLockException $e) { 343 | $this->assertEquals('failed to lock file: '.$dstFile, $e->getMessage()); 344 | } 345 | } 346 | 347 | /** 348 | * @covers ::save 349 | */ 350 | public function testFile_save_FileOpenException() 351 | { 352 | $request = new Request($this->requestArr); 353 | $file = new File($this->config, $request); 354 | 355 | try { 356 | @$file->save('not/existing/path'); 357 | $this->fail(); 358 | } catch (FileOpenException $e) { 359 | $this->assertEquals('failed to open destination file: not/existing/path', $e->getMessage()); 360 | } 361 | } 362 | 363 | /** 364 | * @covers ::save 365 | */ 366 | public function testFile_save_chunk_FileOpenException() 367 | { 368 | //// Setup test 369 | 370 | $this->requestArr['flowTotalChunks'] = 3; 371 | $this->requestArr['flowTotalSize'] = 10; 372 | 373 | $request = new Request($this->requestArr); 374 | $file = new File($this->config, $request); 375 | 376 | $chunkPrefix = sha1($request->getIdentifier()).'_'; 377 | 378 | $chunk = vfsStream::newFile($chunkPrefix.'1', 0777); 379 | $chunk->setContent('0123'); 380 | $this->vfs->addChild($chunk); 381 | 382 | $chunk = vfsStream::newFile($chunkPrefix.'2', 0777); 383 | $chunk->setContent('456'); 384 | $this->vfs->addChild($chunk); 385 | 386 | $missingChunk = $this->vfs->url().DIRECTORY_SEPARATOR.$chunkPrefix.'3'; 387 | $filePath = $this->vfs->url().DIRECTORY_SEPARATOR.'file'; 388 | 389 | //// Actual test 390 | 391 | try { 392 | @$file->save($filePath); 393 | } catch (FileOpenException $e) { 394 | $this->assertEquals('failed to open chunk: '.$missingChunk, $e->getMessage()); 395 | } 396 | } 397 | 398 | /** 399 | * @covers ::save 400 | */ 401 | public function testFile_save_preProcess() 402 | { 403 | //// Setup test 404 | 405 | $this->requestArr['flowTotalChunks'] = 1; 406 | $this->requestArr['flowTotalSize'] = 10; 407 | $processCalled = false; 408 | 409 | $process = static function ($chunk) use (&$processCalled) { 410 | $processCalled = true; 411 | }; 412 | 413 | $this->config->setPreprocessCallback($process); 414 | 415 | $request = new Request($this->requestArr); 416 | $file = new File($this->config, $request); 417 | 418 | $chunkPrefix = sha1($request->getIdentifier()).'_'; 419 | 420 | $chunk = vfsStream::newFile($chunkPrefix.'1', 0777); 421 | $chunk->setContent('1234567890'); 422 | $this->vfs->addChild($chunk); 423 | 424 | $filePath = $this->vfs->url().DIRECTORY_SEPARATOR.'file'; 425 | 426 | //// Actual test 427 | 428 | $this->assertTrue($file->save($filePath)); 429 | $this->assertTrue(file_exists($filePath)); 430 | $this->assertEquals($request->getTotalSize(), filesize($filePath)); 431 | $this->assertTrue($processCalled); 432 | } 433 | } 434 | --------------------------------------------------------------------------------