├── .gitignore ├── LICENSE ├── Readme.md ├── composer.json ├── examples ├── .htaccess ├── Web.config ├── client-express.php ├── client.php ├── composer.json ├── server.php └── wall.jpg └── src ├── Server.php ├── Store ├── FileSystem.php ├── MongoDB.php ├── Redis.php ├── S3.php ├── StorageBackend.php └── StoreInterface.php └── ThunderTUSException.php /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /examples/vendor 3 | composer.lock 4 | /examples/composer.lock 5 | .idea/ 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Tadeu Bento (TCB13) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, 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, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # ThunderTUS PHP 2 | 3 | Resumable file upload in PHP using tus resumable upload protocol v1.0.0. 4 | 5 | **tus** is a HTTP based protocol for resumable file uploads. Resumable means you can carry on where you left off without re-uploading whole data again in case of any interruptions. An interruption may happen willingly if the user wants to pause, or by accident in case of a network issue or server outage. 6 | 7 | **thunder tus** is the most reliable implementation of the tus protocol for PHP yet. Designed for **high concurrency** (real world scenarios) and integration simplicity it's **free of external dependencies** (complex caching engines etc.). It is also **PSR-7 compliant** in order to bring the tus protocol to modern PHP frameworks such as **Slim 3**. 8 | 9 | **extensions**: building on the extensibility capabilities of the tus protocol, thunder tus also includes two new extensions: 10 | 11 | - **CrossCheck**: final checksum of the uploaded files to ensure maximum reliability; 12 | - **Express**: tus uploads with a single HTTP call - making tus better suited for mobile contexts and other environments where performance is a priority. 13 | 14 | ## Installation 15 | 16 | Pull the package via composer. 17 | ```shell 18 | $ composer require TCB13/thunder-tus-php 19 | ``` 20 | 21 | ## Basic Usage 22 | 23 | Use composer to install `tcb13/thunder-tus-php` and some other packages used in the following examples: 24 | ```shell 25 | $ composer require tcb13/thunder-tus-php psr/http-message zendframework/zend-diactoros zendframework/zend-httphandlerrunner 26 | ``` 27 | Create your `tus-server.php` file: 28 | ````php 29 | setStorageBackend($backend); 38 | $server->setApiPath("/"); 39 | $server->handle(); 40 | $response = $server->getResponse(); 41 | 42 | $emitter = new Zend\HttpHandlerRunner\Emitter\SapiEmitter(); 43 | $emitter->emit($response); 44 | ```` 45 | Create the following `.htaccess` (or equivalent) at your virtual host: 46 | ```` 47 | RewriteEngine on 48 | RewriteBase / 49 | RewriteRule ^(.*)$ tus-server.php [L,QSA] 50 | ```` 51 | Now you can go ahead and upload a file using the TUS client included at `examples/client-express.php`. 52 | After the upload is finished you may retrieve the file in another script by calling: 53 | ````php 54 | $finalStorageDirectory = "/var/www/html/uploads"; 55 | $server = new ThunderTUS\Server(); 56 | $status = $server->completeAndFetch($filename, $finalStorageDirectory); 57 | if (!$status) { 58 | throw new \Exception("Could not fetch ({$filename}) from storage backend: not found."); 59 | } 60 | ```` 61 | The file will be moved from the temporary storage backend to the `$finalStorageDirectory` directory. 62 | 63 | You may also retrieve the final file as a stream with `ThunderTUS\Server::completeAndStream()` or keep on the same place as the temporary parts with `ThunderTUS\Server::complete()` 64 | 65 | ## Storage Backends 66 | 67 | In order to use **ThunderTUS you must pick a storage backend**. Those are used to temporally store the uploaded parts until the upload is completed. Storage backends come in a variety of flavours from the local filesystem to MongoBD's GridFS: 68 | 69 | - `FileSystem`: a quick to use and understand backend for simple projects that will append uploaded parts into a file stored at the path provided on it's constructor; 70 | - `Amazon S3`: useful in distributed scenarios (eg. your backend serves requests from multiple machines behind a load balancer), an implementation of Amazon's S3 protocol. Tested compatibility with DigitalOcean's Spaces; 71 | - `Redis`: also for distributed scenarios, stores uploaded parts into a Redis database; 72 | - `MongoDB`: also for distributed scenarios, will store uploaded parts inside a MongoDB GridFS bucket. 73 | 74 | You may also implement your own storage backend by extending the `StorageBackend` class and/or implementing the `StorageInterface` interface. 75 | 76 | ### S3 Storage Backend 77 | ````php 78 | $server = new \ThunderTUS\Server($request, $response); 79 | 80 | $client = new S3Client([ 81 | "version" => "latest", 82 | "region" => "...", 83 | "endpoint" => "...", 84 | "credentials" => [ 85 | "key" => "--key--", 86 | "secret" => "--secret---", 87 | ], 88 | ]); 89 | $backend = new S3($client, "your-bucket", "optional-path-prefix"); 90 | $server->setStorageBackend($backend); 91 | 92 | $server->setUploadMaxFileSize(50000); 93 | $server->setApiPath("/tus"); 94 | $server->handle(); 95 | ````` 96 | You may later retrieve the finished upload as described above at the basic usage section. 97 | 98 | ### MongoDB Storage Backend 99 | ````php 100 | // Connect to your MongDB 101 | $con = new \MongoDB\Client($configs->uri, $configs->options]); 102 | $mongodb= $con->selectDatabase($configs->db]); 103 | 104 | // Start ThunderTUS 105 | $server = new \ThunderTUS\Server($request, $response); 106 | 107 | // Load the MongoDB backend 108 | $mongoBackend = new MongoDB($mongodb); 109 | $server->setStorageBackend($mongoBackend ); 110 | 111 | // Set other settings and process requests 112 | $server->setUploadMaxFileSize(50000); 113 | $server->setApiPath("/tus"); 114 | $server->handle(); 115 | 116 | // Send the response back to the client 117 | $response = $server->getResponse(); 118 | ```` 119 | You may later retrieve the finished upload as described above at the basic usage section. 120 | 121 | ### Redis Storage Backend 122 | ````php 123 | $server = new \ThunderTUS\Server($request, $response); 124 | 125 | $redisBackend = new Redis($redisClient); 126 | $server->setStorageBackend($redisBackend); 127 | 128 | $server->setUploadMaxFileSize(50000); 129 | $server->setApiPath("/tus"); 130 | $server->handle(); 131 | ````` 132 | You may later retrieve the finished upload as described above at the basic usage section. 133 | 134 | ## ThunderTUS & Dependency Injection 135 | 136 | ThunderTUS was designed to be integrated into dependency injection systems / containers. 137 | In simple scenarios you should pass an implementation of a PSR HTTP request and response to ThunderTUS's constructor, however this is optional. Sometimes it might be desirable to be able to instantiate the `Server` in a Service Provider and provide the PSR HTTP implementations later in a controller. 138 | 139 | Example of a **ThunderTUS service provider**: 140 | 141 | ````php 142 | public static function register() 143 | { 144 | $settings = $this->container->get("settings.tusProtocol"); 145 | 146 | // Create the server 147 | $server = new Server(); // No request or response implementations passed here 148 | 149 | // Load the filesystem Backend 150 | $backend = new FileSystem($settings->path]); 151 | $server->setStorageBackend($backend); 152 | 153 | // Set TUS upload parameters 154 | $server->setUploadMaxFileSize((int)$settings->maxSize); 155 | $server->setApiPath($settings->endpoint); 156 | 157 | return $server; 158 | } 159 | ```` 160 | Now the **controller that handles uploads**: 161 | 162 | ````php 163 | public function upload() 164 | { 165 | // Resolve TUS using the container 166 | /** @var \ThunderTUS\Server $server */ 167 | $server = $this->container->get(\ThunderTUS\Server::class); 168 | 169 | // Load the request or response implementations here! 170 | $server->loadHTTPInterfaces($this->request, $this->response); 171 | 172 | // Handle the upload request 173 | $server->handle(); 174 | 175 | // Send the response back to the client 176 | return $server->getResponse(); 177 | } 178 | ```` 179 | We've only provided the PSR HTTP request and response implementations on the controller by calling `$server->loadHTTPInterfaces(..)`. 180 | 181 | ## Client Implementations 182 | 183 | - **PHP Client**: At the `examples` directory you may find a simple client and tus-crosscheck / tus-express examples as well; 184 | - **JavaScript / ES6**: https://github.com/stenas/thunder-tus-js-client - a very well designed and implemented tus-crosscheck / tus-express capable client with minimal footprint. 185 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tcb13/thunder-tus-php", 3 | "type": "library", 4 | "description": "", 5 | "require": { 6 | "php": "^7.2", 7 | "psr/http-message": "*", 8 | "ext-json": "*", 9 | "ext-curl": "*" 10 | }, 11 | "authors": [ 12 | { 13 | "name": "TCB13", 14 | "email": "tadeu.bento@iklive.eu" 15 | } 16 | ], 17 | "minimum-stability": "dev", 18 | "autoload": { 19 | "psr-4": { 20 | "ThunderTUS\\": "src/" 21 | } 22 | } 23 | } -------------------------------------------------------------------------------- /examples/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine on 2 | RewriteBase / 3 | RewriteRule ^(.*)$ server.php [L,QSA] 4 | -------------------------------------------------------------------------------- /examples/Web.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /examples/client-express.php: -------------------------------------------------------------------------------- 1 | " . ($pointer + $chunkLen) . "\n"; 18 | $chunk = fread($fh, $chunkLen); 19 | $chunkChk = base64_encode(hash($chkAlgo, $chunk, true)); 20 | 21 | $ch = curl_init(); 22 | curl_setopt($ch, CURLOPT_URL, $url); 23 | curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PATCH"); 24 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 25 | curl_setopt($ch, CURLOPT_POSTFIELDS, $chunk); 26 | curl_setopt($ch, CURLOPT_HEADER, true); 27 | 28 | $headers = [ 29 | "Content-Type" => "application/offset+octet-stream", 30 | "Tus-Resumable" => "1.0.0", 31 | "Upload-Offset" => $pointer, 32 | "Upload-Checksum" => "$chkAlgo $chunkChk", // For the current part 33 | // ThunderTUS 34 | "CrossCheck" => "true", 35 | "Express" => "true", 36 | "Upload-Length" => $fileLen, 37 | "Upload-CrossChecksum" => "$chkAlgo $fileChk"// For the entire file 38 | ]; 39 | $fheaders = []; 40 | foreach ($headers as $key => $value) 41 | $fheaders[] = $key . ": " . $value; 42 | curl_setopt($ch, CURLOPT_HTTPHEADER, $fheaders); 43 | 44 | $result = curl_exec($ch); 45 | $httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE); 46 | if (curl_errno($ch)) 47 | echo "Error: " . curl_error($ch); 48 | curl_close($ch); 49 | 50 | if ($httpcode == 410) { 51 | print "CrossCheck Failed!"; 52 | break; 53 | } 54 | 55 | // Move pointer to server offset if there was an error uploading a particular part 56 | if ($httpcode !== 204) { 57 | print "Chunk upload failed at $pointer retrying..."; 58 | 59 | $headers = explode("\r\n", $result); 60 | $headers = array_map(function ($item) { 61 | return explode(": ", $item); 62 | }, $headers); 63 | $header = array_filter($headers, function ($value) { 64 | if ($value[0] === "Upload-Offset") 65 | return true; 66 | }); 67 | 68 | if (empty($header) && $httpcode == 409) { 69 | print "Error file already uploaded?"; 70 | break; 71 | } 72 | 73 | $serverOffset = (int)array_shift($header)[1]; 74 | 75 | rewind($fh); 76 | fseek($fh, $serverOffset); 77 | print " new pointer at " . ftell($fh) . "\n"; 78 | } 79 | 80 | } 81 | fclose($fh); 82 | -------------------------------------------------------------------------------- /examples/client.php: -------------------------------------------------------------------------------- 1 | "1.0.0", 20 | "Content-Type" => "application/offset+octet-stream", 21 | "Upload-Length" => $fileLen 22 | ]; 23 | $fheaders = []; 24 | foreach ($headers as $key => $value) 25 | $fheaders[] = $key . ": " . $value; 26 | curl_setopt($ch, CURLOPT_HTTPHEADER, $fheaders); 27 | 28 | $result = curl_exec($ch); 29 | if (curl_errno($ch)) { 30 | echo "Error: " . curl_error($ch); 31 | exit; 32 | } 33 | if (curl_getinfo($ch, CURLINFO_HTTP_CODE) == 201) { 34 | print "File created successfully \n"; 35 | } 36 | else { 37 | print "Error creating file \n"; 38 | exit; 39 | } 40 | curl_close($ch); 41 | 42 | // Get file offset/size on the server 43 | $ch = curl_init(); 44 | curl_setopt($ch, CURLOPT_URL, $url); 45 | curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "HEAD"); 46 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 47 | curl_setopt($ch, CURLOPT_NOBODY, true); 48 | curl_setopt($ch, CURLOPT_HEADER, true); 49 | $headers = [ 50 | "Tus-Resumable" => "1.0.0", 51 | "Content-Type" => "application/offset+octet-stream", 52 | ]; 53 | $fheaders = []; 54 | foreach ($headers as $key => $value) 55 | $fheaders[] = $key . ": " . $value; 56 | curl_setopt($ch, CURLOPT_HTTPHEADER, $fheaders); 57 | 58 | $result = curl_exec($ch); 59 | if (curl_errno($ch)) 60 | echo "Error: " . curl_error($ch); 61 | 62 | $headers = explode("\r\n", $result); 63 | $headers = array_map(function ($item) { 64 | return explode(": ", $item); 65 | }, $headers); 66 | $header = array_filter($headers, function ($value) { 67 | if ($value[0] === "Upload-Offset") 68 | return true; 69 | }); 70 | $serverOffset = (int)array_shift($header)[1]; 71 | if (curl_getinfo($ch, CURLINFO_HTTP_CODE) == 200) 72 | print "Current file offset at server: $serverOffset \n"; 73 | 74 | if (curl_getinfo($ch, CURLINFO_HTTP_CODE) == 404) { 75 | print "File not found!"; 76 | exit; 77 | } 78 | curl_close($ch); 79 | 80 | // Upload whole file 81 | $ch = curl_init(); 82 | curl_setopt($ch, CURLOPT_URL, $url); 83 | curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PATCH"); 84 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 85 | curl_setopt($ch, CURLOPT_POSTFIELDS, file_get_contents($file)); 86 | curl_setopt($ch, CURLOPT_HEADER, true); 87 | $headers = [ 88 | "Content-Type" => "application/offset+octet-stream", 89 | "Tus-Resumable" => "1.0.0", 90 | "Upload-Offset" => 0, 91 | "Upload-Checksum" => "$chkAlgo $fileChk", 92 | ]; 93 | $fheaders = []; 94 | foreach ($headers as $key => $value) 95 | $fheaders[] = $key . ": " . $value; 96 | curl_setopt($ch, CURLOPT_HTTPHEADER, $fheaders); 97 | 98 | $result = curl_exec($ch); 99 | if (curl_errno($ch)) 100 | echo "Error: " . curl_error($ch); 101 | if (curl_getinfo($ch, CURLINFO_HTTP_CODE) == 204) { 102 | print "File uploaded successfully!"; 103 | exit; 104 | } 105 | curl_close($ch); 106 | -------------------------------------------------------------------------------- /examples/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "php": "^7.1", 4 | "psr/http-message": "*", 5 | "zendframework/zend-diactoros": "*", 6 | "zendframework/zend-httphandlerrunner": "*", 7 | "ext-json": "*", 8 | "ext-curl": "*", 9 | "tcb13/thunder-tus-php": "*" 10 | }, 11 | "minimum-stability": "dev" 12 | } -------------------------------------------------------------------------------- /examples/server.php: -------------------------------------------------------------------------------- 1 | setStorageBackend($backend); 18 | $server->setApiPath("/"); 19 | $server->handle(); 20 | $response = $server->getResponse(); 21 | 22 | $emitter = new Zend\HttpHandlerRunner\Emitter\SapiEmitter(); 23 | $emitter->emit($response); 24 | -------------------------------------------------------------------------------- /examples/wall.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TCB13/thunder-tus-php/42d558c26da7888f33a16084739281d19840145e/examples/wall.jpg -------------------------------------------------------------------------------- /src/Server.php: -------------------------------------------------------------------------------- 1 | streamURI = $streamURI; 48 | if ($request !== null && $response !== null) { 49 | $this->loadHTTPInterfaces($request, $response); 50 | } 51 | } 52 | 53 | public function loadHTTPInterfaces(ServerRequestInterface $request, ResponseInterface $response) 54 | { 55 | $this->request = $request; 56 | $this->response = $response; 57 | 58 | // Detect the ThunderTUS CrossCheck extension 59 | if ($this->request->getHeaderLine("CrossCheck") == true) { 60 | $this->extCrossCheck = true; 61 | } 62 | 63 | // Detect the ThunderTUS Express extension 64 | if ($this->request->getHeaderLine("Express") == true) { 65 | $this->extExpress = true; 66 | } 67 | } 68 | 69 | public function setStorageBackend($backend) 70 | { 71 | if (!$backend instanceof StoreInterface) { 72 | throw new ThunderTUSException("Storage backend must implement " . StoreInterface::class); 73 | } 74 | $this->backend = $backend; 75 | return $this; 76 | } 77 | 78 | public function getStorageBackend() 79 | { 80 | return $this->backend; 81 | } 82 | 83 | /** 84 | * Completes an upload and fetches the finished file from the backend storage. 85 | * This method abstracts backend storage file retrieval in a way that the user doesn't 86 | * need to know what backend storage is being used at all times. 87 | * This is useful when the TUS Server is provided by some kind of Service Provider in a 88 | * dependency injection context. 89 | * 90 | * @param string $filename Name of your file 91 | * @param string $destinationDirectory Where to place the finished file 92 | * @param bool $removeAfter Remove the temporary files after this operation 93 | * 94 | * @return bool 95 | */ 96 | public function completeAndFetch(string $filename, string $destinationDirectory, bool $removeAfter = true): bool 97 | { 98 | return $this->backend->completeAndFetch($filename, $destinationDirectory, $removeAfter); 99 | } 100 | 101 | /** 102 | * Completes an upload and returns the finished file in the form of a stream. 103 | * Useful when you want to upload the file to another system without writing 104 | * it to the disk most of the time. 105 | * This method uses PHP's tmp stream to merge the file parts. Adjust it accordingly. 106 | * 107 | * @param string $filename Name of your file 108 | * @param bool $removeAfter Remove the temporary files after this operation 109 | * 110 | * @return bool 111 | */ 112 | public function completeAndStream(string $filename, bool $removeAfter = true) 113 | { 114 | return $this->backend->completeAndStream($filename, $removeAfter); 115 | } 116 | 117 | /** 118 | * Completes an upload without fetching it. The file will be placed in the 119 | * same backend storage you're using for the temporary part upload. 120 | * Useful when you want to keep the finished file in the same storage backend 121 | * you're using for the temporary part upload. 122 | * This method uses PHP's tmp stream to merge the file parts. Adjust it accordingly. 123 | * 124 | * @param string $filename Name of your file 125 | * 126 | * @return bool 127 | */ 128 | public function complete(string $name): bool 129 | { 130 | return $this->backend->complete($name); 131 | } 132 | 133 | /** 134 | * Handles the incoming request. 135 | * 136 | * @throws \ThunderTUS\ThunderTUSException 137 | */ 138 | public function handle(): Server 139 | { 140 | if (!isset($this->request) || !isset($this->response)) { 141 | throw new ThunderTUSException("You must set an '\Psr\Http\Message\ServerRequestInterface' and a '\Psr\Http\Message\ResponseInterface' implementation via the ThunderTUS constructor or by calling 'loadHTTPInterfaces()'."); 142 | } 143 | 144 | // Add global headers to the response 145 | $this->response = $this->response->withHeader("Tus-Resumable", "1.0.0") 146 | ->withHeader("Tus-Max-Size", $this->uploadMaxFileSize) 147 | ->withHeader("Cache-Control", "no-store"); 148 | 149 | // Check if a storage backend is set 150 | if ($this->backend === null) { 151 | throw new ThunderTUSException("You must set a storage backend before handling requests."); 152 | } 153 | 154 | // Call handlers 155 | $method = "handle" . $this->request->getMethod(); 156 | if (!method_exists($this, $method)) { 157 | throw new ThunderTUSException("Invalid HTTP request method. Not TUS or ThunderTUS compliant."); 158 | } 159 | 160 | // Check if this server supports the client protocol version 161 | if ($this->request->getHeaderLine("Tus-Resumable") != self::PROTOCOL_VERSION) { 162 | $this->response = $this->response->withStatus(412); 163 | return $this; 164 | } 165 | 166 | // Gather the filename from the last part of the URL and set the resource location 167 | $this->apiPath = rtrim($this->apiPath, "/") . "/"; 168 | $url = $this->request->getUri(); 169 | $this->file = str_replace([ 170 | "\\", 171 | "/" 172 | ], "", substr($url, strrpos($url, $this->apiPath) + strlen($this->apiPath))); 173 | $this->location = $this->apiPath . $this->file; 174 | 175 | // Replicate the input stream so we can control its pointer 176 | $this->stream = fopen('php://temp', 'w+'); 177 | stream_copy_to_stream(fopen($this->streamURI, 'r'), $this->stream); 178 | rewind($this->stream); 179 | 180 | $this->response = $this->$method(); 181 | 182 | return $this; 183 | } 184 | 185 | /** 186 | * Handle POST requests. 187 | * 188 | * Creates new files on the server and caches its expected size. 189 | * 190 | * If the client uses the ThunderTUS CrossCheck extension this method will also cache the final file 191 | * checksum - the checksum of the entire file. 192 | * 193 | * @return \Psr\Http\Message\ResponseInterface 194 | */ 195 | protected function handlePOST(bool $singlePartUpload = false): ResponseInterface 196 | { 197 | 198 | // If the already exists we can't create it 199 | if ($this->backend->exists($this->file)) { 200 | return $this->response->withStatus(409); 201 | } 202 | 203 | // Get the total upload expected length 204 | $length = $this->request->getHeaderLine("Upload-Length"); 205 | if ($length === "") { // Note: Some PSR7 implementations discard headers set to 0. 206 | return $this->response->withStatus(400); 207 | } 208 | 209 | // Check for the size of the entire upload 210 | if ($this->uploadMaxFileSize > 0 && $this->uploadMaxFileSize < $length) { 211 | return $this->response->withStatus(413); 212 | } 213 | 214 | // Create empty cache container 215 | $cache = new \stdClass(); 216 | $cache->length = $length; 217 | 218 | // Extension Thunder TUS CrossCheck: get complete upload checksum 219 | if ($this->extCrossCheck) { 220 | $supportedAlgos = $this->backend->supportsCrossCheck() ? $this->backend->getCrossCheckAlgoritms() : hash_algos(); 221 | $cache->checksum = self::parseChecksum($this->request->getHeaderLine("Upload-CrossChecksum")); 222 | if ($cache->checksum === false || !in_array($cache->checksum->algorithm, $supportedAlgos)) { 223 | return $this->response->withStatus(400); 224 | } 225 | } 226 | 227 | // Create an empty file to store the upload and save the cache container if not single part 228 | if (!$this->extExpress || ($this->extExpress && !$singlePartUpload)) { 229 | $this->backend->containerCreate($this->file, $cache); 230 | $this->backend->create($this->file); 231 | } 232 | 233 | return $this->response->withStatus(201) 234 | ->withHeader("Location", $this->location); 235 | } 236 | 237 | /** 238 | * Handle HEAD requests. 239 | * 240 | * Informs the client about the status of a file. If the file exists, the number of bytes (offset) already 241 | * stored in the server is also provided. 242 | * 243 | * If the client uses the ThunderTUS Express extension this method will also create new files bypassing 244 | * the need to call POST. 245 | * 246 | * @return \Psr\Http\Message\ResponseInterface 247 | */ 248 | protected function handleHEAD(): ResponseInterface 249 | { 250 | 251 | // Extension Thunder TUS Express: create the file on HEAD request 252 | if ($this->extExpress) { 253 | $this->response = $this->handlePOST(); 254 | $code = $this->response->getStatusCode(); 255 | if ($code !== 201 && $code !== 409) { 256 | return $this->response; 257 | } 258 | $this->response = $this->response->withHeader("Location", $this->location); 259 | } else { // For Standard TUS 260 | if (!$this->backend->exists($this->file)) { 261 | return $this->response->withStatus(404); 262 | } 263 | } 264 | 265 | // Standard TUS HEAD response 266 | $localSize = $this->backend->getSize($this->file); 267 | 268 | return $this->response->withStatus(200) 269 | ->withHeader("Upload-Offset", $localSize); 270 | 271 | } 272 | 273 | /** 274 | * Handle PATCH requests. 275 | * 276 | * Receives a chuck of a file and stores it on the server. Implements the checksum extension to validate 277 | * the integrity of the chunk. 278 | * 279 | * If the client uses the ThunderTUS Express extension this method will also create new files bypassing 280 | * the need to call POST/HEAD. 281 | * 282 | * If the client uses the ThunderTUS CrossCheck extension this method will also verify the final file 283 | * checksum (after all chunks are received) against checksum previously stored in the cache. 284 | * 285 | * @return \Psr\Http\Message\ResponseInterface 286 | */ 287 | protected function handlePATCH(): ResponseInterface 288 | { 289 | 290 | $offset = $this->request->getHeaderLine("Upload-Offset"); 291 | if ($offset === "") // Note: Some PSR7 implementations discard headers set to 0. 292 | { 293 | return $this->response->withStatus(400); 294 | } 295 | 296 | // Check if the server supports the proposed checksum algorithm 297 | $supportedAlgos = $this->backend->supportsCrossCheck() ? $this->backend->getCrossCheckAlgoritms() : hash_algos(); 298 | $checksum = self::parseChecksum($this->request->getHeaderLine("Upload-Checksum")); 299 | if ($checksum === false || !in_array($checksum->algorithm, $supportedAlgos)) { 300 | return $this->response->withStatus(400); 301 | } 302 | 303 | // Extension Thunder TUS Express: create the file on PATCH request 304 | $singlePartUpload = false; 305 | if ($this->extExpress && $offset == 0) { 306 | // Check if we're uploading a small file with only one part (some TUS clients might not set this header) 307 | $contentLength = $this->request->getHeaderLine("Content-Length"); 308 | $uploadLength = $this->request->getHeaderLine("Upload-Length"); 309 | if ($contentLength !== "" && $uploadLength !== "" && $contentLength >= $uploadLength) { 310 | $singlePartUpload = true; 311 | } 312 | 313 | $this->response = $this->handlePOST($singlePartUpload); 314 | $code = $this->response->getStatusCode(); 315 | if ($code !== 201 && $code !== 409) { 316 | return $this->response; 317 | } 318 | 319 | // Avoid overwriting completed uploads 320 | if ($code === 409 && !$singlePartUpload && !$this->backend->containerExists($this->file)) { 321 | return $this->response; 322 | } 323 | 324 | } else { // For Standard TUS 325 | if (!$this->backend->exists($this->file)) { 326 | return $this->response->withStatus(404); 327 | } 328 | } 329 | 330 | // Check if the current stored file offset is different from the proposed upload offset 331 | // This ensures we're getting the file block by block in the right order and nothing is missing 332 | if (!$singlePartUpload) { 333 | $fsize = $this->backend->getSize($this->file); 334 | if ($fsize != $offset) { 335 | return $this->response->withStatus(409) 336 | ->withHeader("Upload-Offset", $fsize); 337 | } 338 | } 339 | 340 | // Compare proposed checksum with the received data checksum 341 | $hashContext = hash_init($checksum->algorithm); 342 | hash_update_stream($hashContext, $this->stream); 343 | $localChecksum = base64_encode(hash_final($hashContext, true)); 344 | if ($localChecksum !== $checksum->value) { 345 | return $this->response->withStatus(460, "Checksum Mismatch") 346 | ->withHeader("Upload-Offset", $singlePartUpload ? 0 : $fsize); 347 | } 348 | rewind($this->stream); 349 | 350 | if ($this->extExpress && $singlePartUpload) { 351 | // Single part uploads 352 | if ($this->backend->store($this->file, $this->stream)) { 353 | $localSize = $contentLength; 354 | } else { 355 | $localSize = 0; // Some error happened while trying to store the file, the client will upload again 356 | } 357 | } else { 358 | $this->backend->append($this->file, $this->stream); 359 | $localSize = $this->backend->getSize($this->file); 360 | 361 | // Detect when the upload is complete 362 | $cache = $this->backend->containerFetch($this->file); 363 | if ($cache->length <= $localSize) { 364 | // Extension Thunder TUS CrossCheck: verify if the uploaded file is as expected or delete it 365 | if ($this->extCrossCheck && $this->backend->supportsCrossCheck()) { 366 | if (!$this->backend->crossCheck($this->file, $cache->checksum->algorithm, $cache->checksum->value)) { 367 | $this->backend->delete($this->file); 368 | $this->backend->containerDelete($this->file); 369 | return $this->response->withStatus(410); 370 | } 371 | } 372 | } 373 | } 374 | 375 | if ($this->extCrossCheck) { 376 | $this->response = $this->response->withHeader("Location", $this->location); 377 | } 378 | 379 | // File uploaded successfully! 380 | return $this->response->withStatus(204) 381 | ->withHeader("Upload-Offset", $localSize); 382 | 383 | } 384 | 385 | /** 386 | * Handle DELETE requests. 387 | * 388 | * Remove an existing file from the server and its associated resources. Useful when the client 389 | * decides to abort an upload. 390 | * 391 | * @return \Psr\Http\Message\ResponseInterface 392 | */ 393 | protected function handleDELETE(): ResponseInterface 394 | { 395 | if (!$this->backend->exists($this->file)) { 396 | return $this->response->withStatus(404); 397 | } 398 | $this->backend->delete($this->file); 399 | 400 | if ($this->backend->containerExists($this->file)) { 401 | $this->backend->containerDelete($this->file); 402 | } 403 | 404 | return $this->response->withStatus(204); 405 | } 406 | 407 | /** 408 | * Handle OPTIONS requests. 409 | * 410 | * Return information about the server's current configuration. Useful to get the protocol 411 | * version, the maximum upload file size and supported extensions. 412 | * 413 | * @return \Psr\Http\Message\ResponseInterface 414 | */ 415 | protected function handleOPTIONS(): ResponseInterface 416 | { 417 | return $this->response->withStatus(204) 418 | ->withHeader("Tus-Version", self::PROTOCOL_VERSION) 419 | ->withHeader("Tus-Max-Size", $this->uploadMaxFileSize) 420 | ->withHeader("Tus-Extension", "creation,checksum,termination,crosscheck,express"); 421 | } 422 | 423 | protected static function parseChecksum(string $value = "") 424 | { 425 | $value = explode(" ", $value); 426 | if (count($value) !== 2) { 427 | return false; 428 | } 429 | $value = array_map("trim", $value); 430 | return (object)[ 431 | "algorithm" => $value[0], 432 | "value" => $value[1] 433 | ]; 434 | } 435 | 436 | /** 437 | * Returns a PSR-7 compliant Response to sent to the client. 438 | * 439 | * @return \Psr\Http\Message\ResponseInterface 440 | */ 441 | public function getResponse(): ResponseInterface 442 | { 443 | return $this->response; 444 | } 445 | 446 | /** 447 | * Returns the underlying stream resource where the data sent to the server was stored. 448 | * 449 | * @param bool $rewind If the stream should be rewinded before returned. 450 | * 451 | * @return bool|resource 452 | */ 453 | public function getStream(bool $rewind = true) 454 | { 455 | if ($rewind) { 456 | rewind($this->stream); 457 | } 458 | return $this->stream; 459 | } 460 | 461 | public function getUploadMaxFileSize(): int 462 | { 463 | return $this->uploadMaxFileSize; 464 | } 465 | 466 | public function getApiPath(): string 467 | { 468 | return $this->apiPath; 469 | } 470 | 471 | /** 472 | * Set the maximum allowed size for uploads. Setting this to 0 will disable this limitation. 473 | * 474 | * @param int $uploadMaxFileSize 475 | * 476 | * @return \ThunderTUS\Server 477 | */ 478 | public function setUploadMaxFileSize(int $uploadMaxFileSize): Server 479 | { 480 | $this->uploadMaxFileSize = $uploadMaxFileSize; 481 | return $this; 482 | } 483 | 484 | /** 485 | * @param mixed $apiPath 486 | * 487 | * @return \ThunderTUS\Server 488 | */ 489 | public function setApiPath($apiPath): Server 490 | { 491 | $this->apiPath = $apiPath; 492 | return $this; 493 | } 494 | } 495 | -------------------------------------------------------------------------------- /src/Store/FileSystem.php: -------------------------------------------------------------------------------- 1 | setUploadDir($uploadDir); 17 | } 18 | } 19 | 20 | public function setUploadDir(string $uploadDir): bool 21 | { 22 | if (!is_dir($uploadDir)) { 23 | throw new ThunderTUSException("Invalid upload directory. Path wasn't set, it doesn't exist or it isn't a directory."); 24 | } 25 | $this->uploadDir = self::normalizePath($uploadDir); 26 | return true; 27 | } 28 | 29 | public function getUploadDir(): string 30 | { 31 | return $this->uploadDir; 32 | } 33 | 34 | /** Implement StoreInterface */ 35 | public function exists(string $name): bool 36 | { 37 | return file_exists($this->uploadDir . $name); 38 | } 39 | 40 | public function create(string $name): bool 41 | { 42 | touch($this->uploadDir . $name); 43 | return true; 44 | } 45 | 46 | public function getSize(string $name): int 47 | { 48 | if (!file_exists($this->uploadDir . $name)) { 49 | throw new ThunderTUSException("File doesn't exist."); 50 | } 51 | return filesize($this->uploadDir . $name); 52 | } 53 | 54 | public function append(string $name, $data): bool 55 | { 56 | // Write the uploaded chunk to the file 57 | return $this->store($name, $data); 58 | } 59 | 60 | public function store(string $name, $data): bool 61 | { 62 | $file = fopen($this->uploadDir . $name, "ab"); 63 | stream_copy_to_stream($data, $file); 64 | fclose($file); 65 | clearstatcache(true, $this->uploadDir . $name); 66 | return true; 67 | } 68 | 69 | public function delete(string $name): bool 70 | { 71 | unlink($this->uploadDir . $name); 72 | return true; 73 | } 74 | 75 | public function completeAndFetch(string $name, string $destinationDirectory, bool $removeAfter = true): bool 76 | { 77 | $destinationDirectory = self::normalizePath($destinationDirectory); 78 | if ($destinationDirectory === $this->uploadDir) { 79 | return true; 80 | } 81 | 82 | if ($removeAfter) { 83 | $this->containerDelete($name); 84 | return rename($this->uploadDir . $name, $destinationDirectory . $name); 85 | } else { 86 | return copy($this->uploadDir . $name, $destinationDirectory . $name); 87 | } 88 | } 89 | 90 | public function completeAndStream(string $name, bool $removeAfter = true) 91 | { 92 | $stream = fopen($this->uploadDir . $name, "r"); 93 | if ($removeAfter) { 94 | $final = fopen("php://temp", "r+"); 95 | stream_copy_to_stream($stream, $final); 96 | fclose($stream); 97 | $this->containerDelete($name); 98 | return unlink($this->uploadDir . $name); 99 | } else { 100 | return $stream; 101 | } 102 | } 103 | 104 | public function complete(string $name): bool 105 | { 106 | $this->containerDelete($name); 107 | return true; 108 | } 109 | 110 | public function supportsCrossCheck(): bool 111 | { 112 | return true; 113 | } 114 | 115 | public function crossCheck(string $name, string $algo, string $expectedHash): bool 116 | { 117 | return base64_encode(hash_file($algo, $this->uploadDir . $name, true)) === $expectedHash; 118 | } 119 | 120 | public function getCrossCheckAlgoritms(): array 121 | { 122 | return hash_algos(); 123 | } 124 | 125 | public function containerCreate(string $name, ?\stdClass $data = null): bool 126 | { 127 | if ($data === null) { 128 | touch($this->uploadDir . $name . static::$containerSuffix); 129 | return true; 130 | } 131 | 132 | $result = file_put_contents($this->uploadDir . $name . static::$containerSuffix, \json_encode($data)); 133 | return $result === false ? false : true; 134 | } 135 | 136 | public function containerUpdate(string $name, \stdClass $data): bool 137 | { 138 | $result = file_put_contents($this->uploadDir . $name . static::$containerSuffix, \json_encode($data)); 139 | return $result === false ? false : true; 140 | } 141 | 142 | public function containerExists(string $name): bool 143 | { 144 | return file_exists($this->uploadDir . $name . static::$containerSuffix); 145 | } 146 | 147 | public function containerFetch(string $name): \stdClass 148 | { 149 | return json_decode(file_get_contents($this->uploadDir . $name . static::$containerSuffix)); 150 | } 151 | 152 | public function containerDelete(string $name): bool 153 | { 154 | unlink($this->uploadDir . $name . static::$containerSuffix); 155 | return true; 156 | } 157 | 158 | } 159 | -------------------------------------------------------------------------------- /src/Store/MongoDB.php: -------------------------------------------------------------------------------- 1 | bucket = $database->selectGridFSBucket(["bucketName" => "tus"]); 20 | } 21 | 22 | public function get(string $name, bool $all = false) 23 | { 24 | if ($all) { 25 | $result = $this->bucket->find(["filename" => $name]); 26 | return $result->toArray(); 27 | } else { 28 | $result = $this->bucket->findOne(["filename" => $name]); 29 | return $result; 30 | } 31 | } 32 | 33 | /** Implement StoreInterface */ 34 | public function exists(string $name): bool 35 | { 36 | return $this->get($name) !== null; 37 | } 38 | 39 | public function create(string $name): bool 40 | { 41 | return $this->get($name) === null; 42 | } 43 | 44 | public function getSize(string $name): int 45 | { 46 | $parts = $this->get($name, true); 47 | return (int)array_sum(array_column($parts, "length")); 48 | } 49 | 50 | public function append(string $name, $data): bool 51 | { 52 | return $this->store($name, $data); 53 | } 54 | 55 | public function store(string $name, $data): bool 56 | { 57 | $this->bucket->uploadFromStream($name, $data); 58 | return true; 59 | } 60 | 61 | public function delete(string $name): bool 62 | { 63 | $parts = $this->get($name, true); 64 | foreach ($parts as $part) { 65 | $this->bucket->delete($part->_id); 66 | } 67 | return true; 68 | } 69 | 70 | public function completeAndFetch(string $name, string $destinationDirectory, bool $removeAfter = true): bool 71 | { 72 | $parts = $this->get($name, true); 73 | if (empty($parts) || $parts === null) { 74 | return false; 75 | } 76 | $parts = array_column($parts, "_id"); 77 | 78 | // Read the gridfs parts file into local storage 10MB at the time 79 | $destinationDirectory = self::normalizePath($destinationDirectory); 80 | $file = fopen($destinationDirectory . $name, 'w'); 81 | foreach ($parts as $part) { 82 | $stream = $this->bucket->openDownloadStream($part); 83 | while (!feof($stream)) { 84 | fwrite($file, fread($stream, self::$partBufferSize)); 85 | } 86 | fclose($stream); 87 | // Delete part from mongodb 88 | if ($removeAfter) { 89 | $this->bucket->delete($part); 90 | } 91 | } 92 | 93 | if ($removeAfter) { 94 | $this->containerDelete($name); 95 | } 96 | 97 | fclose($file); 98 | return true; 99 | } 100 | 101 | public function completeAndStream(string $name, bool $removeAfter = true) 102 | { 103 | $parts = $this->get($name, true); 104 | if (empty($parts) || $parts === null) { 105 | return false; 106 | } 107 | $parts = array_column($parts, "_id"); 108 | 109 | // Read the gridfs parts file a final local tmp stream 110 | $final = fopen("php://temp", "r+"); 111 | foreach ($parts as $part) { 112 | $partStream = $this->bucket->openDownloadStream($part); 113 | stream_copy_to_stream($partStream, $final); 114 | fclose($partStream); 115 | // Delete part from mongodb 116 | if ($removeAfter) { 117 | $this->bucket->delete($part); 118 | } 119 | } 120 | 121 | if ($removeAfter) { 122 | $this->containerDelete($name); 123 | } 124 | 125 | return $final; 126 | } 127 | 128 | public function complete(string $name): bool 129 | { 130 | $parts = $this->get($name, true); 131 | if (empty($parts) || $parts === null) { 132 | return false; 133 | } 134 | $parts = array_column($parts, "_id"); 135 | 136 | // Read the gridfs parts file into a local tmp 10MB at the time 137 | $final = fopen("php://temp", "r+"); 138 | foreach ($parts as $part) { 139 | $stream = $this->bucket->openDownloadStream($part); 140 | while (!feof($stream)) { 141 | stream_copy_to_stream(fread($stream, self::$partBufferSize), $final); 142 | } 143 | fclose($stream); 144 | // Delete part from mongodb 145 | $this->bucket->delete($part); 146 | } 147 | 148 | $this->containerDelete($name); 149 | 150 | // We now have a final tmp with the entrie file upload it to mongodb 151 | rewind($final); 152 | $this->bucket->uploadFromStream($name, $final); 153 | 154 | return true; 155 | 156 | } 157 | 158 | public function containerExists(string $name): bool 159 | { 160 | return $this->exists(self::$containerPrefix . $name); 161 | } 162 | 163 | public function containerCreate(string $name, ?\stdClass $data = null): bool 164 | { 165 | $data = (array)$data; 166 | if ($data === null) { 167 | $data = []; 168 | } 169 | $stream = fopen("php://memory", "r+"); 170 | $this->bucket->uploadFromStream(self::$containerPrefix . $name, $stream, ["metadata" => ["container" => $data]]); 171 | fclose($stream); 172 | return true; 173 | } 174 | 175 | public function containerUpdate(string $name, \stdClass $data): bool 176 | { 177 | $container = $this->get(self::$containerPrefix . $name); 178 | if ($container === null) { 179 | return false; 180 | } 181 | $this->bucket->delete($container->_id); 182 | return $this->containerCreate($name, $data); 183 | } 184 | 185 | public function containerFetch(string $name): \stdClass 186 | { 187 | $container = $this->get(self::$containerPrefix . $name); 188 | return (object)(array)$container->metadata->container; 189 | } 190 | 191 | public function containerDelete(string $name): bool 192 | { 193 | $container = $this->get(self::$containerPrefix . $name); 194 | if ($container === null) { 195 | return true; 196 | } 197 | $this->bucket->delete($container->_id); 198 | return true; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/Store/Redis.php: -------------------------------------------------------------------------------- 1 | client = $client; 20 | } 21 | 22 | public function setTUSKeysPrefix(string $prefix): void 23 | { 24 | static::$prefix = $prefix . ":"; 25 | static::$containerPrefix = $prefix . "container:"; 26 | } 27 | 28 | public function setUploadTTL(int $ttlSeconds): void 29 | { 30 | static::$tusExpire = $ttlSeconds; 31 | } 32 | 33 | public function get(string $name): ?string 34 | { 35 | return $this->client->get(static::$prefix . $name); 36 | } 37 | 38 | /** Implement StoreInterface */ 39 | public function exists(string $name): bool 40 | { 41 | return $this->client->exists(static::$prefix . $name) === 1; 42 | } 43 | 44 | public function create(string $name): bool 45 | { 46 | return $this->client->setex(static::$prefix . $name, self::$tusExpire, "") == "OK"; 47 | } 48 | 49 | public function getSize(string $name): int 50 | { 51 | //return ((int)$this->client->bitcount(static::$prefix . $name))/8; 52 | return (int)$this->client->strlen(static::$prefix . $name); 53 | } 54 | 55 | public function append(string $name, $data): bool 56 | { 57 | $result = $this->client->append(static::$prefix . $name, stream_get_contents($data)); 58 | return $result; 59 | } 60 | 61 | public function store(string $name, $data): bool 62 | { 63 | return $this->client->setex(static::$prefix . $name, self::$tusExpire, stream_get_contents($data)) == "OK"; 64 | } 65 | 66 | public function delete(string $name): bool 67 | { 68 | return $this->client->del([self::$prefix . $name]); 69 | } 70 | 71 | public function completeAndFetch(string $name, string $destinationDirectory, bool $removeAfter = true): bool 72 | { 73 | $data = $this->get($name); 74 | if ($data === null) { 75 | return false; 76 | } 77 | 78 | $destinationDirectory = self::normalizePath($destinationDirectory); 79 | $result = file_put_contents($destinationDirectory . $name, $data); 80 | if ($result === false) { 81 | return false; 82 | } 83 | 84 | if ($removeAfter) { 85 | $this->delete($name); 86 | $this->containerDelete($name); 87 | } 88 | return true; 89 | } 90 | 91 | public function completeAndStream(string $name, bool $removeAfter = true) 92 | { 93 | $data = $this->get($name); 94 | if ($data === null) { 95 | return false; 96 | } 97 | 98 | $final = fopen("php://temp", "r+"); 99 | fwrite($stream, $string); 100 | rewind($stream); 101 | 102 | if ($removeAfter) { 103 | $this->delete($name); 104 | $this->containerDelete($name); 105 | } 106 | 107 | return $stream; 108 | } 109 | 110 | public function complete(string $name): bool 111 | { 112 | $this->containerDelete($name); 113 | return true; 114 | } 115 | 116 | public function containerExists(string $name): bool 117 | { 118 | return $this->client->exists(static::$containerPrefix . $name) == 1; 119 | } 120 | 121 | public function containerCreate(string $name, ?\stdClass $data = null): bool 122 | { 123 | return $this->client->setex(static::$containerPrefix . $name, self::$tusExpire, \json_encode($data)) == "OK"; 124 | } 125 | 126 | public function containerUpdate(string $name, \stdClass $data): bool 127 | { 128 | return $this->containerCreate($name, $data); 129 | } 130 | 131 | public function containerFetch(string $name): \stdClass 132 | { 133 | return \json_decode($this->client->get(static::$containerPrefix . $name)); 134 | } 135 | 136 | public function containerDelete(string $name): bool 137 | { 138 | return $this->client->del([self::$containerPrefix . $name]); 139 | } 140 | 141 | } 142 | -------------------------------------------------------------------------------- /src/Store/S3.php: -------------------------------------------------------------------------------- 1 | client = $client; 19 | $this->bucket = $bucket; 20 | $this->prefix = $prefix; 21 | } 22 | 23 | /** Implement StoreInterface */ 24 | public function exists(string $name): bool 25 | { 26 | // In S3 assume that if a container exists, the file exists as well 27 | return $this->containerExists($name); 28 | } 29 | 30 | public function create(string $name): bool 31 | { 32 | $response = $this->client->createMultipartUpload([ 33 | "Bucket" => $this->bucket, 34 | "Key" => $this->prefix . "/" . $name, 35 | "ContentType" => null 36 | ]); 37 | 38 | // Save the uploadId into our container so we can continue the upload later 39 | $container = $this->containerFetch($name); 40 | $container->uploadid = $response["UploadId"]; 41 | $this->containerUpdate($name, $container); 42 | 43 | return true; 44 | } 45 | 46 | public function getSize(string $name): int 47 | { 48 | $container = $this->containerFetch($name); 49 | if (!isset($container->uploadid)) { 50 | return 0; 51 | } 52 | 53 | $result = $this->client->listParts([ 54 | "Bucket" => $this->bucket, 55 | "Key" => $this->prefix . "/" . $name, 56 | "UploadId" => $container->uploadid 57 | ]); 58 | $result = $result->toArray(); 59 | if (!isset($result["Parts"])) { 60 | return 0; // This is a new file 61 | } 62 | 63 | $size = (int)array_sum(array_column($result["Parts"], "Size")); 64 | return $size; 65 | } 66 | 67 | public function append(string $name, $data): bool 68 | { 69 | // So we know what part number we're uploading and uploadid 70 | $container = $this->containerFetch($name); 71 | if (!isset($container->uploadid)) { 72 | return false; 73 | } 74 | 75 | if (!isset($container->parts)) { 76 | $partNumber = 1; 77 | $parts = []; 78 | // Find the mimetype of the file by reading the begining of the first part 79 | $container->mimetype = $this->mimetypeFromStream($data); 80 | } else { 81 | $parts = (array)$container->parts; 82 | $lastPart = end($parts)->PartNumber; 83 | $partNumber = ($lastPart + 1); 84 | } 85 | 86 | // Upload the part 87 | try { 88 | $result = $this->client->uploadPart([ 89 | "Bucket" => $this->bucket, 90 | "Key" => $this->prefix . "/" . $name, 91 | "UploadId" => $container->uploadid, 92 | "PartNumber" => $partNumber, 93 | "Body" => $data 94 | ]); 95 | } catch (S3Exception $e) { 96 | return false; 97 | } 98 | 99 | // Update the container with the new part info 100 | $partMeta = [ 101 | "PartNumber" => $partNumber, 102 | "ETag" => $result["ETag"], 103 | ]; 104 | $parts[] = $partMeta; 105 | $container->parts = $parts; 106 | $this->containerUpdate($name, $container); 107 | 108 | return true; 109 | } 110 | 111 | public function store(string $name, $data): bool 112 | { 113 | // Store the entire file in one request, used for single part uploads 114 | try { 115 | $this->client->putObject([ 116 | "Bucket" => $this->bucket, 117 | "Key" => $this->prefix . "/" . $name, 118 | "ContentType" => $this->mimetypeFromStream($data), 119 | "Body" => $data, 120 | ]); 121 | } catch (S3Exception $e) { 122 | return false; 123 | } 124 | return true; 125 | } 126 | 127 | protected function mimetypeFromStream($stream): string 128 | { 129 | $finfo = new \finfo(FILEINFO_MIME); 130 | $mimetype = $finfo->buffer(fread($stream, 100)); 131 | $mimetype = explode(";", $mimetype)[0]; 132 | rewind($stream); 133 | return $mimetype; 134 | } 135 | 136 | public function delete(string $name): bool 137 | { 138 | $container = $this->containerFetch($name); 139 | if (!isset($container->uploadid)) { 140 | return false; 141 | } 142 | 143 | try { 144 | $this->client->abortMultipartUpload([ 145 | "Bucket" => $this->bucket, 146 | "Key" => $this->prefix . "/" . $name, 147 | "UploadId" => $container->uploadid 148 | ]); 149 | } catch (S3Exception $e) { 150 | // Just ignore the error, file is probably already gone 151 | } 152 | 153 | return true; 154 | } 155 | 156 | public function completeAndFetch(string $name, string $destinationDirectory, bool $removeAfter = true): bool 157 | { 158 | if (!$this->complete($name)) { 159 | return false; 160 | } 161 | 162 | // Download the completed file 163 | $filePath = self::normalizePath($destinationDirectory) . $name; 164 | $this->client->getObject([ 165 | "Bucket" => $this->bucket, 166 | "Key" => $this->prefix . "/" . $name, 167 | "SaveAs" => $filePath 168 | ]); 169 | 170 | // Remove the S3 file if requested 171 | if ($removeAfter) { 172 | $this->client->deleteObject([ 173 | "Bucket" => $this->bucket, 174 | "Key" => $this->prefix . "/" . $name, 175 | ]); 176 | $this->containerDelete($name); 177 | } 178 | 179 | return true; 180 | } 181 | 182 | public function completeAndStream(string $name, bool $removeAfter = true) 183 | { 184 | if (!$this->complete($name)) { 185 | return false; 186 | } 187 | 188 | // Download the completed file 189 | $result = $this->client->getObject([ 190 | "Bucket" => $this->bucket, 191 | "Key" => $this->prefix . "/" . $name, 192 | ]); 193 | 194 | $body = $result["Body"]; 195 | $final = fopen("php://temp", "r+"); 196 | if (is_resource($body)) { 197 | stream_copy_to_stream($body, $final); 198 | fclose($body); 199 | } else { 200 | fwrite($final, $body); 201 | } 202 | rewind($final); 203 | 204 | if ($removeAfter) { 205 | $this->client->deleteObject([ 206 | "Bucket" => $this->bucket, 207 | "Key" => $this->prefix . "/" . $name, 208 | ]); 209 | $this->containerDelete($name); 210 | } 211 | 212 | return $final; 213 | } 214 | 215 | public function complete(string $name): bool 216 | { 217 | try { 218 | $container = $this->containerFetch($name); 219 | } catch (S3Exception $e) { 220 | // Single file uploads doesn't have container 221 | // no further file processing is required! 222 | return true; 223 | } 224 | if (!isset($container->uploadid) || !isset($container->parts)) { 225 | return false; 226 | } 227 | $parts = json_decode(json_encode($container->parts), true); 228 | 229 | // Merge all the parts of the file by completing the upload 230 | try { 231 | 232 | $result = $this->client->completeMultipartUpload([ 233 | "Bucket" => $this->bucket, 234 | "Key" => $this->prefix . "/" . $name, 235 | "UploadId" => $container->uploadid, 236 | "MultipartUpload" => [ 237 | "Parts" => $parts, 238 | ], 239 | 240 | ]); 241 | 242 | // Update the mimetype of the file. This value was stored in the container when the first 243 | // part was uploaded 244 | if (isset($container->mimetype)) { 245 | $result = $this->client->copyObject([ 246 | "Bucket" => $this->bucket, 247 | "CopySource" => $this->bucket . "/" . $this->prefix . "/" . $name, 248 | "Key" => $this->prefix . "/" . $name, 249 | "MetadataDirective" => "REPLACE", 250 | "ContentType" => $container->mimetype 251 | ]); 252 | } 253 | 254 | } catch (\Exception $e) { 255 | // In case of error just discard all the parts and the container 256 | $this->delete($name); 257 | $this->containerDelete($name); 258 | return false; 259 | } 260 | 261 | $this->containerDelete($name); 262 | return true; 263 | } 264 | 265 | public function containerExists(string $name): bool 266 | { 267 | try { 268 | $result = $this->client->headObject([ 269 | "Bucket" => $this->bucket, 270 | "Key" => $this->prefix . "/" . $this->containerPrefix . $name 271 | ]); 272 | } catch (S3Exception $e) { 273 | if ($e->getStatusCode() === 404) { 274 | return false; 275 | } 276 | } 277 | return true; 278 | } 279 | 280 | public function containerCreate(string $name, ?\stdClass $data = null): bool 281 | { 282 | $data = (array)$data; 283 | if ($data === null) { 284 | $data = []; 285 | } 286 | 287 | $this->client->putObject([ 288 | "Bucket" => $this->bucket, 289 | "Key" => $this->prefix . "/" . $this->containerPrefix . $name, 290 | "Body" => json_encode($data) 291 | ]); 292 | 293 | return true; 294 | } 295 | 296 | public function containerUpdate(string $name, \stdClass $data): bool 297 | { 298 | return $this->containerCreate($name, $data); 299 | } 300 | 301 | public function containerFetch(string $name): \stdClass 302 | { 303 | $result = $this->client->getObject([ 304 | "Bucket" => $this->bucket, 305 | "Key" => $this->prefix . "/" . $this->containerPrefix . $name, 306 | ]); 307 | $result = (string)$result["Body"]; 308 | return json_decode($result); 309 | } 310 | 311 | public function containerDelete(string $name): bool 312 | { 313 | $this->client->deleteObject([ 314 | "Bucket" => $this->bucket, 315 | "Key" => $this->prefix . "/" . $this->containerPrefix . $name, 316 | ]); 317 | return true; 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /src/Store/StorageBackend.php: -------------------------------------------------------------------------------- 1 |