├── 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 [](https://travis-ci.org/flowjs/flow-php-server) [](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 |
--------------------------------------------------------------------------------