├── src ├── Exception │ ├── TusException.php │ ├── FileException.php │ ├── ConnectionException.php │ └── OutOfRangeException.php ├── Middleware │ ├── TusMiddleware.php │ ├── GlobalHeaders.php │ ├── Cors.php │ └── Middleware.php ├── Cache │ ├── CacheFactory.php │ ├── AbstractCache.php │ ├── Cacheable.php │ ├── ApcuStore.php │ ├── RedisStore.php │ └── FileStore.php ├── Config │ ├── client.php │ └── server.php ├── Events │ ├── UploadMerged.php │ ├── UploadCreated.php │ ├── UploadComplete.php │ ├── UploadProgress.php │ └── TusEvent.php ├── Config.php ├── Commands │ └── ExpirationCommand.php ├── Tus │ ├── AbstractTus.php │ ├── Client.php │ └── Server.php ├── Response.php ├── Request.php └── File.php ├── bin └── tus ├── SECURITY.md ├── .php-cs-fixer.php ├── LICENSE ├── composer.json └── README.md /src/Exception/TusException.php: -------------------------------------------------------------------------------- 1 | add(new ExpirationCommand()); 16 | 17 | $app->run(); 18 | -------------------------------------------------------------------------------- /src/Middleware/TusMiddleware.php: -------------------------------------------------------------------------------- 1 | in(['src', 'tests']); 4 | 5 | return (new PhpCsFixer\Config()) 6 | ->setRules([ 7 | '@PSR12' => true, 8 | 'not_operator_with_space' => true, 9 | 'single_quote' => true, 10 | 'binary_operator_spaces' => ['operators' => ['=' => 'align_single_space']], 11 | 'native_function_invocation' => ['include' => ['@compiler_optimized']], 12 | ]) 13 | ->setRiskyAllowed(true) 14 | ->setFinder($finder); 15 | -------------------------------------------------------------------------------- /src/Middleware/GlobalHeaders.php: -------------------------------------------------------------------------------- 1 | 'nosniff', 18 | 'Tus-Resumable' => Server::TUS_PROTOCOL_VERSION, 19 | ]; 20 | 21 | $response->setHeaders($headers); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Cache/CacheFactory.php: -------------------------------------------------------------------------------- 1 | [ 9 | 'host' => getenv('REDIS_HOST') !== false ? getenv('REDIS_HOST') : '127.0.0.1', 10 | 'port' => getenv('REDIS_PORT') !== false ? getenv('REDIS_PORT') : '6379', 11 | 'database' => getenv('REDIS_DB') !== false ? getenv('REDIS_DB') : 0, 12 | ], 13 | 14 | /** 15 | * File cache configs. 16 | */ 17 | 'file' => [ 18 | 'dir' => \TusPhp\Config::getCacheHome() . DIRECTORY_SEPARATOR . '.cache' . DIRECTORY_SEPARATOR, 19 | 'name' => 'tus_php.client.cache', 20 | ], 21 | ]; 22 | -------------------------------------------------------------------------------- /src/Config/server.php: -------------------------------------------------------------------------------- 1 | [ 9 | 'host' => getenv('REDIS_HOST') !== false ? getenv('REDIS_HOST') : '127.0.0.1', 10 | 'port' => getenv('REDIS_PORT') !== false ? getenv('REDIS_PORT') : '6379', 11 | 'database' => getenv('REDIS_DB') !== false ? getenv('REDIS_DB') : 0, 12 | ], 13 | 14 | /** 15 | * File cache configs. 16 | */ 17 | 'file' => [ 18 | 'dir' => \TusPhp\Config::getCacheHome() . DIRECTORY_SEPARATOR . '.cache' . DIRECTORY_SEPARATOR, 19 | 'name' => 'tus_php.server.cache', 20 | ], 21 | ]; 22 | -------------------------------------------------------------------------------- /src/Events/UploadMerged.php: -------------------------------------------------------------------------------- 1 | file = $file; 24 | $this->request = $request; 25 | $this->response = $response; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Events/UploadCreated.php: -------------------------------------------------------------------------------- 1 | file = $file; 24 | $this->request = $request; 25 | $this->response = $response; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Events/UploadComplete.php: -------------------------------------------------------------------------------- 1 | file = $file; 24 | $this->request = $request; 25 | $this->response = $response; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Events/UploadProgress.php: -------------------------------------------------------------------------------- 1 | file = $file; 24 | $this->request = $request; 25 | $this->response = $response; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Events/TusEvent.php: -------------------------------------------------------------------------------- 1 | file; 29 | } 30 | 31 | /** 32 | * Get request. 33 | * 34 | * @return Request 35 | */ 36 | public function getRequest(): Request 37 | { 38 | return $this->request; 39 | } 40 | 41 | /** 42 | * Get response. 43 | * 44 | * @return Response 45 | */ 46 | public function getResponse(): Response 47 | { 48 | return $this->response; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Middleware/Cors.php: -------------------------------------------------------------------------------- 1 | setHeaders([ 19 | 'Access-Control-Allow-Origin' => $request->header('Origin'), 20 | 'Access-Control-Allow-Methods' => implode(',', $request->allowedHttpVerbs()), 21 | 'Access-Control-Allow-Headers' => 'Origin, X-Requested-With, Content-Type, Content-Length, Upload-Key, Upload-Checksum, Upload-Length, Upload-Offset, Tus-Version, Tus-Resumable, Upload-Metadata', 22 | 'Access-Control-Expose-Headers' => 'Upload-Key, Upload-Checksum, Upload-Length, Upload-Offset, Upload-Metadata, Tus-Version, Tus-Resumable, Tus-Extension, Location', 23 | 'Access-Control-Max-Age' => self::HEADER_ACCESS_CONTROL_MAX_AGE, 24 | ]); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Ankit Pokhrel 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 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ankitpokhrel/tus-php", 3 | "description": "A pure PHP server and client for the tus resumable upload protocol v1.0.0", 4 | "type": "library", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Ankit Pokhrel", 9 | "email": "oss@ankit.pl" 10 | } 11 | ], 12 | "require": { 13 | "php": "^8.1", 14 | "ext-json": "*", 15 | "guzzlehttp/guzzle": "^7.2", 16 | "nesbot/carbon": "^2.67 || ^3.0", 17 | "predis/predis": "^2.0.3", 18 | "ramsey/uuid": "^4.0", 19 | "symfony/console": "^6.0 || ^7.0", 20 | "symfony/event-dispatcher": "^6.0 || ^7.0", 21 | "symfony/http-foundation": "^6.0 || ^7.0", 22 | "symfony/mime": "^6.0 || ^7.0" 23 | }, 24 | "require-dev": { 25 | "ext-pcntl": "*", 26 | "friendsofphp/php-cs-fixer": "^3.0", 27 | "mockery/mockery": "^1.4.2", 28 | "phpunit/phpunit": "^10.5" 29 | }, 30 | "autoload": { 31 | "psr-4": { 32 | "TusPhp\\": "src/" 33 | } 34 | }, 35 | "autoload-dev": { 36 | "psr-4": { 37 | "TusPhp\\Test\\": "tests/" 38 | } 39 | }, 40 | "config": { 41 | "optimize-autoloader": true, 42 | "sort-packages": true 43 | }, 44 | "scripts": { 45 | "test": "xdebug disable && vendor/bin/phpunit", 46 | "test-coverage": "xdebug enable && vendor/bin/phpunit --coverage-html ./coverage" 47 | }, 48 | "bin": [ 49 | "bin/tus" 50 | ], 51 | "extra": { 52 | "branch-alias": { 53 | "dev-main": "2.3-dev" 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Middleware/Middleware.php: -------------------------------------------------------------------------------- 1 | globalMiddleware = [ 16 | GlobalHeaders::class => new GlobalHeaders(), 17 | Cors::class => new Cors(), 18 | ]; 19 | } 20 | 21 | /** 22 | * Get registered middleware. 23 | * 24 | * @return array 25 | */ 26 | public function list(): array 27 | { 28 | return $this->globalMiddleware; 29 | } 30 | 31 | /** 32 | * Set middleware. 33 | * 34 | * @param array $middleware 35 | * 36 | * @return Middleware 37 | */ 38 | public function add(...$middleware): self 39 | { 40 | foreach ($middleware as $m) { 41 | if ($m instanceof TusMiddleware) { 42 | $this->globalMiddleware[\get_class($m)] = $m; 43 | } elseif (\is_string($m)) { 44 | $this->globalMiddleware[$m] = new $m(); 45 | } 46 | } 47 | 48 | return $this; 49 | } 50 | 51 | /** 52 | * Skip middleware. 53 | * 54 | * @param array $middleware 55 | * 56 | * @return Middleware 57 | */ 58 | public function skip(...$middleware): self 59 | { 60 | foreach ($middleware as $m) { 61 | unset($this->globalMiddleware[$m]); 62 | } 63 | 64 | return $this; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Cache/AbstractCache.php: -------------------------------------------------------------------------------- 1 | ttl = $secs; 23 | 24 | return $this; 25 | } 26 | 27 | /** 28 | * {@inheritDoc} 29 | */ 30 | public function getTtl(): int 31 | { 32 | return $this->ttl; 33 | } 34 | 35 | /** 36 | * Set cache prefix. 37 | * 38 | * @param string $prefix 39 | * 40 | * @return Cacheable 41 | */ 42 | public function setPrefix(string $prefix): Cacheable 43 | { 44 | $this->prefix = $prefix; 45 | 46 | return $this; 47 | } 48 | 49 | /** 50 | * Get cache prefix. 51 | * 52 | * @return string 53 | */ 54 | public function getPrefix(): string 55 | { 56 | return $this->prefix; 57 | } 58 | 59 | /** 60 | * Delete all keys. 61 | * 62 | * @param array $keys 63 | * 64 | * @return bool 65 | */ 66 | public function deleteAll(array $keys): bool 67 | { 68 | if (empty($keys)) { 69 | return false; 70 | } 71 | 72 | $status = true; 73 | 74 | foreach ($keys as $key) { 75 | $status = $status && $this->delete($key); 76 | } 77 | 78 | return $status; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Cache/Cacheable.php: -------------------------------------------------------------------------------- 1 | getActualCacheKey($key)); 16 | 17 | if ( ! $contents) { 18 | return null; 19 | } 20 | 21 | if ($withExpired) { 22 | return $contents ?: null; 23 | } 24 | 25 | $isExpired = Carbon::parse($contents['expires_at'])->lt(Carbon::now()); 26 | 27 | return $isExpired ? null : $contents; 28 | } 29 | 30 | /** 31 | * {@inheritDoc} 32 | */ 33 | public function set(string $key, $value) 34 | { 35 | $contents = $this->get($key) ?? []; 36 | 37 | if (\is_array($value)) { 38 | $contents = $value + $contents; 39 | } else { 40 | $contents[] = $value; 41 | } 42 | 43 | return apcu_store($this->getActualCacheKey($key), $contents, $this->getTtl()); 44 | } 45 | 46 | /** 47 | * {@inheritDoc} 48 | */ 49 | public function delete(string $key): bool 50 | { 51 | return true === apcu_delete($this->getActualCacheKey($key)); 52 | } 53 | 54 | /** 55 | * {@inheritDoc} 56 | */ 57 | public function keys(): array 58 | { 59 | $iterator = new APCUIterator('/^' . preg_quote($this->getPrefix()) . '.*$/', APC_ITER_KEY); 60 | 61 | return array_column(iterator_to_array($iterator, false), 'key'); 62 | } 63 | 64 | /** 65 | * Get actual cache key with prefix. 66 | * 67 | * @param string $key 68 | * 69 | * @return string 70 | */ 71 | protected function getActualCacheKey(string $key): string 72 | { 73 | $prefix = $this->getPrefix(); 74 | 75 | if (false === strpos($key, $prefix)) { 76 | $key = $prefix . $key; 77 | } 78 | 79 | return $key; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | setName('tus:expired') 25 | ->setDescription('Remove expired uploads.') 26 | ->setHelp('Deletes all expired uploads to free server resources. Values can be redis, file or apcu. Defaults to file.') 27 | ->addArgument( 28 | 'cache-adapter', 29 | InputArgument::OPTIONAL, 30 | 'Cache adapter to use: redis, file or apcu', 31 | 'file' 32 | ) 33 | ->addOption( 34 | 'config', 35 | 'c', 36 | InputArgument::OPTIONAL, 37 | 'File to get config parameters from.' 38 | ); 39 | } 40 | 41 | /** 42 | * {@inheritDoc} 43 | */ 44 | protected function execute(InputInterface $input, OutputInterface $output): int 45 | { 46 | $output->writeln([ 47 | 'Cleaning server resources', 48 | '=========================', 49 | '', 50 | ]); 51 | 52 | $config = $input->getOption('config'); 53 | 54 | if ( ! empty($config)) { 55 | Config::set($config); 56 | } 57 | 58 | $cacheAdapter = $input->getArgument('cache-adapter') ?? 'file'; 59 | 60 | $this->server = new TusServer(CacheFactory::make($cacheAdapter)); 61 | 62 | $deleted = $this->server->handleExpiration(); 63 | 64 | if (empty($deleted)) { 65 | $output->writeln('Nothing to delete.'); 66 | } else { 67 | foreach ($deleted as $key => $item) { 68 | $output->writeln('' . ($key + 1) . ". Deleted {$item['name']} from " . \dirname($item['file_path']) . ''); 69 | } 70 | } 71 | 72 | $output->writeln(''); 73 | 74 | return Command::SUCCESS; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Cache/RedisStore.php: -------------------------------------------------------------------------------- 1 | redis = new RedisClient($options); 24 | } 25 | 26 | /** 27 | * Get redis. 28 | * 29 | * @return RedisClient 30 | */ 31 | public function getRedis(): RedisClient 32 | { 33 | return $this->redis; 34 | } 35 | 36 | /** 37 | * {@inheritDoc} 38 | */ 39 | public function get(string $key, bool $withExpired = false) 40 | { 41 | $prefix = $this->getPrefix(); 42 | 43 | if (false === strpos($key, $prefix)) { 44 | $key = $prefix . $key; 45 | } 46 | 47 | $contents = $this->redis->get($key); 48 | if (null !== $contents) { 49 | $contents = json_decode($contents, true); 50 | } 51 | 52 | if ($withExpired) { 53 | return $contents; 54 | } 55 | 56 | if ( ! $contents) { 57 | return null; 58 | } 59 | 60 | $isExpired = Carbon::parse($contents['expires_at'])->lt(Carbon::now()); 61 | 62 | return $isExpired ? null : $contents; 63 | } 64 | 65 | /** 66 | * {@inheritDoc} 67 | */ 68 | public function set(string $key, $value) 69 | { 70 | $contents = $this->get($key) ?? []; 71 | 72 | if (\is_array($value)) { 73 | $contents = $value + $contents; 74 | } else { 75 | $contents[] = $value; 76 | } 77 | 78 | $status = $this->redis->set($this->getPrefix() . $key, json_encode($contents)); 79 | 80 | return 'OK' === $status->getPayload(); 81 | } 82 | 83 | /** 84 | * {@inheritDoc} 85 | */ 86 | public function delete(string $key): bool 87 | { 88 | $prefix = $this->getPrefix(); 89 | 90 | if (false === strpos($key, $prefix)) { 91 | $key = $prefix . $key; 92 | } 93 | 94 | return $this->redis->del([$key]) > 0; 95 | } 96 | 97 | /** 98 | * {@inheritDoc} 99 | */ 100 | public function keys(): array 101 | { 102 | return $this->redis->keys($this->getPrefix() . '*'); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Tus/AbstractTus.php: -------------------------------------------------------------------------------- 1 | cache = CacheFactory::make($cache); 52 | } elseif ($cache instanceof Cacheable) { 53 | $this->cache = $cache; 54 | } 55 | 56 | $prefix = 'tus:' . strtolower((new \ReflectionClass(static::class))->getShortName()) . ':'; 57 | 58 | $this->cache->setPrefix($prefix); 59 | 60 | return $this; 61 | } 62 | 63 | /** 64 | * Get cache. 65 | * 66 | * @return Cacheable 67 | */ 68 | public function getCache(): Cacheable 69 | { 70 | return $this->cache; 71 | } 72 | 73 | /** 74 | * Set API path. 75 | * 76 | * @param string $path 77 | * 78 | * @return self 79 | */ 80 | public function setApiPath(string $path): self 81 | { 82 | $this->apiPath = $path; 83 | 84 | return $this; 85 | } 86 | 87 | /** 88 | * Get API path. 89 | * 90 | * @return string 91 | */ 92 | public function getApiPath(): string 93 | { 94 | return $this->apiPath; 95 | } 96 | 97 | /** 98 | * Set and get event dispatcher. 99 | * 100 | * @return EventDispatcherInterface 101 | */ 102 | public function event(): EventDispatcherInterface 103 | { 104 | if ( ! $this->dispatcher) { 105 | $this->dispatcher = new EventDispatcher(); 106 | } 107 | 108 | return $this->dispatcher; 109 | } 110 | 111 | /** 112 | * Set event dispatcher. 113 | * 114 | * @param EventDispatcherInterface $dispatcher 115 | * 116 | * @return self 117 | */ 118 | public function setDispatcher(EventDispatcherInterface $dispatcher): self 119 | { 120 | $this->dispatcher = $dispatcher; 121 | 122 | return $this; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Response.php: -------------------------------------------------------------------------------- 1 | createOnly = $state; 31 | 32 | return $this; 33 | } 34 | 35 | /** 36 | * Set headers. 37 | * 38 | * @param array $headers 39 | * 40 | * @return Response 41 | */ 42 | public function setHeaders(array $headers): self 43 | { 44 | $this->headers += $headers; 45 | 46 | return $this; 47 | } 48 | 49 | /** 50 | * Replace headers. 51 | * 52 | * @param array $headers 53 | * 54 | * @return Response 55 | */ 56 | public function replaceHeaders(array $headers): self 57 | { 58 | $this->headers = $headers; 59 | 60 | return $this; 61 | } 62 | 63 | /** 64 | * Get global headers. 65 | * 66 | * @return array 67 | */ 68 | public function getHeaders(): array 69 | { 70 | return $this->headers; 71 | } 72 | 73 | /** 74 | * Get create only. 75 | * 76 | * @return bool 77 | */ 78 | public function getCreateOnly(): bool 79 | { 80 | return $this->createOnly; 81 | } 82 | 83 | /** 84 | * Create and send a response. 85 | * 86 | * @param mixed $content Response data. 87 | * @param int $status Http status code. 88 | * @param array $headers Headers. 89 | * 90 | * @return HttpResponse 91 | */ 92 | public function send($content, int $status = HttpResponse::HTTP_OK, array $headers = []): HttpResponse 93 | { 94 | $headers = array_merge($this->headers, $headers); 95 | 96 | if (\is_array($content)) { 97 | $content = json_encode($content); 98 | } 99 | 100 | $response = new HttpResponse($content, $status, $headers); 101 | 102 | return $this->createOnly ? $response : $response->send(); 103 | } 104 | 105 | /** 106 | * Create a new file download response. 107 | * 108 | * @param \SplFileInfo|string $file 109 | * @param string|null $name 110 | * @param array $headers 111 | * @param string|null $disposition 112 | * 113 | * @return BinaryFileResponse 114 | */ 115 | public function download( 116 | $file, 117 | string $name = null, 118 | array $headers = [], 119 | string $disposition = ResponseHeaderBag::DISPOSITION_ATTACHMENT 120 | ): BinaryFileResponse { 121 | $response = new BinaryFileResponse($file, HttpResponse::HTTP_OK, $headers, true, $disposition); 122 | 123 | $response->prepare(HttpRequest::createFromGlobals()); 124 | 125 | if ($name !== null) { 126 | $response = $response->setContentDisposition( 127 | $disposition, 128 | $name 129 | ); 130 | } 131 | 132 | return $this->createOnly ? $response : $response->send(); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Request.php: -------------------------------------------------------------------------------- 1 | request) { 19 | $this->request = HttpRequest::createFromGlobals(); 20 | } 21 | } 22 | 23 | /** 24 | * Get http method from current request. 25 | * 26 | * @return string 27 | */ 28 | public function method(): string 29 | { 30 | return $this->request->getMethod(); 31 | } 32 | 33 | /** 34 | * Get the current path info for the request. 35 | * 36 | * @return string 37 | */ 38 | public function path(): string 39 | { 40 | return $this->request->getPathInfo(); 41 | } 42 | 43 | /** 44 | * Get upload key from url. 45 | * 46 | * @return string 47 | */ 48 | public function key(): string 49 | { 50 | return basename($this->path()); 51 | } 52 | 53 | /** 54 | * Supported http requests. 55 | * 56 | * @return array 57 | */ 58 | public function allowedHttpVerbs(): array 59 | { 60 | return [ 61 | HttpRequest::METHOD_GET, 62 | HttpRequest::METHOD_POST, 63 | HttpRequest::METHOD_PATCH, 64 | HttpRequest::METHOD_DELETE, 65 | HttpRequest::METHOD_HEAD, 66 | HttpRequest::METHOD_OPTIONS, 67 | ]; 68 | } 69 | 70 | /** 71 | * Retrieve a header from the request. 72 | * 73 | * @param string $key 74 | * @param string|string[]|null $default 75 | * 76 | * @return string|null 77 | */ 78 | public function header(string $key, $default = null): ?string 79 | { 80 | return $this->request->headers->get($key, $default); 81 | } 82 | 83 | /** 84 | * Get the root URL for the request. 85 | * 86 | * @return string 87 | */ 88 | public function url(): string 89 | { 90 | return rtrim($this->request->getUriForPath('/'), '/'); 91 | } 92 | 93 | /** 94 | * Extract metadata from header. 95 | * 96 | * @param string $key 97 | * @param string $value 98 | * 99 | * @return array 100 | */ 101 | public function extractFromHeader(string $key, string $value): array 102 | { 103 | $meta = $this->header($key); 104 | 105 | if (false !== strpos($meta, $value)) { 106 | $meta = trim(str_replace($value, '', $meta)); 107 | 108 | return explode(' ', $meta) ?? []; 109 | } 110 | 111 | return []; 112 | } 113 | 114 | /** 115 | * Extract base64 encoded filename from header. 116 | * 117 | * @return string 118 | */ 119 | public function extractFileName(): string 120 | { 121 | $name = $this->extractMeta('name') ?: $this->extractMeta('filename'); 122 | 123 | if ( ! $this->isValidFilename($name)) { 124 | return ''; 125 | } 126 | 127 | return $name; 128 | } 129 | 130 | /** 131 | * Extracts the metadata from the request header. 132 | * 133 | * @param string $requestedKey 134 | * 135 | * @return string 136 | */ 137 | public function extractMeta(string $requestedKey): string 138 | { 139 | $uploadMetaData = $this->request->headers->get('Upload-Metadata'); 140 | 141 | if (empty($uploadMetaData)) { 142 | return ''; 143 | } 144 | 145 | $uploadMetaDataChunks = explode(',', $uploadMetaData); 146 | 147 | foreach ($uploadMetaDataChunks as $chunk) { 148 | $pieces = explode(' ', trim($chunk)); 149 | 150 | $key = $pieces[0]; 151 | $value = $pieces[1] ?? ''; 152 | 153 | if ($key === $requestedKey) { 154 | return base64_decode($value); 155 | } 156 | } 157 | 158 | return ''; 159 | } 160 | 161 | /** 162 | * Extracts all meta data from the request header. 163 | * 164 | * @return string[] 165 | */ 166 | public function extractAllMeta(): array 167 | { 168 | $uploadMetaData = $this->request->headers->get('Upload-Metadata'); 169 | 170 | if (empty($uploadMetaData)) { 171 | return []; 172 | } 173 | 174 | $uploadMetaDataChunks = explode(',', $uploadMetaData); 175 | 176 | $result = []; 177 | foreach ($uploadMetaDataChunks as $chunk) { 178 | $pieces = explode(' ', trim($chunk)); 179 | 180 | $key = $pieces[0]; 181 | $value = $pieces[1] ?? ''; 182 | 183 | $result[$key] = base64_decode($value); 184 | } 185 | 186 | return $result; 187 | } 188 | 189 | /** 190 | * Extract partials from header. 191 | * 192 | * @return array 193 | */ 194 | public function extractPartials(): array 195 | { 196 | return $this->extractFromHeader('Upload-Concat', Server::UPLOAD_TYPE_FINAL . ';'); 197 | } 198 | 199 | /** 200 | * Check if this is a partial upload request. 201 | * 202 | * @return bool 203 | */ 204 | public function isPartial(): bool 205 | { 206 | return Server::UPLOAD_TYPE_PARTIAL === $this->header('Upload-Concat'); 207 | } 208 | 209 | /** 210 | * Check if this is a final concatenation request. 211 | * 212 | * @return bool 213 | */ 214 | public function isFinal(): bool 215 | { 216 | return null !== ($header = $this->header('Upload-Concat')) && false !== strpos($header, Server::UPLOAD_TYPE_FINAL . ';'); 217 | } 218 | 219 | /** 220 | * Get request. 221 | * 222 | * @return HttpRequest 223 | */ 224 | public function getRequest(): HttpRequest 225 | { 226 | return $this->request; 227 | } 228 | 229 | /** 230 | * Validate file name. 231 | * 232 | * @param string $filename 233 | * 234 | * @return bool 235 | */ 236 | protected function isValidFilename(string $filename): bool 237 | { 238 | $forbidden = ['../', '"', "'", '&', '/', '\\', '?', '#', ':']; 239 | 240 | foreach ($forbidden as $char) { 241 | if (false !== strpos($filename, $char)) { 242 | return false; 243 | } 244 | } 245 | 246 | return true; 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/Cache/FileStore.php: -------------------------------------------------------------------------------- 1 | setCacheDir($cacheDir); 32 | $this->setCacheFile($cacheFile); 33 | } 34 | 35 | /** 36 | * Set cache dir. 37 | * 38 | * @param string $path 39 | * 40 | * @return self 41 | */ 42 | public function setCacheDir(string $path): self 43 | { 44 | $this->cacheDir = $path; 45 | 46 | return $this; 47 | } 48 | 49 | /** 50 | * Get cache dir. 51 | * 52 | * @return string 53 | */ 54 | public function getCacheDir(): string 55 | { 56 | return $this->cacheDir; 57 | } 58 | 59 | /** 60 | * Set cache file. 61 | * 62 | * @param string $file 63 | * 64 | * @return self 65 | */ 66 | public function setCacheFile(string $file): self 67 | { 68 | $this->cacheFile = $file; 69 | 70 | return $this; 71 | } 72 | 73 | /** 74 | * Get cache file. 75 | * 76 | * @return string 77 | */ 78 | public function getCacheFile(): string 79 | { 80 | return $this->cacheDir . $this->cacheFile; 81 | } 82 | 83 | /** 84 | * Create cache dir if not exists. 85 | * 86 | * @return void 87 | */ 88 | protected function createCacheDir() 89 | { 90 | if ( ! file_exists($this->cacheDir)) { 91 | mkdir($this->cacheDir); 92 | } 93 | } 94 | 95 | /** 96 | * Create a cache file. 97 | * 98 | * @return void 99 | */ 100 | protected function createCacheFile() 101 | { 102 | $this->createCacheDir(); 103 | 104 | $cacheFilePath = $this->getCacheFile(); 105 | 106 | if ( ! file_exists($cacheFilePath)) { 107 | touch($cacheFilePath); 108 | } 109 | } 110 | 111 | /** 112 | * {@inheritDoc} 113 | */ 114 | public function get(string $key, bool $withExpired = false) 115 | { 116 | $key = $this->getActualCacheKey($key); 117 | $contents = $this->getCacheContents(); 118 | 119 | if (empty($contents[$key])) { 120 | return null; 121 | } 122 | 123 | if ($withExpired) { 124 | return $contents[$key]; 125 | } 126 | 127 | return $this->isValid($key) ? $contents[$key] : null; 128 | } 129 | 130 | /** 131 | * @param string $path 132 | * @param int $type 133 | * @param callable|null $cb 134 | * 135 | * @return mixed 136 | */ 137 | protected function lock(string $path, int $type = LOCK_SH, callable $cb = null, $fopenType = FILE::READ_BINARY) 138 | { 139 | $out = false; 140 | $handle = @fopen($path, $fopenType); 141 | 142 | if (false === $handle) { 143 | return $out; 144 | } 145 | 146 | try { 147 | if (flock($handle, $type)) { 148 | clearstatcache(true, $path); 149 | 150 | $out = $cb($handle); 151 | } 152 | } finally { 153 | flock($handle, LOCK_UN); 154 | fclose($handle); 155 | } 156 | 157 | return $out; 158 | } 159 | 160 | /** 161 | * Get contents of a file with shared access. 162 | * 163 | * @param string $path 164 | * 165 | * @return string 166 | */ 167 | public function sharedGet(string $path): string 168 | { 169 | return $this->lock($path, LOCK_SH, function ($handle) use ($path) { 170 | $fstat = fstat($handle); 171 | $size = $fstat ? $fstat['size'] : 1; 172 | $contents = fread($handle, $size ?: 1); 173 | 174 | if (false === $contents) { 175 | return ''; 176 | } 177 | 178 | return $contents; 179 | }); 180 | } 181 | 182 | /** 183 | * Write the contents of a file with exclusive lock. 184 | * 185 | * @param string $path 186 | * @param string $contents 187 | * @param int $lock 188 | * 189 | * @return int|false 190 | */ 191 | public function put(string $path, string $contents, int $lock = LOCK_EX) 192 | { 193 | return file_put_contents($path, $contents, $lock); 194 | } 195 | 196 | /** 197 | * {@inheritDoc} 198 | */ 199 | public function set(string $key, $value) 200 | { 201 | $cacheKey = $this->getActualCacheKey($key); 202 | $cacheFile = $this->getCacheFile(); 203 | 204 | if ( ! file_exists($cacheFile) || ! $this->isValid($cacheKey)) { 205 | $this->createCacheFile(); 206 | } 207 | 208 | return $this->lock($cacheFile, LOCK_EX, function ($handle) use ($cacheKey, $cacheFile, $value) { 209 | $size = fstat($handle)['size']; 210 | $contents = fread($handle, $size ?: 1) ?? ''; 211 | $contents = json_decode($contents, true) ?? []; 212 | 213 | if ( ! empty($contents[$cacheKey]) && \is_array($value)) { 214 | $contents[$cacheKey] = $value + $contents[$cacheKey]; 215 | } else { 216 | $contents[$cacheKey] = $value; 217 | } 218 | ftruncate($handle, 0); 219 | return fwrite($handle, json_encode($contents)); 220 | }, FILE::APPEND_WRITE); 221 | } 222 | 223 | /** 224 | * {@inheritDoc} 225 | */ 226 | public function delete(string $key): bool 227 | { 228 | $cacheKey = $this->getActualCacheKey($key); 229 | $contents = $this->getCacheContents(); 230 | 231 | if (isset($contents[$cacheKey])) { 232 | unset($contents[$cacheKey]); 233 | 234 | return false !== $this->put($this->getCacheFile(), json_encode($contents)); 235 | } 236 | 237 | return false; 238 | } 239 | 240 | /** 241 | * {@inheritDoc} 242 | */ 243 | public function keys(): array 244 | { 245 | $contents = $this->getCacheContents(); 246 | 247 | if (\is_array($contents)) { 248 | return array_keys($contents); 249 | } 250 | 251 | return []; 252 | } 253 | 254 | /** 255 | * Check if cache is still valid. 256 | * 257 | * @param string $key 258 | * 259 | * @return bool 260 | */ 261 | public function isValid(string $key): bool 262 | { 263 | $key = $this->getActualCacheKey($key); 264 | $meta = $this->getCacheContents()[$key] ?? []; 265 | 266 | if (empty($meta['expires_at'])) { 267 | return false; 268 | } 269 | 270 | return Carbon::now() < Carbon::createFromFormat(self::RFC_7231, $meta['expires_at']); 271 | } 272 | 273 | /** 274 | * Get cache contents. 275 | * 276 | * @return array|bool 277 | */ 278 | public function getCacheContents() 279 | { 280 | $cacheFile = $this->getCacheFile(); 281 | 282 | if ( ! file_exists($cacheFile)) { 283 | return false; 284 | } 285 | 286 | return json_decode($this->sharedGet($cacheFile), true) ?? []; 287 | } 288 | 289 | /** 290 | * Get actual cache key with prefix. 291 | * 292 | * @param string $key 293 | * 294 | * @return string 295 | */ 296 | public function getActualCacheKey(string $key): string 297 | { 298 | $prefix = $this->getPrefix(); 299 | 300 | if (false === strpos($key, $prefix)) { 301 | $key = $prefix . $key; 302 | } 303 | 304 | return $key; 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /src/File.php: -------------------------------------------------------------------------------- 1 | name = $name; 64 | $this->cache = $cache; 65 | } 66 | 67 | /** 68 | * Set file meta. 69 | * 70 | * @param int $offset 71 | * @param int $fileSize 72 | * @param string $filePath 73 | * @param string|null $location 74 | * 75 | * @return File 76 | */ 77 | public function setMeta(int $offset, int $fileSize, string $filePath, string $location = null): self 78 | { 79 | $this->offset = $offset; 80 | $this->fileSize = $fileSize; 81 | $this->filePath = $filePath; 82 | $this->location = $location; 83 | 84 | return $this; 85 | } 86 | 87 | /** 88 | * Set name. 89 | * 90 | * @param string $name 91 | * 92 | * @return File 93 | */ 94 | public function setName(string $name): self 95 | { 96 | $this->name = $name; 97 | 98 | return $this; 99 | } 100 | 101 | /** 102 | * Get name. 103 | * 104 | * @return string 105 | */ 106 | public function getName(): string 107 | { 108 | return $this->name; 109 | } 110 | 111 | /** 112 | * Set file size. 113 | * 114 | * @param int $size 115 | * 116 | * @return File 117 | */ 118 | public function setFileSize(int $size): self 119 | { 120 | $this->fileSize = $size; 121 | 122 | return $this; 123 | } 124 | 125 | /** 126 | * Get file size. 127 | * 128 | * @return int 129 | */ 130 | public function getFileSize(): int 131 | { 132 | return $this->fileSize; 133 | } 134 | 135 | /** 136 | * Set key. 137 | * 138 | * @param string $key 139 | * 140 | * @return File 141 | */ 142 | public function setKey(string $key): self 143 | { 144 | $this->key = $key; 145 | 146 | return $this; 147 | } 148 | 149 | /** 150 | * Get key. 151 | * 152 | * @return string 153 | */ 154 | public function getKey(): string 155 | { 156 | return $this->key; 157 | } 158 | 159 | /** 160 | * Set checksum. 161 | * 162 | * @param string $checksum 163 | * 164 | * @return File 165 | */ 166 | public function setChecksum(string $checksum): self 167 | { 168 | $this->checksum = $checksum; 169 | 170 | return $this; 171 | } 172 | 173 | /** 174 | * Get checksum. 175 | * 176 | * @return string 177 | */ 178 | public function getChecksum(): string 179 | { 180 | return $this->checksum; 181 | } 182 | 183 | /** 184 | * Set offset. 185 | * 186 | * @param int $offset 187 | * 188 | * @return File 189 | */ 190 | public function setOffset(int $offset): self 191 | { 192 | $this->offset = $offset; 193 | 194 | return $this; 195 | } 196 | 197 | /** 198 | * Get offset. 199 | * 200 | * @return int 201 | */ 202 | public function getOffset(): int 203 | { 204 | return $this->offset; 205 | } 206 | 207 | /** 208 | * Set location. 209 | * 210 | * @param string $location 211 | * 212 | * @return File 213 | */ 214 | public function setLocation(string $location): self 215 | { 216 | $this->location = $location; 217 | 218 | return $this; 219 | } 220 | 221 | /** 222 | * Get location. 223 | * 224 | * @return string 225 | */ 226 | public function getLocation(): string 227 | { 228 | return $this->location; 229 | } 230 | 231 | /** 232 | * Set absolute file location. 233 | * 234 | * @param string $path 235 | * 236 | * @return File 237 | */ 238 | public function setFilePath(string $path): self 239 | { 240 | $this->filePath = $path; 241 | 242 | return $this; 243 | } 244 | 245 | /** 246 | * Get absolute location. 247 | * 248 | * @return string 249 | */ 250 | public function getFilePath(): string 251 | { 252 | return $this->filePath; 253 | } 254 | 255 | /** 256 | * @param string[] $metadata 257 | * 258 | * @return File 259 | */ 260 | public function setUploadMetadata(array $metadata): self 261 | { 262 | $this->uploadMetadata = $metadata; 263 | 264 | return $this; 265 | } 266 | 267 | /** 268 | * Get input stream. 269 | * 270 | * @return string 271 | */ 272 | public function getInputStream(): string 273 | { 274 | return self::INPUT_STREAM; 275 | } 276 | 277 | /** 278 | * Get file meta. 279 | * 280 | * @return array 281 | */ 282 | public function details(): array 283 | { 284 | $now = Carbon::now(); 285 | 286 | return [ 287 | 'name' => $this->name, 288 | 'size' => $this->fileSize, 289 | 'offset' => $this->offset, 290 | 'checksum' => $this->checksum, 291 | 'location' => $this->location, 292 | 'file_path' => $this->filePath, 293 | 'metadata' => $this->uploadMetadata, 294 | 'created_at' => $now->format($this->cache::RFC_7231), 295 | 'expires_at' => $now->addSeconds($this->cache->getTtl())->format($this->cache::RFC_7231), 296 | ]; 297 | } 298 | 299 | /** 300 | * Upload file to server. 301 | * 302 | * @param int $totalBytes 303 | * 304 | * @throws ConnectionException 305 | * 306 | * @return int 307 | */ 308 | public function upload(int $totalBytes): int 309 | { 310 | if ($this->offset === $totalBytes) { 311 | return $this->offset; 312 | } 313 | 314 | $input = $this->open($this->getInputStream(), self::READ_BINARY); 315 | $output = $this->open($this->getFilePath(), self::APPEND_BINARY); 316 | $key = $this->getKey(); 317 | 318 | try { 319 | $this->seek($output, $this->offset); 320 | 321 | while ( ! feof($input)) { 322 | if (CONNECTION_NORMAL !== connection_status()) { 323 | throw new ConnectionException('Connection aborted by user.'); 324 | } 325 | 326 | $data = $this->read($input, self::CHUNK_SIZE); 327 | $bytes = $this->write($output, $data, self::CHUNK_SIZE); 328 | 329 | $this->offset += $bytes; 330 | 331 | $this->cache->set($key, ['offset' => $this->offset]); 332 | 333 | if ($this->offset > $totalBytes) { 334 | throw new OutOfRangeException('The uploaded file is corrupt.'); 335 | } 336 | 337 | if ($this->offset === $totalBytes) { 338 | break; 339 | } 340 | } 341 | } finally { 342 | $this->close($input); 343 | $this->close($output); 344 | } 345 | 346 | return $this->offset; 347 | } 348 | 349 | /** 350 | * Open file in given mode. 351 | * 352 | * @param string $filePath 353 | * @param string $mode 354 | * 355 | * @throws FileException 356 | * 357 | * @return resource 358 | */ 359 | public function open(string $filePath, string $mode) 360 | { 361 | $this->exists($filePath, $mode); 362 | 363 | $ptr = @fopen($filePath, $mode); 364 | 365 | if (false === $ptr) { 366 | throw new FileException("Unable to open $filePath."); 367 | } 368 | 369 | return $ptr; 370 | } 371 | 372 | /** 373 | * Check if file to read exists. 374 | * 375 | * @param string $filePath 376 | * @param string $mode 377 | * 378 | * @throws FileException 379 | * 380 | * @return bool 381 | */ 382 | public function exists(string $filePath, string $mode = self::READ_BINARY): bool 383 | { 384 | if (self::INPUT_STREAM === $filePath) { 385 | return true; 386 | } 387 | 388 | if (self::READ_BINARY === $mode && ! file_exists($filePath)) { 389 | throw new FileException('File not found.'); 390 | } 391 | 392 | return true; 393 | } 394 | 395 | /** 396 | * Move file pointer to given offset. 397 | * 398 | * @param resource $handle 399 | * @param int $offset 400 | * @param int $whence 401 | * 402 | * @throws FileException 403 | * 404 | * @return int 405 | */ 406 | public function seek($handle, int $offset, int $whence = SEEK_SET): int 407 | { 408 | $position = fseek($handle, $offset, $whence); 409 | 410 | if (-1 === $position) { 411 | throw new FileException('Cannot move pointer to desired position.'); 412 | } 413 | 414 | return $position; 415 | } 416 | 417 | /** 418 | * Read data from file. 419 | * 420 | * @param resource $handle 421 | * @param int $chunkSize 422 | * 423 | * @throws FileException 424 | * 425 | * @return string 426 | */ 427 | public function read($handle, int $chunkSize): string 428 | { 429 | $data = fread($handle, $chunkSize); 430 | 431 | if (false === $data) { 432 | throw new FileException('Cannot read file.'); 433 | } 434 | 435 | return $data; 436 | } 437 | 438 | /** 439 | * Write data to file. 440 | * 441 | * @param resource $handle 442 | * @param string $data 443 | * @param int|null $length 444 | * 445 | * @throws FileException 446 | * 447 | * @return int 448 | */ 449 | public function write($handle, string $data, $length = null): int 450 | { 451 | $bytesWritten = \is_int($length) ? fwrite($handle, $data, $length) : fwrite($handle, $data); 452 | 453 | if (false === $bytesWritten) { 454 | throw new FileException('Cannot write to a file.'); 455 | } 456 | 457 | return $bytesWritten; 458 | } 459 | 460 | /** 461 | * Merge 2 or more files. 462 | * 463 | * @param array $files File data with meta info. 464 | * 465 | * @return int 466 | */ 467 | public function merge(array $files): int 468 | { 469 | $destination = $this->getFilePath(); 470 | $firstFile = array_shift($files); 471 | 472 | // First partial file can directly be copied. 473 | $this->copy($firstFile['file_path'], $destination); 474 | 475 | $this->offset = $firstFile['offset']; 476 | $this->fileSize = filesize($firstFile['file_path']); 477 | 478 | $handle = $this->open($destination, self::APPEND_BINARY); 479 | 480 | foreach ($files as $file) { 481 | if ( ! file_exists($file['file_path'])) { 482 | throw new FileException('File to be merged not found.'); 483 | } 484 | 485 | $this->fileSize += $this->write($handle, file_get_contents($file['file_path'])); 486 | 487 | $this->offset += $file['offset']; 488 | } 489 | 490 | $this->close($handle); 491 | 492 | return $this->fileSize; 493 | } 494 | 495 | /** 496 | * Copy file from source to destination. 497 | * 498 | * @param string $source 499 | * @param string $destination 500 | * 501 | * @return bool 502 | */ 503 | public function copy(string $source, string $destination): bool 504 | { 505 | $status = @copy($source, $destination); 506 | 507 | if (false === $status) { 508 | throw new FileException(sprintf('Cannot copy source (%s) to destination (%s).', $source, $destination)); 509 | } 510 | 511 | return $status; 512 | } 513 | 514 | /** 515 | * Delete file and/or folder. 516 | * 517 | * @param array $files 518 | * @param bool $folder 519 | * 520 | * @return bool 521 | */ 522 | public function delete(array $files, bool $folder = false): bool 523 | { 524 | $status = $this->deleteFiles($files); 525 | 526 | if ($status && $folder) { 527 | return rmdir(\dirname(current($files))); 528 | } 529 | 530 | return $status; 531 | } 532 | 533 | /** 534 | * Delete multiple files. 535 | * 536 | * @param array $files 537 | * 538 | * @return bool 539 | */ 540 | public function deleteFiles(array $files): bool 541 | { 542 | if (empty($files)) { 543 | return false; 544 | } 545 | 546 | $status = true; 547 | 548 | foreach ($files as $file) { 549 | if (file_exists($file)) { 550 | $status = $status && unlink($file); 551 | } 552 | } 553 | 554 | return $status; 555 | } 556 | 557 | /** 558 | * Close file. 559 | * 560 | * @param $handle 561 | * 562 | * @return bool 563 | */ 564 | public function close($handle): bool 565 | { 566 | return fclose($handle); 567 | } 568 | } 569 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

TusPHP

2 | 3 |

4 | 5 | PHP Version 6 | 7 | 8 | Build Status 9 | 10 | 11 | Code Coverage 12 | 13 | 14 | Scrutinizer Code Quality 15 | 16 | 17 | Downloads 18 | 19 | 20 | Software License 21 | 22 |

23 | 24 |

25 | Resumable file upload in PHP using tus resumable upload protocol v1.0.0 26 |

27 | 28 |

29 | TusPHP Demo

30 | Medium Article ⚡ Laravel & Lumen Integration ⚡ Symfony Integration ⚡ CakePHP Integration ⚡ WordPress Integration 31 |

32 | 33 | **tus** is a HTTP based protocol for resumable file uploads. Resumable means you can carry on where you left off without 34 | re-uploading whole data again in case of any interruptions. An interruption may happen willingly if the user wants 35 | to pause, or by accident in case of a network issue or server outage. 36 | 37 | ### Table of Contents 38 | 39 | * [Installation](#installation) 40 | * [Usage](#usage) 41 | * [Server](#server) 42 | * [Nginx](#nginx) 43 | * [Apache](#apache) 44 | * [Client](#client) 45 | * [Third Party Client Libraries](#third-party-client-libraries) 46 | * [Cloud Providers](#cloud-providers) 47 | * [Extension support](#extension-support) 48 | * [Expiration](#expiration) 49 | * [Concatenation](#concatenation) 50 | * [Events](#events) 51 | * [Responding to an Event](#responding-to-an-event) 52 | * [Middleware](#middleware) 53 | * [Creating a Middleware](#creating-a-middleware) 54 | * [Adding a Middleware](#adding-a-middleware) 55 | * [Skipping a Middleware](#skipping-a-middleware) 56 | * [Setting up a dev environment and/or running examples locally](#setting-up-a-dev-environment-andor-running-examples-locally) 57 | * [Docker](#docker) 58 | * [Contributing](#contributing) 59 | * [Questions about this project?](#questions-about-this-project) 60 | * [Supporters](#supporters) 61 | 62 | ### Installation 63 | 64 | Pull the package via composer. 65 | ```shell 66 | $ composer require ankitpokhrel/tus-php 67 | 68 | // Use v1 for php7.1, Symfony 3 or 4. 69 | 70 | $ composer require ankitpokhrel/tus-php:^1.2 71 | ``` 72 | 73 | ### Usage 74 | | ![Basic Tus Architecture](https://cdn-images-1.medium.com/max/2000/1*N4JhqeXJgWA1Z7pc6_5T_A.png "Basic Tus Architecture") | 75 | |:--:| 76 | | Basic Tus Architecture | 77 | 78 | #### Server 79 | This is how a simple server looks like. 80 | 81 | ```php 82 | // server.php 83 | 84 | // Either redis, file or apcu. Leave empty for file based cache. 85 | $server = new \TusPhp\Tus\Server('redis'); 86 | $response = $server->serve(); 87 | 88 | $response->send(); 89 | 90 | exit(0); // Exit from current PHP process. 91 | ``` 92 | 93 | > :bangbang: File based cache is not recommended for production use. 94 | 95 | You need to rewrite your server to respond to a specific endpoint. For example: 96 | 97 | ###### Nginx 98 | ```nginx 99 | # nginx.conf 100 | 101 | location /files { 102 | try_files $uri $uri/ /server.php?$query_string; 103 | } 104 | ``` 105 | 106 | A new config option [fastcgi_request_buffering](http://nginx.org/en/docs/http/ngx_http_fastcgi_module.html#fastcgi_request_buffering) is available since nginx 1.7.11. 107 | When buffering is enabled, the entire request body is read from the client before sending the request to a FastCGI server. Disabling this option might help with timeouts during the upload. 108 | Furthermore, it helps if you’re running out of disc space on the tmp partition of your system. 109 | 110 | If you do not turn off `fastcgi_request_buffering` and you use `fastcgi`, you will not be able to resume uploads because nginx will not give the request back to PHP until the entire file is uploaded. 111 | 112 | ```nginx 113 | location ~ \.php$ { 114 | # ... 115 | 116 | fastcgi_request_buffering off; # Disable request buffering 117 | 118 | # ... 119 | } 120 | ``` 121 | 122 | A sample nginx configuration can be found [here](docker/server/configs/default.conf). 123 | 124 | ###### Apache 125 | ```apache 126 | # .htaccess 127 | 128 | RewriteEngine on 129 | 130 | RewriteCond %{REQUEST_FILENAME} !-f 131 | RewriteRule ^files/?(.*)?$ /server.php?$1 [QSA,L] 132 | ``` 133 | 134 | Default max upload size is 0 which means there is no restriction. You can set max upload size as described below. 135 | ```php 136 | $server->setMaxUploadSize(100000000); // 100 MB in bytes 137 | ``` 138 | 139 | Default redis and file configuration for server and client can be found inside `config/server.php` and `config/client.php` respectively. 140 | To override default config you can simply copy the file to your preferred location and update the parameters. You then need to set the config before doing anything else. 141 | 142 | ```php 143 | \TusPhp\Config::set(''); 144 | 145 | $server = new \TusPhp\Tus\Server('redis'); 146 | ``` 147 | 148 | Alternately, you can set `REDIS_HOST`, `REDIS_PORT` and `REDIS_DB` env in your server to override redis settings for both server and client. 149 | 150 | #### Client 151 | The client can be used for creating, resuming and/or deleting uploads. 152 | 153 | ```php 154 | $client = new \TusPhp\Tus\Client($baseUrl); 155 | 156 | // Key is mandatory. 157 | $key = 'your unique key'; 158 | 159 | $client->setKey($key)->file('/path/to/file', 'filename.ext'); 160 | 161 | // Create and upload a chunk of 1MB 162 | $bytesUploaded = $client->upload(1000000); 163 | 164 | // Resume, $bytesUploaded = 2MB 165 | $bytesUploaded = $client->upload(1000000); 166 | 167 | // To upload whole file, skip length param 168 | $client->file('/path/to/file', 'filename.ext')->upload(); 169 | ``` 170 | 171 | To check if the file was partially uploaded before, you can use `getOffset` method. It returns false if the upload 172 | isn't there or invalid, returns total bytes uploaded otherwise. 173 | 174 | ```php 175 | $offset = $client->getOffset(); // 2000000 bytes or 2MB 176 | ``` 177 | 178 | Delete partial upload from the cache. 179 | 180 | ```php 181 | $client->delete($key); 182 | ``` 183 | 184 | By default, the client uses `/files` as an API path. You can change it with `setApiPath` method. 185 | 186 | ```php 187 | $client->setApiPath('/api'); 188 | ``` 189 | 190 | By default, the server will use `sha256` algorithm to verify the integrity of the upload. If you want to use a different hash algorithm, you can do so by 191 | using `setChecksumAlgorithm` method. To get the list of supported hash algorithms, you can send `OPTIONS` request to the server. 192 | 193 | ```php 194 | $client->setChecksumAlgorithm('crc32'); 195 | ``` 196 | 197 | #### Third Party Client Libraries 198 | ##### [Uppy](https://uppy.io/) 199 | Uppy is a sleek, modular file uploader plugin developed by same folks behind tus protocol. 200 | You can use uppy to seamlessly integrate official [tus-js-client](https://github.com/tus/tus-js-client) with tus-php server. 201 | Check out more details in [uppy docs](https://uppy.io/docs/tus/). 202 | ```js 203 | uppy.use(Tus, { 204 | endpoint: 'https://tus-server.yoursite.com/files/', // use your tus endpoint here 205 | resume: true, 206 | autoRetry: true, 207 | retryDelays: [0, 1000, 3000, 5000] 208 | }) 209 | ``` 210 | 211 | ##### [Tus-JS-Client](https://github.com/tus/tus-js-client) 212 | Tus-php server is compatible with the official [tus-js-client](https://github.com/tus/tus-js-client) Javascript library. 213 | ```js 214 | var upload = new tus.Upload(file, { 215 | endpoint: "/tus", 216 | retryDelays: [0, 3000, 5000, 10000, 20000], 217 | metadata: { 218 | name: file.name, 219 | type: file.type 220 | } 221 | }) 222 | upload.start() 223 | ``` 224 | 225 | #### Cloud Providers 226 | Many cloud providers implement PHP [streamWrapper](https://www.php.net/manual/en/class.streamwrapper.php) interface that enables us to store and retrieve data from these providers using built-in PHP functions. Since tus-php relies on PHP's built-in filesystem functions, we can easily use it to upload files to the providers like [Amazon S3](https://docs.aws.amazon.com/sdk-for-php/v3/developer-guide/s3-stream-wrapper.html) if their API supports writing in append binary mode. An example implementation to upload files directly to S3 bucket is as follows: 227 | 228 | ```php 229 | // server.php 230 | // composer require aws/aws-sdk-php 231 | 232 | use Aws\S3\S3Client; 233 | use TusPhp\Tus\Server; 234 | use Aws\Credentials\Credentials; 235 | 236 | $awsAccessKey = 'AWS_ACCESS_KEY'; // YOUR AWS ACCESS KEY 237 | $awsSecretKey = 'AWS_SECRET_KEY'; // YOUR AWS SECRET KEY 238 | $awsRegion = 'eu-west-1'; // YOUR AWS BUCKET REGION 239 | $basePath = 's3://your-bucket-name'; 240 | 241 | $s3Client = new S3Client([ 242 | 'version' => 'latest', 243 | 'region' => $awsRegion, 244 | 'credentials' => new Credentials($awsAccessKey, $awsSecretKey) 245 | ]); 246 | $s3Client->registerStreamWrapper(); 247 | 248 | $server = new Server('file'); 249 | $server->setUploadDir($basePath); 250 | 251 | $response = $server->serve(); 252 | $response->send(); 253 | 254 | exit(0); 255 | ``` 256 | 257 | ### Extension Support 258 | - [x] The Creation extension is mostly implemented and is used for creating the upload. Deferring the upload's length is not possible at the moment. 259 | - [x] The Termination extension is implemented which is used to terminate completed and unfinished uploads allowing the Server to free up used resources. 260 | - [x] The Checksum extension is implemented, the server will use `sha256` algorithm by default to verify the upload. 261 | - [x] The Expiration extension is implemented, details below. 262 | - [x] This Concatenation extension is implemented except that the server is not capable of handling unfinished concatenation. 263 | 264 | #### Expiration 265 | The Server is capable of removing expired but unfinished uploads. You can use the following command manually or in a 266 | cron job to remove them. Note that this command checks your cache storage to find expired uploads. So, make sure 267 | to run it before the cache is expired, else it will not find all files that needs to be cleared. 268 | 269 | ```shell 270 | $ ./vendor/bin/tus tus:expired --help 271 | 272 | Usage: 273 | tus:expired [] [options] 274 | 275 | Arguments: 276 | cache-adapter Cache adapter to use: redis, file or apcu [default: "file"] 277 | 278 | Options: 279 | -c, --config=CONFIG File to get config parameters from. 280 | 281 | eg: 282 | 283 | $ ./vendor/bin/tus tus:expired redis 284 | 285 | Cleaning server resources 286 | ========================= 287 | 288 | 1. Deleted 1535888128_35094.jpg from /var/www/uploads 289 | ``` 290 | 291 | You can use`--config` option to override default redis or file configuration. 292 | 293 | ```shell 294 | $ ./vendor/bin/tus tus:expired redis --config= 295 | ``` 296 | 297 | #### Concatenation 298 | The Server is capable of concatenating multiple uploads into a single one enabling Clients to perform parallel uploads and to upload non-contiguous chunks. 299 | 300 | ```php 301 | // Actual file key 302 | $uploadKey = uniqid(); 303 | 304 | $client->setKey($uploadKey)->file('/path/to/file', 'chunk_a.ext'); 305 | 306 | // Upload 10000 bytes starting from 1000 bytes 307 | $bytesUploaded = $client->seek(1000)->upload(10000); 308 | $chunkAkey = $client->getKey(); 309 | 310 | // Upload 1000 bytes starting from 0 bytes 311 | $bytesUploaded = $client->setFileName('chunk_b.ext')->seek(0)->upload(1000); 312 | $chunkBkey = $client->getKey(); 313 | 314 | // Upload remaining bytes starting from 11000 bytes (10000 + 1000) 315 | $bytesUploaded = $client->setFileName('chunk_c.ext')->seek(11000)->upload(); 316 | $chunkCkey = $client->getKey(); 317 | 318 | // Concatenate partial uploads 319 | $client->setFileName('actual_file.ext')->concat($uploadKey, $chunkBkey, $chunkAkey, $chunkCkey); 320 | ``` 321 | 322 | Additionally, the server will verify checksum against the merged file to make sure that the file is not corrupt. 323 | 324 | ### Events 325 | Often times, you may want to perform some operation after the upload is complete or created. For example, you may want to crop images after upload or transcode a file and email it to your user. 326 | You can utilize tus events for these operations. Following events are dispatched by server during different point of execution. 327 | 328 | | Event Name | Dispatched | 329 | -------------|------------| 330 | | `tus-server.upload.created` | after the upload is created during `POST` request. | 331 | | `tus-server.upload.progress` | after a chunk is uploaded during `PATCH` request. | 332 | | `tus-server.upload.complete` | after the upload is complete and checksum verification is done. | 333 | | `tus-server.upload.merged` | after all partial uploads are merged during concatenation request. | 334 | 335 | #### Responding to an Event 336 | To listen to an event, you can simply attach a listener to the event name. An `TusEvent` instance is created and passed to all of the listeners. 337 | 338 | ```php 339 | $server->event()->addListener('tus-server.upload.complete', function (\TusPhp\Events\TusEvent $event) { 340 | $fileMeta = $event->getFile()->details(); 341 | $request = $event->getRequest(); 342 | $response = $event->getResponse(); 343 | 344 | // ... 345 | }); 346 | ``` 347 | 348 | or, you can also bind some method of a custom class. 349 | 350 | ```php 351 | /** 352 | * Listener can be method from any normal class. 353 | */ 354 | class SomeClass 355 | { 356 | public function postUploadOperation(\TusPhp\Events\TusEvent $event) 357 | { 358 | // ... 359 | } 360 | } 361 | 362 | $listener = new SomeClass(); 363 | 364 | $server->event()->addListener('tus-server.upload.complete', [$listener, 'postUploadOperation']); 365 | ``` 366 | 367 | ### Middleware 368 | You can manipulate request and response of a server using a middleware. Middleware can be used to run a piece of code before a server calls the actual handle method. 369 | You can use middleware to authenticate a request, handle CORS, whitelist/blacklist an IP etc. 370 | 371 | #### Creating a Middleware 372 | In order to create a middleware, you need to implement `TusMiddleware` interface. The handle method provides request and response object for you to manipulate. 373 | 374 | ```php 375 | user->isLoggedIn()) { 394 | throw new UnauthorizedHttpException('User not authenticated'); 395 | } 396 | 397 | $request->getRequest()->headers->set('Authorization', 'Bearer ' . $this->user->token()); 398 | } 399 | 400 | // ... 401 | } 402 | ``` 403 | 404 | #### Adding a Middleware 405 | To add a middleware, get middleware object from server and simply pass middleware classes. 406 | 407 | ```php 408 | $server->middleware()->add(Authenticated::class, AnotherMiddleware::class); 409 | ``` 410 | 411 | Or, you can also pass middleware class objects. 412 | ```php 413 | $authenticated = new Your\Namespace\Authenticated(new User()); 414 | 415 | $server->middleware()->add($authenticated); 416 | ``` 417 | 418 | #### Skipping a Middleware 419 | If you wish to skip or ignore any middleware, you can do so by using the `skip` method. 420 | 421 | ```php 422 | $server->middleware()->skip(Cors::class, AnotherMiddleware::class); 423 | ``` 424 | 425 | ### Setting up a dev environment and/or running examples locally 426 | An ajax based example for this implementation can be found in `examples/` folder. You can build and run it using docker as described below. 427 | 428 | #### Docker 429 | Make sure that [docker](https://docs.docker.com/engine/installation/) and [docker-compose](https://docs.docker.com/compose/install/) 430 | are installed in your system. Then, run docker script from project root. 431 | ```shell 432 | # PHP7 433 | $ make dev 434 | 435 | # PHP8 436 | $ make dev8 437 | 438 | # or, without make 439 | 440 | # PHP7 441 | $ bin/docker.sh 442 | 443 | # PHP8 444 | $ PHP_VERSION=8 bin/docker.sh 445 | ``` 446 | 447 | Now, the client can be accessed at http://0.0.0.0:8080 and the server can be accessed at http://0.0.0.0:8081. The default API endpoint is set to`/files` 448 | and uploaded files can be found inside `uploads` folder. All docker configs can be found in `docker/` folder. 449 | 450 | If you want a fresh start then you can use the following commands. It will delete and recreate all containers, images, and uploads folder. 451 | ```shell 452 | # PHP7 453 | $ make dev-fresh 454 | 455 | # PHP8 456 | $ make dev8-fresh 457 | 458 | # or, without make 459 | 460 | # PHP7 461 | $ bin/clean.sh && bin/docker.sh 462 | 463 | # PHP8 464 | $ bin/clean.sh && PHP_VERSION=8 bin/docker.sh 465 | ``` 466 | 467 | We also have some utility scripts that will ease your local development experience. See [Makefile](Makefile) for a list of all available commands. 468 | If you are not using [make](https://www.gnu.org/software/make/manual/make.html#Overview), then you can use shell scripts available [here](bin). 469 | 470 | ### Contributing 471 | 1. Install [PHPUnit](https://phpunit.de/) and [composer](https://getcomposer.org/) if you haven't already. 472 | 2. Install dependencies 473 | ```shell 474 | $ make vendor 475 | 476 | # or 477 | 478 | $ composer install 479 | ``` 480 | 3. Run tests with phpunit 481 | ```shell 482 | $ make test 483 | 484 | # or 485 | 486 | $ composer test 487 | 488 | # or 489 | 490 | $ ./vendor/bin/phpunit 491 | ``` 492 | 4. Validate changes against [PSR2 Coding Standards](http://www.php-fig.org/psr/psr-2/) 493 | ```shell 494 | # fix lint issues 495 | $ make lint 496 | 497 | # dry run 498 | $ make lint-dry 499 | ``` 500 | 501 | You can use `xdebug enable` and `xdebug disable` to enable and disable [Xdebug](https://xdebug.org/) inside the container. 502 | 503 | ### Questions about this project? 504 | Please feel free to report any bug found. Pull requests, issues, and project recommendations are more than welcome! 505 | -------------------------------------------------------------------------------- /src/Tus/Client.php: -------------------------------------------------------------------------------- 1 | headers = $options['headers'] ?? []; 67 | $options['headers'] = [ 68 | 'Tus-Resumable' => self::TUS_PROTOCOL_VERSION, 69 | ] + ($this->headers); 70 | 71 | $this->client = new GuzzleClient( 72 | ['base_uri' => $baseUri] + $options 73 | ); 74 | 75 | Config::set(__DIR__ . '/../Config/client.php'); 76 | 77 | $this->setCache('file'); 78 | } 79 | 80 | /** 81 | * Set file properties. 82 | * 83 | * @param string $file File path. 84 | * @param string|null $name File name. 85 | * 86 | * @return Client 87 | */ 88 | public function file(string $file, string $name = null): self 89 | { 90 | $this->filePath = $file; 91 | 92 | if ( ! file_exists($file) || ! is_readable($file)) { 93 | throw new FileException('Cannot read file: ' . $file); 94 | } 95 | 96 | $this->fileName = $name ?? basename($this->filePath); 97 | $this->fileSize = filesize($file); 98 | 99 | $this->addMetadata('filename', $this->fileName); 100 | 101 | return $this; 102 | } 103 | 104 | /** 105 | * Get file path. 106 | * 107 | * @return string|null 108 | */ 109 | public function getFilePath(): ?string 110 | { 111 | return $this->filePath; 112 | } 113 | 114 | /** 115 | * Set file name. 116 | * 117 | * @param string $name 118 | * 119 | * @return Client 120 | */ 121 | public function setFileName(string $name): self 122 | { 123 | $this->addMetadata('filename', $this->fileName = $name); 124 | 125 | return $this; 126 | } 127 | 128 | /** 129 | * Get file name. 130 | * 131 | * @return string|null 132 | */ 133 | public function getFileName(): ?string 134 | { 135 | return $this->fileName; 136 | } 137 | 138 | /** 139 | * Get file size. 140 | * 141 | * @return int 142 | */ 143 | public function getFileSize(): int 144 | { 145 | return $this->fileSize; 146 | } 147 | 148 | /** 149 | * Get guzzle client. 150 | * 151 | * @return GuzzleClient 152 | */ 153 | public function getClient(): GuzzleClient 154 | { 155 | return $this->client; 156 | } 157 | 158 | /** 159 | * Set checksum. 160 | * 161 | * @param string $checksum 162 | * 163 | * @return Client 164 | */ 165 | public function setChecksum(string $checksum): self 166 | { 167 | $this->checksum = $checksum; 168 | 169 | return $this; 170 | } 171 | 172 | /** 173 | * Get checksum. 174 | * 175 | * @return string 176 | */ 177 | public function getChecksum(): string 178 | { 179 | if (empty($this->checksum)) { 180 | $this->setChecksum(hash_file($this->getChecksumAlgorithm(), $this->getFilePath())); 181 | } 182 | 183 | return $this->checksum; 184 | } 185 | 186 | /** 187 | * Add metadata. 188 | * 189 | * @param string $key 190 | * @param string $value 191 | * 192 | * @return Client 193 | */ 194 | public function addMetadata(string $key, string $value): self 195 | { 196 | $this->metadata[$key] = base64_encode($value); 197 | 198 | return $this; 199 | } 200 | 201 | /** 202 | * Remove metadata. 203 | * 204 | * @param string $key 205 | * 206 | * @return Client 207 | */ 208 | public function removeMetadata(string $key): self 209 | { 210 | unset($this->metadata[$key]); 211 | 212 | return $this; 213 | } 214 | 215 | /** 216 | * Set metadata. 217 | * 218 | * @param array $items 219 | * 220 | * @return Client 221 | */ 222 | public function setMetadata(array $items): self 223 | { 224 | $items = array_map('base64_encode', $items); 225 | 226 | $this->metadata = $items; 227 | 228 | return $this; 229 | } 230 | 231 | /** 232 | * Get metadata. 233 | * 234 | * @return array 235 | */ 236 | public function getMetadata(): array 237 | { 238 | return $this->metadata; 239 | } 240 | 241 | /** 242 | * Get metadata for Upload-Metadata header. 243 | * 244 | * @return string 245 | */ 246 | protected function getUploadMetadataHeader(): string 247 | { 248 | $metadata = []; 249 | 250 | foreach ($this->getMetadata() as $key => $value) { 251 | $metadata[] = "{$key} {$value}"; 252 | } 253 | 254 | return implode(',', $metadata); 255 | } 256 | 257 | /** 258 | * Set key. 259 | * 260 | * @param string $key 261 | * 262 | * @return Client 263 | */ 264 | public function setKey(string $key): self 265 | { 266 | $this->key = $key; 267 | 268 | return $this; 269 | } 270 | 271 | /** 272 | * Get key. 273 | * 274 | * @return string 275 | */ 276 | public function getKey(): string 277 | { 278 | return $this->key; 279 | } 280 | 281 | /** 282 | * Get url. 283 | * 284 | * @return string|null 285 | */ 286 | public function getUrl(): ?string 287 | { 288 | $this->url = $this->getCache()->get($this->getKey())['location'] ?? null; 289 | 290 | if ( ! $this->url) { 291 | throw new FileException('File not found.'); 292 | } 293 | 294 | return $this->url; 295 | } 296 | 297 | /** 298 | * Set checksum algorithm. 299 | * 300 | * @param string $algorithm 301 | * 302 | * @return Client 303 | */ 304 | public function setChecksumAlgorithm(string $algorithm): self 305 | { 306 | $this->checksumAlgorithm = $algorithm; 307 | 308 | return $this; 309 | } 310 | 311 | /** 312 | * Get checksum algorithm. 313 | * 314 | * @return string 315 | */ 316 | public function getChecksumAlgorithm(): string 317 | { 318 | return $this->checksumAlgorithm; 319 | } 320 | 321 | /** 322 | * Check if current upload is expired. 323 | * 324 | * @return bool 325 | */ 326 | public function isExpired(): bool 327 | { 328 | $expiresAt = $this->getCache()->get($this->getKey())['expires_at'] ?? null; 329 | 330 | return empty($expiresAt) || Carbon::parse($expiresAt)->lt(Carbon::now()); 331 | } 332 | 333 | /** 334 | * Check if this is a partial upload request. 335 | * 336 | * @return bool 337 | */ 338 | public function isPartial(): bool 339 | { 340 | return $this->partial; 341 | } 342 | 343 | /** 344 | * Get partial offset. 345 | * 346 | * @return int 347 | */ 348 | public function getPartialOffset(): int 349 | { 350 | return $this->partialOffset; 351 | } 352 | 353 | /** 354 | * Set offset and force this to be a partial upload request. 355 | * 356 | * @param int $offset 357 | * 358 | * @return self 359 | */ 360 | public function seek(int $offset): self 361 | { 362 | $this->partialOffset = $offset; 363 | 364 | $this->partial(); 365 | 366 | return $this; 367 | } 368 | 369 | /** 370 | * Upload file. 371 | * 372 | * @param int $bytes Bytes to upload 373 | * 374 | * @throws TusException 375 | * @throws GuzzleException 376 | * @throws ConnectionException 377 | * 378 | * @return int 379 | */ 380 | public function upload(int $bytes = -1): int 381 | { 382 | $bytes = $bytes < 0 ? $this->getFileSize() : $bytes; 383 | $offset = $this->partialOffset < 0 ? 0 : $this->partialOffset; 384 | 385 | try { 386 | // Check if this upload exists with HEAD request. 387 | $offset = $this->sendHeadRequest(); 388 | } catch (FileException | ClientException $e) { 389 | // Create a new upload. 390 | $this->url = $this->create($this->getKey()); 391 | } catch (ConnectException $e) { 392 | throw new ConnectionException("Couldn't connect to server."); 393 | } 394 | 395 | // Verify that upload is not yet expired. 396 | if ($this->isExpired()) { 397 | throw new TusException('Upload expired.'); 398 | } 399 | 400 | // Now, resume upload with PATCH request. 401 | return $this->sendPatchRequest($bytes, $offset); 402 | } 403 | 404 | /** 405 | * Returns offset if file is partially uploaded. 406 | * 407 | * @throws GuzzleException 408 | * 409 | * @return bool|int 410 | */ 411 | public function getOffset() 412 | { 413 | try { 414 | $offset = $this->sendHeadRequest(); 415 | } catch (FileException | ClientException $e) { 416 | return false; 417 | } 418 | 419 | return $offset; 420 | } 421 | 422 | /** 423 | * Create resource with POST request. 424 | * 425 | * @param string $key 426 | * 427 | * @throws FileException 428 | * @throws GuzzleException 429 | * 430 | * @return string 431 | */ 432 | public function create(string $key): string 433 | { 434 | return $this->createWithUpload($key, 0)['location']; 435 | } 436 | 437 | /** 438 | * Create resource with POST request and upload data using the creation-with-upload extension. 439 | * 440 | * @see https://tus.io/protocols/resumable-upload.html#creation-with-upload 441 | * 442 | * @param string $key 443 | * @param int $bytes -1 => all data; 0 => no data 444 | * 445 | * @throws GuzzleException 446 | * 447 | * @return array [ 448 | * 'location' => string, 449 | * 'offset' => int 450 | * ] 451 | */ 452 | public function createWithUpload(string $key, int $bytes = -1): array 453 | { 454 | $bytes = $bytes < 0 ? $this->fileSize : $bytes; 455 | 456 | $headers = $this->headers + [ 457 | 'Upload-Length' => $this->fileSize, 458 | 'Upload-Key' => $key, 459 | 'Upload-Checksum' => $this->getUploadChecksumHeader(), 460 | 'Upload-Metadata' => $this->getUploadMetadataHeader(), 461 | ]; 462 | 463 | $data = ''; 464 | if ($bytes > 0) { 465 | $data = $this->getData(0, $bytes); 466 | 467 | $headers += [ 468 | 'Content-Type' => self::HEADER_CONTENT_TYPE, 469 | 'Content-Length' => \strlen($data), 470 | ]; 471 | } 472 | 473 | if ($this->isPartial()) { 474 | $headers += ['Upload-Concat' => 'partial']; 475 | } 476 | 477 | try { 478 | $response = $this->getClient()->post($this->apiPath, [ 479 | 'body' => $data, 480 | 'headers' => $headers, 481 | ]); 482 | } catch (ClientException $e) { 483 | $response = $e->getResponse(); 484 | } 485 | 486 | $statusCode = $response->getStatusCode(); 487 | 488 | if (HttpResponse::HTTP_CREATED !== $statusCode) { 489 | throw new FileException('Unable to create resource.'); 490 | } 491 | 492 | $uploadOffset = $bytes > 0 ? current($response->getHeader('upload-offset')) : 0; 493 | $uploadLocation = current($response->getHeader('location')); 494 | 495 | $this->getCache()->set($this->getKey(), [ 496 | 'location' => $uploadLocation, 497 | 'expires_at' => Carbon::now()->addSeconds($this->getCache()->getTtl())->format($this->getCache()::RFC_7231), 498 | ]); 499 | 500 | return [ 501 | 'location' => $uploadLocation, 502 | 'offset' => $uploadOffset, 503 | ]; 504 | } 505 | 506 | /** 507 | * Concatenate 2 or more partial uploads. 508 | * 509 | * @param string $key 510 | * @param mixed $partials 511 | * 512 | * @throws GuzzleException 513 | * 514 | * @return string 515 | */ 516 | public function concat(string $key, ...$partials): string 517 | { 518 | $response = $this->getClient()->post($this->apiPath, [ 519 | 'headers' => $this->headers + [ 520 | 'Upload-Length' => $this->fileSize, 521 | 'Upload-Key' => $key, 522 | 'Upload-Checksum' => $this->getUploadChecksumHeader(), 523 | 'Upload-Metadata' => $this->getUploadMetadataHeader(), 524 | 'Upload-Concat' => self::UPLOAD_TYPE_FINAL . ';' . implode(' ', $partials), 525 | ], 526 | ]); 527 | 528 | $data = json_decode($response->getBody(), true); 529 | $checksum = $data['data']['checksum'] ?? null; 530 | $statusCode = $response->getStatusCode(); 531 | 532 | if (HttpResponse::HTTP_CREATED !== $statusCode || ! $checksum) { 533 | throw new FileException('Unable to create resource.'); 534 | } 535 | 536 | return $checksum; 537 | } 538 | 539 | /** 540 | * Send DELETE request. 541 | * 542 | * @throws FileException 543 | * @throws GuzzleException 544 | * 545 | * @return void 546 | */ 547 | public function delete() 548 | { 549 | try { 550 | $this->getClient()->delete($this->getUrl()); 551 | } catch (ClientException $e) { 552 | $statusCode = $e->getResponse()->getStatusCode(); 553 | 554 | if (HttpResponse::HTTP_NOT_FOUND === $statusCode || HttpResponse::HTTP_GONE === $statusCode) { 555 | throw new FileException('File not found.'); 556 | } 557 | } 558 | } 559 | 560 | /** 561 | * Set as partial request. 562 | * 563 | * @param bool $state 564 | * 565 | * @return void 566 | */ 567 | protected function partial(bool $state = true) 568 | { 569 | $this->partial = $state; 570 | 571 | if ( ! $this->partial) { 572 | return; 573 | } 574 | 575 | $key = $this->getKey(); 576 | 577 | if (false !== strpos($key, self::PARTIAL_UPLOAD_NAME_SEPARATOR)) { 578 | [$key, /* $partialKey */] = explode(self::PARTIAL_UPLOAD_NAME_SEPARATOR, $key); 579 | } 580 | 581 | $this->key = $key . self::PARTIAL_UPLOAD_NAME_SEPARATOR . Uuid::uuid4()->toString(); 582 | } 583 | 584 | /** 585 | * Send HEAD request. 586 | * 587 | * @throws FileException 588 | * @throws GuzzleException 589 | * 590 | * @return int 591 | */ 592 | protected function sendHeadRequest(): int 593 | { 594 | $response = $this->getClient()->head($this->getUrl()); 595 | $statusCode = $response->getStatusCode(); 596 | 597 | if (HttpResponse::HTTP_OK !== $statusCode) { 598 | throw new FileException('File not found.'); 599 | } 600 | 601 | return (int) current($response->getHeader('upload-offset')); 602 | } 603 | 604 | /** 605 | * Send PATCH request. 606 | * 607 | * @param int $bytes 608 | * @param int $offset 609 | * 610 | * @throws TusException 611 | * @throws FileException 612 | * @throws GuzzleException 613 | * @throws ConnectionException 614 | * 615 | * @return int 616 | */ 617 | protected function sendPatchRequest(int $bytes, int $offset): int 618 | { 619 | $data = $this->getData($offset, $bytes); 620 | $headers = $this->headers + [ 621 | 'Content-Type' => self::HEADER_CONTENT_TYPE, 622 | 'Content-Length' => \strlen($data), 623 | 'Upload-Checksum' => $this->getUploadChecksumHeader(), 624 | ]; 625 | 626 | if ($this->isPartial()) { 627 | $headers += ['Upload-Concat' => self::UPLOAD_TYPE_PARTIAL]; 628 | } else { 629 | $headers += ['Upload-Offset' => $offset]; 630 | } 631 | 632 | try { 633 | $response = $this->getClient()->patch($this->getUrl(), [ 634 | 'body' => $data, 635 | 'headers' => $headers, 636 | ]); 637 | 638 | return (int) current($response->getHeader('upload-offset')); 639 | } catch (ClientException $e) { 640 | throw $this->handleClientException($e); 641 | } catch (ConnectException $e) { 642 | throw new ConnectionException("Couldn't connect to server."); 643 | } 644 | } 645 | 646 | /** 647 | * Handle client exception during patch request. 648 | * 649 | * @param ClientException $e 650 | * 651 | * @return \Exception 652 | */ 653 | protected function handleClientException(ClientException $e) 654 | { 655 | $response = $e->getResponse(); 656 | $statusCode = $response !== null ? $response->getStatusCode() : HttpResponse::HTTP_INTERNAL_SERVER_ERROR; 657 | 658 | if (HttpResponse::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE === $statusCode) { 659 | return new FileException('The uploaded file is corrupt.'); 660 | } 661 | 662 | if (HttpResponse::HTTP_CONTINUE === $statusCode) { 663 | return new ConnectionException('Connection aborted by user.'); 664 | } 665 | 666 | if (HttpResponse::HTTP_UNSUPPORTED_MEDIA_TYPE === $statusCode) { 667 | return new TusException('Unsupported media types.'); 668 | } 669 | 670 | return new TusException((string) $response->getBody(), $statusCode); 671 | } 672 | 673 | /** 674 | * Get X bytes of data from file. 675 | * 676 | * @param int $offset 677 | * @param int $bytes 678 | * 679 | * @return string 680 | */ 681 | protected function getData(int $offset, int $bytes): string 682 | { 683 | $file = new File(); 684 | $handle = $file->open($this->getFilePath(), $file::READ_BINARY); 685 | 686 | $file->seek($handle, $offset); 687 | 688 | $data = $file->read($handle, $bytes); 689 | 690 | $file->close($handle); 691 | 692 | return $data; 693 | } 694 | 695 | /** 696 | * Get upload checksum header. 697 | * 698 | * @return string 699 | */ 700 | protected function getUploadChecksumHeader(): string 701 | { 702 | return $this->getChecksumAlgorithm() . ' ' . base64_encode($this->getChecksum()); 703 | } 704 | } 705 | -------------------------------------------------------------------------------- /src/Tus/Server.php: -------------------------------------------------------------------------------- 1 | request = new Request(); 86 | $this->response = new Response(); 87 | $this->middleware = new Middleware(); 88 | $this->uploadDir = \dirname(__DIR__, 2) . '/' . 'uploads'; 89 | 90 | $this->setCache($cacheAdapter); 91 | } 92 | 93 | /** 94 | * Set upload dir. 95 | * 96 | * @param string $path 97 | * 98 | * @return Server 99 | */ 100 | public function setUploadDir(string $path): self 101 | { 102 | $this->uploadDir = $path; 103 | 104 | return $this; 105 | } 106 | 107 | /** 108 | * Get upload dir. 109 | * 110 | * @return string 111 | */ 112 | public function getUploadDir(): string 113 | { 114 | return $this->uploadDir; 115 | } 116 | 117 | /** 118 | * Get request. 119 | * 120 | * @return Request 121 | */ 122 | public function getRequest(): Request 123 | { 124 | return $this->request; 125 | } 126 | 127 | /** 128 | * Get request. 129 | * 130 | * @return Response 131 | */ 132 | public function getResponse(): Response 133 | { 134 | return $this->response; 135 | } 136 | 137 | /** 138 | * Get file checksum. 139 | * 140 | * @param string $filePath 141 | * 142 | * @return string 143 | */ 144 | public function getServerChecksum(string $filePath): string 145 | { 146 | return hash_file($this->getChecksumAlgorithm(), $filePath); 147 | } 148 | 149 | /** 150 | * Get checksum algorithm. 151 | * 152 | * @return string|null 153 | */ 154 | public function getChecksumAlgorithm(): ?string 155 | { 156 | $checksumHeader = $this->getRequest()->header('Upload-Checksum'); 157 | 158 | if (empty($checksumHeader)) { 159 | return self::DEFAULT_CHECKSUM_ALGORITHM; 160 | } 161 | 162 | [$checksumAlgorithm, /* $checksum */] = explode(' ', $checksumHeader); 163 | 164 | return $checksumAlgorithm; 165 | } 166 | 167 | /** 168 | * Set upload key. 169 | * 170 | * @param string $key 171 | * 172 | * @return Server 173 | */ 174 | public function setUploadKey(string $key): self 175 | { 176 | $this->uploadKey = $key; 177 | 178 | return $this; 179 | } 180 | 181 | /** 182 | * Get upload key from header. 183 | * 184 | * @return string|HttpResponse 185 | */ 186 | public function getUploadKey() 187 | { 188 | if ( ! empty($this->uploadKey)) { 189 | return $this->uploadKey; 190 | } 191 | 192 | $key = $this->getRequest()->header('Upload-Key') ?? Uuid::uuid4()->toString(); 193 | 194 | if (empty($key)) { 195 | return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST); 196 | } 197 | 198 | $this->uploadKey = $key; 199 | 200 | return $this->uploadKey; 201 | } 202 | 203 | /** 204 | * Set middleware. 205 | * 206 | * @param Middleware $middleware 207 | * 208 | * @return self 209 | */ 210 | public function setMiddleware(Middleware $middleware): self 211 | { 212 | $this->middleware = $middleware; 213 | 214 | return $this; 215 | } 216 | 217 | /** 218 | * Get middleware. 219 | * 220 | * @return Middleware 221 | */ 222 | public function middleware(): Middleware 223 | { 224 | return $this->middleware; 225 | } 226 | 227 | /** 228 | * Set max upload size in bytes. 229 | * 230 | * @param int $uploadSize 231 | * 232 | * @return Server 233 | */ 234 | public function setMaxUploadSize(int $uploadSize): self 235 | { 236 | $this->maxUploadSize = $uploadSize; 237 | 238 | return $this; 239 | } 240 | 241 | /** 242 | * Get max upload size. 243 | * 244 | * @return int 245 | */ 246 | public function getMaxUploadSize(): int 247 | { 248 | return $this->maxUploadSize; 249 | } 250 | 251 | /** 252 | * Handle all HTTP request. 253 | * 254 | * @return HttpResponse|BinaryFileResponse 255 | */ 256 | public function serve() 257 | { 258 | $this->applyMiddleware(); 259 | 260 | $requestMethod = $this->getRequest()->method(); 261 | 262 | if ( ! \in_array($requestMethod, $this->getRequest()->allowedHttpVerbs(), true)) { 263 | return $this->response->send(null, HttpResponse::HTTP_METHOD_NOT_ALLOWED); 264 | } 265 | 266 | $clientVersion = $this->getRequest()->header('Tus-Resumable'); 267 | 268 | if (HttpRequest::METHOD_OPTIONS !== $requestMethod && $clientVersion && self::TUS_PROTOCOL_VERSION !== $clientVersion) { 269 | return $this->response->send(null, HttpResponse::HTTP_PRECONDITION_FAILED, [ 270 | 'Tus-Version' => self::TUS_PROTOCOL_VERSION, 271 | ]); 272 | } 273 | 274 | $method = 'handle' . ucfirst(strtolower($requestMethod)); 275 | 276 | return $this->{$method}(); 277 | } 278 | 279 | /** 280 | * Apply middleware. 281 | * 282 | * @return void 283 | */ 284 | protected function applyMiddleware() 285 | { 286 | $middleware = $this->middleware()->list(); 287 | 288 | foreach ($middleware as $m) { 289 | $m->handle($this->getRequest(), $this->getResponse()); 290 | } 291 | } 292 | 293 | /** 294 | * Handle OPTIONS request. 295 | * 296 | * @return HttpResponse 297 | */ 298 | protected function handleOptions(): HttpResponse 299 | { 300 | $headers = [ 301 | 'Allow' => implode(',', $this->request->allowedHttpVerbs()), 302 | 'Tus-Version' => self::TUS_PROTOCOL_VERSION, 303 | 'Tus-Extension' => implode(',', self::TUS_EXTENSIONS), 304 | 'Tus-Checksum-Algorithm' => $this->getSupportedHashAlgorithms(), 305 | ]; 306 | 307 | $maxUploadSize = $this->getMaxUploadSize(); 308 | 309 | if ($maxUploadSize > 0) { 310 | $headers['Tus-Max-Size'] = $maxUploadSize; 311 | } 312 | 313 | return $this->response->send(null, HttpResponse::HTTP_OK, $headers); 314 | } 315 | 316 | /** 317 | * Handle HEAD request. 318 | * 319 | * @return HttpResponse 320 | */ 321 | protected function handleHead(): HttpResponse 322 | { 323 | $key = $this->request->key(); 324 | 325 | if ( ! $fileMeta = $this->cache->get($key)) { 326 | return $this->response->send(null, HttpResponse::HTTP_NOT_FOUND); 327 | } 328 | 329 | $offset = $fileMeta['offset'] ?? false; 330 | 331 | if (false === $offset) { 332 | return $this->response->send(null, HttpResponse::HTTP_GONE); 333 | } 334 | 335 | return $this->response->send(null, HttpResponse::HTTP_OK, $this->getHeadersForHeadRequest($fileMeta)); 336 | } 337 | 338 | /** 339 | * Handle POST request. 340 | * 341 | * @return HttpResponse 342 | */ 343 | protected function handlePost(): HttpResponse 344 | { 345 | $fileName = $this->getRequest()->extractFileName(); 346 | $uploadType = self::UPLOAD_TYPE_NORMAL; 347 | 348 | if (empty($fileName)) { 349 | return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST); 350 | } 351 | 352 | if ( ! $this->verifyUploadSize()) { 353 | return $this->response->send(null, HttpResponse::HTTP_REQUEST_ENTITY_TOO_LARGE); 354 | } 355 | 356 | $uploadKey = $this->getUploadKey(); 357 | $filePath = $this->uploadDir . '/' . $fileName; 358 | 359 | if ($this->getRequest()->isFinal()) { 360 | return $this->handleConcatenation($fileName, $filePath); 361 | } 362 | 363 | if ($this->getRequest()->isPartial()) { 364 | $filePath = $this->getPathForPartialUpload($uploadKey) . $fileName; 365 | $uploadType = self::UPLOAD_TYPE_PARTIAL; 366 | } 367 | 368 | $checksum = $this->getClientChecksum(); 369 | $location = $this->getRequest()->url() . $this->getApiPath() . '/' . $uploadKey; 370 | 371 | $file = $this->buildFile([ 372 | 'name' => $fileName, 373 | 'offset' => 0, 374 | 'size' => $this->getRequest()->header('Upload-Length'), 375 | 'file_path' => $filePath, 376 | 'location' => $location, 377 | ])->setKey($uploadKey)->setChecksum($checksum)->setUploadMetadata($this->getRequest()->extractAllMeta()); 378 | 379 | $this->cache->set($uploadKey, $file->details() + ['upload_type' => $uploadType]); 380 | 381 | $headers = [ 382 | 'Location' => $location, 383 | 'Upload-Expires' => $this->cache->get($uploadKey)['expires_at'], 384 | ]; 385 | 386 | $this->event()->dispatch( 387 | new UploadCreated($file, $this->getRequest(), $this->getResponse()->setHeaders($headers)), 388 | UploadCreated::NAME 389 | ); 390 | 391 | return $this->response->send(null, HttpResponse::HTTP_CREATED, $headers); 392 | } 393 | 394 | /** 395 | * Handle file concatenation. 396 | * 397 | * @param string $fileName 398 | * @param string $filePath 399 | * 400 | * @return HttpResponse 401 | */ 402 | protected function handleConcatenation(string $fileName, string $filePath): HttpResponse 403 | { 404 | $partials = $this->getRequest()->extractPartials(); 405 | $uploadKey = $this->getUploadKey(); 406 | $files = $this->getPartialsMeta($partials); 407 | $filePaths = array_column($files, 'file_path'); 408 | $location = $this->getRequest()->url() . $this->getApiPath() . '/' . $uploadKey; 409 | 410 | $file = $this->buildFile([ 411 | 'name' => $fileName, 412 | 'offset' => 0, 413 | 'size' => 0, 414 | 'file_path' => $filePath, 415 | 'location' => $location, 416 | ])->setFilePath($filePath)->setKey($uploadKey)->setUploadMetadata($this->getRequest()->extractAllMeta()); 417 | 418 | $file->setOffset($file->merge($files)); 419 | 420 | // Verify checksum. 421 | $checksum = $this->getServerChecksum($filePath); 422 | 423 | if ($checksum !== $this->getClientChecksum()) { 424 | return $this->response->send(null, self::HTTP_CHECKSUM_MISMATCH); 425 | } 426 | 427 | $file->setChecksum($checksum); 428 | $this->cache->set($uploadKey, $file->details() + ['upload_type' => self::UPLOAD_TYPE_FINAL]); 429 | 430 | // Cleanup. 431 | if ($file->delete($filePaths, true)) { 432 | $this->cache->deleteAll($partials); 433 | } 434 | 435 | $this->event()->dispatch( 436 | new UploadMerged($file, $this->getRequest(), $this->getResponse()), 437 | UploadMerged::NAME 438 | ); 439 | 440 | return $this->response->send( 441 | ['data' => ['checksum' => $checksum]], 442 | HttpResponse::HTTP_CREATED, 443 | [ 444 | 'Location' => $location, 445 | ] 446 | ); 447 | } 448 | 449 | /** 450 | * Handle PATCH request. 451 | * 452 | * @return HttpResponse 453 | */ 454 | protected function handlePatch(): HttpResponse 455 | { 456 | $uploadKey = $this->request->key(); 457 | 458 | if ( ! $meta = $this->cache->get($uploadKey)) { 459 | return $this->response->send(null, HttpResponse::HTTP_GONE); 460 | } 461 | 462 | $status = $this->verifyPatchRequest($meta); 463 | 464 | if (HttpResponse::HTTP_OK !== $status) { 465 | return $this->response->send(null, $status); 466 | } 467 | 468 | $file = $this->buildFile($meta)->setUploadMetadata($meta['metadata'] ?? []); 469 | $checksum = $meta['checksum']; 470 | 471 | try { 472 | $fileSize = $file->getFileSize(); 473 | $offset = $file->setKey($uploadKey)->setChecksum($checksum)->upload($fileSize); 474 | 475 | // If upload is done, verify checksum. 476 | if ($offset === $fileSize) { 477 | if ( ! $this->verifyChecksum($checksum, $meta['file_path'])) { 478 | return $this->response->send(null, self::HTTP_CHECKSUM_MISMATCH); 479 | } 480 | 481 | $this->event()->dispatch( 482 | new UploadComplete($file, $this->getRequest(), $this->getResponse()), 483 | UploadComplete::NAME 484 | ); 485 | } else { 486 | $this->event()->dispatch( 487 | new UploadProgress($file, $this->getRequest(), $this->getResponse()), 488 | UploadProgress::NAME 489 | ); 490 | } 491 | } catch (FileException $e) { 492 | return $this->response->send($e->getMessage(), HttpResponse::HTTP_UNPROCESSABLE_ENTITY); 493 | } catch (OutOfRangeException $e) { 494 | return $this->response->send(null, HttpResponse::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE); 495 | } catch (ConnectionException $e) { 496 | return $this->response->send(null, HttpResponse::HTTP_CONTINUE); 497 | } 498 | 499 | if ( ! $meta = $this->cache->get($uploadKey)) { 500 | return $this->response->send(null, HttpResponse::HTTP_GONE); 501 | } 502 | 503 | return $this->response->send(null, HttpResponse::HTTP_NO_CONTENT, [ 504 | 'Content-Type' => self::HEADER_CONTENT_TYPE, 505 | 'Upload-Expires' => $meta['expires_at'], 506 | 'Upload-Offset' => $offset, 507 | ]); 508 | } 509 | 510 | /** 511 | * Verify PATCH request. 512 | * 513 | * @param array $meta 514 | * 515 | * @return int 516 | */ 517 | protected function verifyPatchRequest(array $meta): int 518 | { 519 | if (self::UPLOAD_TYPE_FINAL === $meta['upload_type']) { 520 | return HttpResponse::HTTP_FORBIDDEN; 521 | } 522 | 523 | $uploadOffset = $this->request->header('upload-offset'); 524 | 525 | if ($uploadOffset && $uploadOffset !== (string) $meta['offset']) { 526 | return HttpResponse::HTTP_CONFLICT; 527 | } 528 | 529 | $contentType = $this->request->header('Content-Type'); 530 | 531 | if ($contentType !== self::HEADER_CONTENT_TYPE) { 532 | return HTTPRESPONSE::HTTP_UNSUPPORTED_MEDIA_TYPE; 533 | } 534 | 535 | return HttpResponse::HTTP_OK; 536 | } 537 | 538 | /** 539 | * Handle GET request. 540 | * 541 | * As per RFC7231, we need to treat HEAD and GET as an identical request. 542 | * All major PHP frameworks follows the same and silently transforms each 543 | * HEAD requests to GET. 544 | * 545 | * @return BinaryFileResponse|HttpResponse 546 | */ 547 | protected function handleGet() 548 | { 549 | // We will treat '/files//get' as a download request. 550 | if ('get' === $this->request->key()) { 551 | return $this->handleDownload(); 552 | } 553 | 554 | return $this->handleHead(); 555 | } 556 | 557 | /** 558 | * Handle Download request. 559 | * 560 | * @return BinaryFileResponse|HttpResponse 561 | */ 562 | protected function handleDownload() 563 | { 564 | $path = explode('/', str_replace('/get', '', $this->request->path())); 565 | $key = end($path); 566 | 567 | if ( ! $fileMeta = $this->cache->get($key)) { 568 | return $this->response->send('404 upload not found.', HttpResponse::HTTP_NOT_FOUND); 569 | } 570 | 571 | $resource = $fileMeta['file_path'] ?? null; 572 | $fileName = $fileMeta['name'] ?? null; 573 | 574 | if ( ! $resource || ! file_exists($resource)) { 575 | return $this->response->send('404 upload not found.', HttpResponse::HTTP_NOT_FOUND); 576 | } 577 | 578 | return $this->response->download($resource, $fileName); 579 | } 580 | 581 | /** 582 | * Handle DELETE request. 583 | * 584 | * @return HttpResponse 585 | */ 586 | protected function handleDelete(): HttpResponse 587 | { 588 | $key = $this->request->key(); 589 | $fileMeta = $this->cache->get($key); 590 | $resource = $fileMeta['file_path'] ?? null; 591 | 592 | if ( ! $resource) { 593 | return $this->response->send(null, HttpResponse::HTTP_NOT_FOUND); 594 | } 595 | 596 | $isDeleted = $this->cache->delete($key); 597 | 598 | if ( ! $isDeleted || ! file_exists($resource)) { 599 | return $this->response->send(null, HttpResponse::HTTP_GONE); 600 | } 601 | 602 | unlink($resource); 603 | 604 | return $this->response->send(null, HttpResponse::HTTP_NO_CONTENT, [ 605 | 'Tus-Extension' => self::TUS_EXTENSION_TERMINATION, 606 | ]); 607 | } 608 | 609 | /** 610 | * Get required headers for head request. 611 | * 612 | * @param array $fileMeta 613 | * 614 | * @return array 615 | */ 616 | protected function getHeadersForHeadRequest(array $fileMeta): array 617 | { 618 | $headers = [ 619 | 'Upload-Length' => (int) $fileMeta['size'], 620 | 'Upload-Offset' => (int) $fileMeta['offset'], 621 | 'Cache-Control' => 'no-store', 622 | ]; 623 | 624 | if (self::UPLOAD_TYPE_FINAL === $fileMeta['upload_type'] && $fileMeta['size'] !== $fileMeta['offset']) { 625 | unset($headers['Upload-Offset']); 626 | } 627 | 628 | if (self::UPLOAD_TYPE_NORMAL !== $fileMeta['upload_type']) { 629 | $headers += ['Upload-Concat' => $fileMeta['upload_type']]; 630 | } 631 | 632 | return $headers; 633 | } 634 | 635 | /** 636 | * Build file object. 637 | * 638 | * @param array $meta 639 | * 640 | * @return File 641 | */ 642 | protected function buildFile(array $meta): File 643 | { 644 | $file = new File($meta['name'], $this->cache); 645 | 646 | if (\array_key_exists('offset', $meta)) { 647 | $file->setMeta($meta['offset'], $meta['size'], $meta['file_path'], $meta['location']); 648 | } 649 | 650 | return $file; 651 | } 652 | 653 | /** 654 | * Get list of supported hash algorithms. 655 | * 656 | * @return string 657 | */ 658 | protected function getSupportedHashAlgorithms(): string 659 | { 660 | $supportedAlgorithms = hash_algos(); 661 | 662 | $algorithms = []; 663 | foreach ($supportedAlgorithms as $hashAlgo) { 664 | if (false !== strpos($hashAlgo, ',')) { 665 | $algorithms[] = "'{$hashAlgo}'"; 666 | } else { 667 | $algorithms[] = $hashAlgo; 668 | } 669 | } 670 | 671 | return implode(',', $algorithms); 672 | } 673 | 674 | /** 675 | * Verify and get upload checksum from header. 676 | * 677 | * @return string|HttpResponse 678 | */ 679 | protected function getClientChecksum() 680 | { 681 | $checksumHeader = $this->getRequest()->header('Upload-Checksum'); 682 | 683 | if (empty($checksumHeader)) { 684 | return ''; 685 | } 686 | 687 | [$checksumAlgorithm, $checksum] = explode(' ', $checksumHeader); 688 | 689 | $checksum = base64_decode($checksum); 690 | 691 | if (false === $checksum || ! \in_array($checksumAlgorithm, hash_algos(), true)) { 692 | return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST); 693 | } 694 | 695 | return $checksum; 696 | } 697 | 698 | /** 699 | * Get expired but incomplete uploads. 700 | * 701 | * @param array|null $contents 702 | * 703 | * @return bool 704 | */ 705 | protected function isExpired($contents): bool 706 | { 707 | if (empty($contents)) { 708 | return true; 709 | } 710 | 711 | $isExpired = empty($contents['expires_at']) || Carbon::parse($contents['expires_at'])->lt(Carbon::now()); 712 | 713 | if ($isExpired && $contents['offset'] !== $contents['size']) { 714 | return true; 715 | } 716 | 717 | return false; 718 | } 719 | 720 | /** 721 | * Get path for partial upload. 722 | * 723 | * @param string $key 724 | * 725 | * @return string 726 | */ 727 | protected function getPathForPartialUpload(string $key): string 728 | { 729 | [$actualKey, /* $partialUploadKey */] = explode(self::PARTIAL_UPLOAD_NAME_SEPARATOR, $key); 730 | 731 | $path = $this->uploadDir . '/' . $actualKey . '/'; 732 | 733 | if ( ! file_exists($path)) { 734 | mkdir($path); 735 | } 736 | 737 | return $path; 738 | } 739 | 740 | /** 741 | * Get metadata of partials. 742 | * 743 | * @param array $partials 744 | * 745 | * @return array 746 | */ 747 | protected function getPartialsMeta(array $partials): array 748 | { 749 | $files = []; 750 | 751 | foreach ($partials as $partial) { 752 | $fileMeta = $this->getCache()->get($partial); 753 | 754 | $files[] = $fileMeta; 755 | } 756 | 757 | return $files; 758 | } 759 | 760 | /** 761 | * Delete expired resources. 762 | * 763 | * @return array 764 | */ 765 | public function handleExpiration(): array 766 | { 767 | $deleted = []; 768 | $cacheKeys = $this->cache->keys(); 769 | 770 | foreach ($cacheKeys as $key) { 771 | $fileMeta = $this->cache->get($key, true); 772 | 773 | if ( ! $this->isExpired($fileMeta)) { 774 | continue; 775 | } 776 | 777 | if ( ! $this->cache->delete($key)) { 778 | continue; 779 | } 780 | 781 | if (is_writable($fileMeta['file_path'])) { 782 | unlink($fileMeta['file_path']); 783 | } 784 | 785 | $deleted[] = $fileMeta; 786 | } 787 | 788 | return $deleted; 789 | } 790 | 791 | /** 792 | * Verify max upload size. 793 | * 794 | * @return bool 795 | */ 796 | protected function verifyUploadSize(): bool 797 | { 798 | $maxUploadSize = $this->getMaxUploadSize(); 799 | 800 | if ($maxUploadSize > 0 && $this->getRequest()->header('Upload-Length') > $maxUploadSize) { 801 | return false; 802 | } 803 | 804 | return true; 805 | } 806 | 807 | /** 808 | * Verify checksum if available. 809 | * 810 | * @param string $checksum 811 | * @param string $filePath 812 | * 813 | * @return bool 814 | */ 815 | protected function verifyChecksum(string $checksum, string $filePath): bool 816 | { 817 | // Skip if checksum is empty. 818 | if (empty($checksum)) { 819 | return true; 820 | } 821 | 822 | return $checksum === $this->getServerChecksum($filePath); 823 | } 824 | 825 | /** 826 | * No other methods are allowed. 827 | * 828 | * @param string $method 829 | * @param array $params 830 | * 831 | * @return HttpResponse 832 | */ 833 | public function __call(string $method, array $params) 834 | { 835 | return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST); 836 | } 837 | } 838 | --------------------------------------------------------------------------------