├── 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 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | Resumable file upload in PHP using tus resumable upload protocol v1.0.0
26 |
27 |
28 |
29 | 
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 | |  |
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 |
--------------------------------------------------------------------------------