1 | <?php |
||||
2 | |||||
3 | namespace TusPhp\Tus; |
||||
4 | |||||
5 | use TusPhp\File; |
||||
6 | use Carbon\Carbon; |
||||
7 | use TusPhp\Request; |
||||
8 | use TusPhp\Response; |
||||
9 | use Ramsey\Uuid\Uuid; |
||||
10 | use TusPhp\Cache\Cacheable; |
||||
11 | use TusPhp\Events\UploadMerged; |
||||
12 | use TusPhp\Events\UploadCreated; |
||||
13 | use TusPhp\Events\UploadComplete; |
||||
14 | use TusPhp\Events\UploadProgress; |
||||
15 | use TusPhp\Middleware\Middleware; |
||||
16 | use TusPhp\Exception\FileException; |
||||
17 | use TusPhp\Exception\ConnectionException; |
||||
18 | use TusPhp\Exception\OutOfRangeException; |
||||
19 | use Symfony\Component\HttpFoundation\BinaryFileResponse; |
||||
20 | use Symfony\Component\HttpFoundation\Response as HttpResponse; |
||||
21 | |||||
22 | class Server extends AbstractTus |
||||
23 | { |
||||
24 | /** @const string Tus Creation Extension */ |
||||
25 | public const TUS_EXTENSION_CREATION = 'creation'; |
||||
26 | |||||
27 | /** @const string Tus Termination Extension */ |
||||
28 | public const TUS_EXTENSION_TERMINATION = 'termination'; |
||||
29 | |||||
30 | /** @const string Tus Checksum Extension */ |
||||
31 | public const TUS_EXTENSION_CHECKSUM = 'checksum'; |
||||
32 | |||||
33 | /** @const string Tus Expiration Extension */ |
||||
34 | public const TUS_EXTENSION_EXPIRATION = 'expiration'; |
||||
35 | |||||
36 | /** @const string Tus Concatenation Extension */ |
||||
37 | public const TUS_EXTENSION_CONCATENATION = 'concatenation'; |
||||
38 | |||||
39 | /** @const array All supported tus extensions */ |
||||
40 | public const TUS_EXTENSIONS = [ |
||||
41 | self::TUS_EXTENSION_CREATION, |
||||
42 | self::TUS_EXTENSION_TERMINATION, |
||||
43 | self::TUS_EXTENSION_CHECKSUM, |
||||
44 | self::TUS_EXTENSION_EXPIRATION, |
||||
45 | self::TUS_EXTENSION_CONCATENATION, |
||||
46 | ]; |
||||
47 | |||||
48 | /** @const int 460 Checksum Mismatch */ |
||||
49 | private const HTTP_CHECKSUM_MISMATCH = 460; |
||||
50 | |||||
51 | /** @const string Default checksum algorithm */ |
||||
52 | private const DEFAULT_CHECKSUM_ALGORITHM = 'sha256'; |
||||
53 | |||||
54 | /** @var Request */ |
||||
55 | protected $request; |
||||
56 | |||||
57 | /** @var Response */ |
||||
58 | protected $response; |
||||
59 | |||||
60 | /** @var string */ |
||||
61 | protected $uploadDir; |
||||
62 | |||||
63 | /** @var string */ |
||||
64 | protected $uploadKey; |
||||
65 | |||||
66 | /** @var Middleware */ |
||||
67 | protected $middleware; |
||||
68 | |||||
69 | /** |
||||
70 | * @var int Max upload size in bytes |
||||
71 | * Default 0, no restriction. |
||||
72 | */ |
||||
73 | protected $maxUploadSize = 0; |
||||
74 | |||||
75 | /** |
||||
76 | * TusServer constructor. |
||||
77 | * |
||||
78 | * @param Cacheable|string $cacheAdapter |
||||
79 | * |
||||
80 | * @throws \ReflectionException |
||||
81 | */ |
||||
82 | 3 | public function __construct($cacheAdapter = 'file') |
|||
83 | { |
||||
84 | 3 | $this->request = new Request; |
|||
85 | 3 | $this->response = new Response; |
|||
86 | 3 | $this->middleware = new Middleware; |
|||
87 | 3 | $this->uploadDir = \dirname(__DIR__, 2) . '/' . 'uploads'; |
|||
88 | |||||
89 | 3 | $this->setCache($cacheAdapter); |
|||
90 | 3 | } |
|||
91 | |||||
92 | /** |
||||
93 | * Set upload dir. |
||||
94 | * |
||||
95 | * @param string $path |
||||
96 | * |
||||
97 | * @return Server |
||||
98 | */ |
||||
99 | 2 | public function setUploadDir(string $path) : self |
|||
100 | { |
||||
101 | 2 | $this->uploadDir = $path; |
|||
102 | |||||
103 | 2 | return $this; |
|||
104 | } |
||||
105 | |||||
106 | /** |
||||
107 | * Get upload dir. |
||||
108 | * |
||||
109 | * @return string |
||||
110 | */ |
||||
111 | 1 | public function getUploadDir() : string |
|||
112 | { |
||||
113 | 1 | return $this->uploadDir; |
|||
114 | } |
||||
115 | |||||
116 | /** |
||||
117 | * Get request. |
||||
118 | * |
||||
119 | * @return Request |
||||
120 | */ |
||||
121 | 1 | public function getRequest() : Request |
|||
122 | { |
||||
123 | 1 | return $this->request; |
|||
124 | } |
||||
125 | |||||
126 | /** |
||||
127 | * Get request. |
||||
128 | * |
||||
129 | * @return Response |
||||
130 | */ |
||||
131 | 1 | public function getResponse() : Response |
|||
132 | { |
||||
133 | 1 | return $this->response; |
|||
134 | } |
||||
135 | |||||
136 | /** |
||||
137 | * Get file checksum. |
||||
138 | * |
||||
139 | * @param string $filePath |
||||
140 | * |
||||
141 | * @return string |
||||
142 | */ |
||||
143 | 1 | public function getServerChecksum(string $filePath) : string |
|||
144 | { |
||||
145 | 1 | return hash_file($this->getChecksumAlgorithm(), $filePath); |
|||
146 | } |
||||
147 | |||||
148 | /** |
||||
149 | * Get checksum algorithm. |
||||
150 | * |
||||
151 | * @return string|null |
||||
152 | */ |
||||
153 | 1 | public function getChecksumAlgorithm() : ?string |
|||
154 | { |
||||
155 | 1 | $checksumHeader = $this->getRequest()->header('Upload-Checksum'); |
|||
156 | |||||
157 | 1 | if (empty($checksumHeader)) { |
|||
158 | 1 | return self::DEFAULT_CHECKSUM_ALGORITHM; |
|||
159 | } |
||||
160 | |||||
161 | 1 | [$checksumAlgorithm, /* $checksum */] = explode(' ', $checksumHeader); |
|||
162 | |||||
163 | 1 | return $checksumAlgorithm; |
|||
164 | } |
||||
165 | |||||
166 | /** |
||||
167 | * Set upload key. |
||||
168 | * |
||||
169 | * @param string $key |
||||
170 | * |
||||
171 | * @return Server |
||||
172 | */ |
||||
173 | 1 | public function setUploadKey(string $key) : self |
|||
174 | { |
||||
175 | 1 | $this->uploadKey = $key; |
|||
176 | |||||
177 | 1 | return $this; |
|||
178 | } |
||||
179 | |||||
180 | /** |
||||
181 | * Get upload key from header. |
||||
182 | * |
||||
183 | * @return string|HttpResponse |
||||
184 | */ |
||||
185 | 4 | public function getUploadKey() |
|||
186 | { |
||||
187 | 4 | if ( ! empty($this->uploadKey)) { |
|||
188 | 1 | return $this->uploadKey; |
|||
189 | } |
||||
190 | |||||
191 | 3 | $key = $this->getRequest()->header('Upload-Key') ?? Uuid::uuid4()->toString(); |
|||
192 | |||||
193 | 3 | if (empty($key)) { |
|||
194 | 1 | return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST); |
|||
195 | } |
||||
196 | |||||
197 | 2 | $this->uploadKey = $key; |
|||
198 | |||||
199 | 2 | return $this->uploadKey; |
|||
200 | } |
||||
201 | |||||
202 | /** |
||||
203 | * Set middleware. |
||||
204 | * |
||||
205 | * @param Middleware $middleware |
||||
206 | * |
||||
207 | * @return self |
||||
208 | */ |
||||
209 | 1 | public function setMiddleware(Middleware $middleware) : self |
|||
210 | { |
||||
211 | 1 | $this->middleware = $middleware; |
|||
212 | |||||
213 | 1 | return $this; |
|||
214 | } |
||||
215 | |||||
216 | /** |
||||
217 | * Get middleware. |
||||
218 | * |
||||
219 | * @return Middleware |
||||
220 | */ |
||||
221 | 1 | public function middleware() : Middleware |
|||
222 | { |
||||
223 | 1 | return $this->middleware; |
|||
224 | } |
||||
225 | |||||
226 | /** |
||||
227 | * Set max upload size. |
||||
228 | * |
||||
229 | * @param int $uploadSize |
||||
230 | * |
||||
231 | * @return Server |
||||
232 | */ |
||||
233 | 2 | public function setMaxUploadSize(int $uploadSize) : self |
|||
234 | { |
||||
235 | 2 | $this->maxUploadSize = $uploadSize; |
|||
236 | |||||
237 | 2 | return $this; |
|||
238 | } |
||||
239 | |||||
240 | /** |
||||
241 | * Get max upload size. |
||||
242 | * |
||||
243 | * @return int |
||||
244 | */ |
||||
245 | 1 | public function getMaxUploadSize() : int |
|||
246 | { |
||||
247 | 1 | return $this->maxUploadSize; |
|||
248 | } |
||||
249 | |||||
250 | /** |
||||
251 | * Handle all HTTP request. |
||||
252 | * |
||||
253 | * @return HttpResponse|BinaryFileResponse |
||||
254 | */ |
||||
255 | 5 | public function serve() |
|||
256 | { |
||||
257 | 5 | $this->applyMiddleware(); |
|||
258 | |||||
259 | 5 | $requestMethod = $this->getRequest()->method(); |
|||
260 | |||||
261 | 5 | if ( ! \in_array($requestMethod, $this->getRequest()->allowedHttpVerbs(), true)) { |
|||
262 | 1 | return $this->response->send(null, HttpResponse::HTTP_METHOD_NOT_ALLOWED); |
|||
263 | } |
||||
264 | |||||
265 | 4 | $clientVersion = $this->getRequest()->header('Tus-Resumable'); |
|||
266 | |||||
267 | 4 | if ($clientVersion && $clientVersion !== self::TUS_PROTOCOL_VERSION) { |
|||
268 | 1 | return $this->response->send(null, HttpResponse::HTTP_PRECONDITION_FAILED, [ |
|||
269 | 1 | 'Tus-Version' => self::TUS_PROTOCOL_VERSION, |
|||
270 | ]); |
||||
271 | } |
||||
272 | |||||
273 | 3 | $method = 'handle' . ucfirst(strtolower($requestMethod)); |
|||
274 | |||||
275 | 3 | return $this->{$method}(); |
|||
276 | } |
||||
277 | |||||
278 | /** |
||||
279 | * Apply middleware. |
||||
280 | * |
||||
281 | * @return void |
||||
282 | */ |
||||
283 | 1 | protected function applyMiddleware() |
|||
284 | { |
||||
285 | 1 | $middleware = $this->middleware()->list(); |
|||
286 | |||||
287 | 1 | foreach ($middleware as $m) { |
|||
288 | 1 | $m->handle($this->getRequest(), $this->getResponse()); |
|||
289 | } |
||||
290 | 1 | } |
|||
291 | |||||
292 | /** |
||||
293 | * Handle OPTIONS request. |
||||
294 | * |
||||
295 | * @return HttpResponse |
||||
296 | */ |
||||
297 | 2 | protected function handleOptions() : HttpResponse |
|||
298 | { |
||||
299 | $headers = [ |
||||
300 | 2 | 'Allow' => implode(',', $this->request->allowedHttpVerbs()), |
|||
301 | 2 | 'Tus-Version' => self::TUS_PROTOCOL_VERSION, |
|||
302 | 2 | 'Tus-Extension' => implode(',', self::TUS_EXTENSIONS), |
|||
303 | 2 | 'Tus-Checksum-Algorithm' => $this->getSupportedHashAlgorithms(), |
|||
304 | ]; |
||||
305 | |||||
306 | 2 | $maxUploadSize = $this->getMaxUploadSize(); |
|||
307 | |||||
308 | 2 | if ($maxUploadSize > 0) { |
|||
309 | 1 | $headers['Tus-Max-Size'] = $maxUploadSize; |
|||
310 | } |
||||
311 | |||||
312 | 2 | return $this->response->send(null, HttpResponse::HTTP_OK, $headers); |
|||
313 | } |
||||
314 | |||||
315 | /** |
||||
316 | * Handle HEAD request. |
||||
317 | * |
||||
318 | * @return HttpResponse |
||||
319 | */ |
||||
320 | 5 | protected function handleHead() : HttpResponse |
|||
321 | { |
||||
322 | 5 | $key = $this->request->key(); |
|||
323 | |||||
324 | 5 | if ( ! $fileMeta = $this->cache->get($key)) { |
|||
325 | 1 | return $this->response->send(null, HttpResponse::HTTP_NOT_FOUND); |
|||
326 | } |
||||
327 | |||||
328 | 4 | $offset = $fileMeta['offset'] ?? false; |
|||
329 | |||||
330 | 4 | if (false === $offset) { |
|||
331 | 1 | return $this->response->send(null, HttpResponse::HTTP_GONE); |
|||
332 | } |
||||
333 | |||||
334 | 3 | return $this->response->send(null, HttpResponse::HTTP_OK, $this->getHeadersForHeadRequest($fileMeta)); |
|||
335 | } |
||||
336 | |||||
337 | /** |
||||
338 | * Handle POST request. |
||||
339 | * |
||||
340 | * @return HttpResponse |
||||
341 | */ |
||||
342 | 5 | protected function handlePost() : HttpResponse |
|||
343 | { |
||||
344 | 5 | $fileName = $this->getRequest()->extractFileName(); |
|||
345 | 5 | $uploadType = self::UPLOAD_TYPE_NORMAL; |
|||
346 | |||||
347 | 5 | if (empty($fileName)) { |
|||
348 | 1 | return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST); |
|||
349 | } |
||||
350 | |||||
351 | 4 | if ( ! $this->verifyUploadSize()) { |
|||
352 | 1 | return $this->response->send(null, HttpResponse::HTTP_REQUEST_ENTITY_TOO_LARGE); |
|||
353 | } |
||||
354 | |||||
355 | 3 | $uploadKey = $this->getUploadKey(); |
|||
356 | 3 | $filePath = $this->uploadDir . '/' . $fileName; |
|||
357 | |||||
358 | 3 | if ($this->getRequest()->isFinal()) { |
|||
359 | 1 | return $this->handleConcatenation($fileName, $filePath); |
|||
360 | } |
||||
361 | |||||
362 | 2 | if ($this->getRequest()->isPartial()) { |
|||
363 | 1 | $filePath = $this->getPathForPartialUpload($uploadKey) . $fileName; |
|||
364 | 1 | $uploadType = self::UPLOAD_TYPE_PARTIAL; |
|||
365 | } |
||||
366 | |||||
367 | 2 | $checksum = $this->getClientChecksum(); |
|||
368 | 2 | $location = $this->getRequest()->url() . $this->getApiPath() . '/' . $uploadKey; |
|||
369 | |||||
370 | 2 | $file = $this->buildFile([ |
|||
371 | 2 | 'name' => $fileName, |
|||
372 | 2 | 'offset' => 0, |
|||
373 | 2 | 'size' => $this->getRequest()->header('Upload-Length'), |
|||
374 | 2 | 'file_path' => $filePath, |
|||
375 | 2 | 'location' => $location, |
|||
376 | 2 | ])->setKey($uploadKey)->setChecksum($checksum)->setUploadMetadata($this->getRequest()->extractAllMeta()); |
|||
377 | |||||
378 | 2 | $this->cache->set($uploadKey, $file->details() + ['upload_type' => $uploadType]); |
|||
379 | |||||
380 | $headers = [ |
||||
381 | 2 | 'Location' => $location, |
|||
382 | 2 | 'Upload-Expires' => $this->cache->get($uploadKey)['expires_at'], |
|||
383 | ]; |
||||
384 | |||||
385 | 2 | $this->event()->dispatch(UploadCreated::NAME, new UploadCreated($file, $this->getRequest(), $this->getResponse()->setHeaders($headers))); |
|||
0 ignored issues
–
show
Bug
introduced
by
Loading history...
The call to
Symfony\Contracts\EventD...erInterface::dispatch() has too many arguments starting with new TusPhp\Events\Upload...->setHeaders($headers)) .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue. If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.
Loading history...
|
|||||
386 | |||||
387 | 2 | return $this->response->send(null, HttpResponse::HTTP_CREATED, $headers); |
|||
388 | } |
||||
389 | |||||
390 | /** |
||||
391 | * Handle file concatenation. |
||||
392 | * |
||||
393 | * @param string $fileName |
||||
394 | * @param string $filePath |
||||
395 | * |
||||
396 | * @return HttpResponse |
||||
397 | */ |
||||
398 | 2 | protected function handleConcatenation(string $fileName, string $filePath) : HttpResponse |
|||
399 | { |
||||
400 | 2 | $partials = $this->getRequest()->extractPartials(); |
|||
401 | 2 | $uploadKey = $this->getUploadKey(); |
|||
402 | 2 | $files = $this->getPartialsMeta($partials); |
|||
403 | 2 | $filePaths = array_column($files, 'file_path'); |
|||
404 | 2 | $location = $this->getRequest()->url() . $this->getApiPath() . '/' . $uploadKey; |
|||
405 | |||||
406 | 2 | $file = $this->buildFile([ |
|||
407 | 2 | 'name' => $fileName, |
|||
408 | 2 | 'offset' => 0, |
|||
409 | 2 | 'size' => 0, |
|||
410 | 2 | 'file_path' => $filePath, |
|||
411 | 2 | 'location' => $location, |
|||
412 | 2 | ])->setFilePath($filePath)->setKey($uploadKey)->setUploadMetadata($this->getRequest()->extractAllMeta()); |
|||
413 | |||||
414 | 2 | $file->setOffset($file->merge($files)); |
|||
415 | |||||
416 | // Verify checksum. |
||||
417 | 2 | $checksum = $this->getServerChecksum($filePath); |
|||
418 | |||||
419 | 2 | if ($checksum !== $this->getClientChecksum()) { |
|||
420 | 1 | return $this->response->send(null, self::HTTP_CHECKSUM_MISMATCH); |
|||
421 | } |
||||
422 | |||||
423 | 1 | $file->setChecksum($checksum); |
|||
424 | 1 | $this->cache->set($uploadKey, $file->details() + ['upload_type' => self::UPLOAD_TYPE_FINAL]); |
|||
425 | |||||
426 | // Cleanup. |
||||
427 | 1 | if ($file->delete($filePaths, true)) { |
|||
428 | 1 | $this->cache->deleteAll($partials); |
|||
429 | } |
||||
430 | |||||
431 | 1 | $this->event()->dispatch( |
|||
432 | 1 | UploadMerged::NAME, |
|||
0 ignored issues
–
show
TusPhp\Events\UploadMerged::NAME of type string is incompatible with the type object expected by parameter $event of Symfony\Contracts\EventD...erInterface::dispatch() .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
Loading history...
|
|||||
433 | 1 | new UploadMerged($file, $this->getRequest(), $this->getResponse()) |
|||
0 ignored issues
–
show
The call to
Symfony\Contracts\EventD...erInterface::dispatch() has too many arguments starting with new TusPhp\Events\Upload..., $this->getResponse()) .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue. If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.
Loading history...
|
|||||
434 | ); |
||||
435 | |||||
436 | 1 | return $this->response->send( |
|||
437 | 1 | ['data' => ['checksum' => $checksum]], |
|||
438 | 1 | HttpResponse::HTTP_CREATED, |
|||
439 | [ |
||||
440 | 1 | 'Location' => $location, |
|||
441 | ] |
||||
442 | ); |
||||
443 | } |
||||
444 | |||||
445 | /** |
||||
446 | * Handle PATCH request. |
||||
447 | * |
||||
448 | * @return HttpResponse |
||||
449 | */ |
||||
450 | 10 | protected function handlePatch() : HttpResponse |
|||
451 | { |
||||
452 | 10 | $uploadKey = $this->request->key(); |
|||
453 | |||||
454 | 10 | if ( ! $meta = $this->cache->get($uploadKey)) { |
|||
455 | 1 | return $this->response->send(null, HttpResponse::HTTP_GONE); |
|||
456 | } |
||||
457 | |||||
458 | 9 | $status = $this->verifyPatchRequest($meta); |
|||
459 | |||||
460 | 9 | if (HttpResponse::HTTP_OK !== $status) { |
|||
461 | 3 | return $this->response->send(null, $status); |
|||
462 | } |
||||
463 | |||||
464 | 6 | $file = $this->buildFile($meta)->setUploadMetadata($meta['metadata'] ?? []); |
|||
465 | 6 | $checksum = $meta['checksum']; |
|||
466 | |||||
467 | try { |
||||
468 | 6 | $fileSize = $file->getFileSize(); |
|||
469 | 6 | $offset = $file->setKey($uploadKey)->setChecksum($checksum)->upload($fileSize); |
|||
470 | |||||
471 | // If upload is done, verify checksum. |
||||
472 | 3 | if ($offset === $fileSize) { |
|||
473 | 2 | if ( ! $this->verifyChecksum($checksum, $meta['file_path'])) { |
|||
474 | 1 | return $this->response->send(null, self::HTTP_CHECKSUM_MISMATCH); |
|||
475 | } |
||||
476 | |||||
477 | 1 | $this->event()->dispatch( |
|||
478 | 1 | UploadComplete::NAME, |
|||
0 ignored issues
–
show
TusPhp\Events\UploadComplete::NAME of type string is incompatible with the type object expected by parameter $event of Symfony\Contracts\EventD...erInterface::dispatch() .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
Loading history...
|
|||||
479 | 1 | new UploadComplete($file, $this->getRequest(), $this->getResponse()) |
|||
0 ignored issues
–
show
The call to
Symfony\Contracts\EventD...erInterface::dispatch() has too many arguments starting with new TusPhp\Events\Upload..., $this->getResponse()) .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue. If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.
Loading history...
|
|||||
480 | ); |
||||
481 | } else { |
||||
482 | 1 | $this->event()->dispatch( |
|||
483 | 1 | UploadProgress::NAME, |
|||
484 | 2 | new UploadProgress($file, $this->getRequest(), $this->getResponse()) |
|||
485 | ); |
||||
486 | } |
||||
487 | 3 | } catch (FileException $e) { |
|||
488 | 1 | return $this->response->send($e->getMessage(), HttpResponse::HTTP_UNPROCESSABLE_ENTITY); |
|||
489 | 2 | } catch (OutOfRangeException $e) { |
|||
490 | 1 | return $this->response->send(null, HttpResponse::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE); |
|||
491 | 1 | } catch (ConnectionException $e) { |
|||
492 | 1 | return $this->response->send(null, HttpResponse::HTTP_CONTINUE); |
|||
493 | } |
||||
494 | |||||
495 | 2 | return $this->response->send(null, HttpResponse::HTTP_NO_CONTENT, [ |
|||
496 | 2 | 'Content-Type' => self::HEADER_CONTENT_TYPE, |
|||
497 | 2 | 'Upload-Expires' => $this->cache->get($uploadKey)['expires_at'], |
|||
498 | 2 | 'Upload-Offset' => $offset, |
|||
499 | ]); |
||||
500 | } |
||||
501 | |||||
502 | /** |
||||
503 | * Verify PATCH request. |
||||
504 | * |
||||
505 | * @param array $meta |
||||
506 | * |
||||
507 | * @return int |
||||
508 | */ |
||||
509 | 9 | protected function verifyPatchRequest(array $meta) : int |
|||
510 | { |
||||
511 | 9 | if (self::UPLOAD_TYPE_FINAL === $meta['upload_type']) { |
|||
512 | 1 | return HttpResponse::HTTP_FORBIDDEN; |
|||
513 | } |
||||
514 | |||||
515 | 8 | $uploadOffset = $this->request->header('upload-offset'); |
|||
516 | |||||
517 | 8 | if ($uploadOffset && $uploadOffset !== (string) $meta['offset']) { |
|||
518 | 1 | return HttpResponse::HTTP_CONFLICT; |
|||
519 | } |
||||
520 | |||||
521 | 7 | $contentType = $this->request->header('Content-Type'); |
|||
522 | |||||
523 | 7 | if ($contentType !== self::HEADER_CONTENT_TYPE) { |
|||
524 | 1 | return HTTPRESPONSE::HTTP_UNSUPPORTED_MEDIA_TYPE; |
|||
525 | } |
||||
526 | |||||
527 | 6 | return HttpResponse::HTTP_OK; |
|||
528 | } |
||||
529 | |||||
530 | /** |
||||
531 | * Handle GET request. |
||||
532 | * |
||||
533 | * As per RFC7231, we need to treat HEAD and GET as an identical request. |
||||
534 | * All major PHP frameworks follows the same and silently transforms each |
||||
535 | * HEAD requests to GET. |
||||
536 | * |
||||
537 | * @return BinaryFileResponse|HttpResponse |
||||
538 | */ |
||||
539 | 6 | protected function handleGet() |
|||
540 | { |
||||
541 | // We will treat '/files/<key>/get' as a download request. |
||||
542 | 6 | if ('get' === $this->request->key()) { |
|||
543 | 5 | return $this->handleDownload(); |
|||
544 | } |
||||
545 | |||||
546 | 1 | return $this->handleHead(); |
|||
547 | } |
||||
548 | |||||
549 | /** |
||||
550 | * Handle Download request. |
||||
551 | * |
||||
552 | * @return BinaryFileResponse|HttpResponse |
||||
553 | */ |
||||
554 | 4 | protected function handleDownload() |
|||
555 | { |
||||
556 | 4 | $path = explode('/', str_replace('/get', '', $this->request->path())); |
|||
557 | 4 | $key = end($path); |
|||
558 | |||||
559 | 4 | if ( ! $fileMeta = $this->cache->get($key)) { |
|||
560 | 2 | return $this->response->send('404 upload not found.', HttpResponse::HTTP_NOT_FOUND); |
|||
561 | } |
||||
562 | |||||
563 | 2 | $resource = $fileMeta['file_path'] ?? null; |
|||
564 | 2 | $fileName = $fileMeta['name'] ?? null; |
|||
565 | |||||
566 | 2 | if ( ! $resource || ! file_exists($resource)) { |
|||
567 | 1 | return $this->response->send('404 upload not found.', HttpResponse::HTTP_NOT_FOUND); |
|||
568 | } |
||||
569 | |||||
570 | 1 | return $this->response->download($resource, $fileName); |
|||
571 | } |
||||
572 | |||||
573 | /** |
||||
574 | * Handle DELETE request. |
||||
575 | * |
||||
576 | * @return HttpResponse |
||||
577 | */ |
||||
578 | 3 | protected function handleDelete() : HttpResponse |
|||
579 | { |
||||
580 | 3 | $key = $this->request->key(); |
|||
581 | 3 | $fileMeta = $this->cache->get($key); |
|||
582 | 3 | $resource = $fileMeta['file_path'] ?? null; |
|||
583 | |||||
584 | 3 | if ( ! $resource) { |
|||
585 | 1 | return $this->response->send(null, HttpResponse::HTTP_NOT_FOUND); |
|||
586 | } |
||||
587 | |||||
588 | 2 | $isDeleted = $this->cache->delete($key); |
|||
589 | |||||
590 | 2 | if ( ! $isDeleted || ! file_exists($resource)) { |
|||
591 | 1 | return $this->response->send(null, HttpResponse::HTTP_GONE); |
|||
592 | } |
||||
593 | |||||
594 | 1 | unlink($resource); |
|||
595 | |||||
596 | 1 | return $this->response->send(null, HttpResponse::HTTP_NO_CONTENT, [ |
|||
597 | 1 | 'Tus-Extension' => self::TUS_EXTENSION_TERMINATION, |
|||
598 | ]); |
||||
599 | } |
||||
600 | |||||
601 | /** |
||||
602 | * Get required headers for head request. |
||||
603 | * |
||||
604 | * @param array $fileMeta |
||||
605 | * |
||||
606 | * @return array |
||||
607 | */ |
||||
608 | 4 | protected function getHeadersForHeadRequest(array $fileMeta) : array |
|||
609 | { |
||||
610 | $headers = [ |
||||
611 | 4 | 'Upload-Length' => (int) $fileMeta['size'], |
|||
612 | 4 | 'Upload-Offset' => (int) $fileMeta['offset'], |
|||
613 | 4 | 'Cache-Control' => 'no-store', |
|||
614 | ]; |
||||
615 | |||||
616 | 4 | if (self::UPLOAD_TYPE_FINAL === $fileMeta['upload_type'] && $fileMeta['size'] !== $fileMeta['offset']) { |
|||
617 | 2 | unset($headers['Upload-Offset']); |
|||
618 | } |
||||
619 | |||||
620 | 4 | if (self::UPLOAD_TYPE_NORMAL !== $fileMeta['upload_type']) { |
|||
621 | 3 | $headers += ['Upload-Concat' => $fileMeta['upload_type']]; |
|||
622 | } |
||||
623 | |||||
624 | 4 | return $headers; |
|||
625 | } |
||||
626 | |||||
627 | /** |
||||
628 | * Build file object. |
||||
629 | * |
||||
630 | * @param array $meta |
||||
631 | * |
||||
632 | * @return File |
||||
633 | */ |
||||
634 | 1 | protected function buildFile(array $meta) : File |
|||
635 | { |
||||
636 | 1 | $file = new File($meta['name'], $this->cache); |
|||
637 | |||||
638 | 1 | if (\array_key_exists('offset', $meta)) { |
|||
639 | 1 | $file->setMeta($meta['offset'], $meta['size'], $meta['file_path'], $meta['location']); |
|||
640 | } |
||||
641 | |||||
642 | 1 | return $file; |
|||
643 | } |
||||
644 | |||||
645 | /** |
||||
646 | * Get list of supported hash algorithms. |
||||
647 | * |
||||
648 | * @return string |
||||
649 | */ |
||||
650 | 1 | protected function getSupportedHashAlgorithms() : string |
|||
651 | { |
||||
652 | 1 | $supportedAlgorithms = hash_algos(); |
|||
653 | |||||
654 | 1 | $algorithms = []; |
|||
655 | 1 | foreach ($supportedAlgorithms as $hashAlgo) { |
|||
656 | 1 | if (false !== strpos($hashAlgo, ',')) { |
|||
657 | 1 | $algorithms[] = "'{$hashAlgo}'"; |
|||
658 | } else { |
||||
659 | 1 | $algorithms[] = $hashAlgo; |
|||
660 | } |
||||
661 | } |
||||
662 | |||||
663 | 1 | return implode(',', $algorithms); |
|||
664 | } |
||||
665 | |||||
666 | /** |
||||
667 | * Verify and get upload checksum from header. |
||||
668 | * |
||||
669 | * @return string|HttpResponse |
||||
670 | */ |
||||
671 | 4 | protected function getClientChecksum() |
|||
672 | { |
||||
673 | 4 | $checksumHeader = $this->getRequest()->header('Upload-Checksum'); |
|||
674 | |||||
675 | 4 | if (empty($checksumHeader)) { |
|||
676 | 1 | return ''; |
|||
677 | } |
||||
678 | |||||
679 | 3 | [$checksumAlgorithm, $checksum] = explode(' ', $checksumHeader); |
|||
680 | |||||
681 | 3 | $checksum = base64_decode($checksum); |
|||
682 | |||||
683 | 3 | if (false === $checksum || ! \in_array($checksumAlgorithm, hash_algos(), true)) { |
|||
684 | 2 | return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST); |
|||
685 | } |
||||
686 | |||||
687 | 1 | return $checksum; |
|||
688 | } |
||||
689 | |||||
690 | /** |
||||
691 | * Get expired but incomplete uploads. |
||||
692 | * |
||||
693 | * @param array|null $contents |
||||
694 | * |
||||
695 | * @return bool |
||||
696 | */ |
||||
697 | 3 | protected function isExpired($contents) : bool |
|||
698 | { |
||||
699 | 3 | $isExpired = empty($contents['expires_at']) || Carbon::parse($contents['expires_at'])->lt(Carbon::now()); |
|||
700 | |||||
701 | 3 | if ($isExpired && $contents['offset'] !== $contents['size']) { |
|||
702 | 3 | return true; |
|||
703 | } |
||||
704 | |||||
705 | 2 | return false; |
|||
706 | } |
||||
707 | |||||
708 | /** |
||||
709 | * Get path for partial upload. |
||||
710 | * |
||||
711 | * @param string $key |
||||
712 | * |
||||
713 | * @return string |
||||
714 | */ |
||||
715 | 1 | protected function getPathForPartialUpload(string $key) : string |
|||
716 | { |
||||
717 | 1 | [$actualKey, /* $partialUploadKey */] = explode(self::PARTIAL_UPLOAD_NAME_SEPARATOR, $key); |
|||
718 | |||||
719 | 1 | $path = $this->uploadDir . '/' . $actualKey . '/'; |
|||
720 | |||||
721 | 1 | if ( ! file_exists($path)) { |
|||
722 | 1 | mkdir($path); |
|||
723 | } |
||||
724 | |||||
725 | 1 | return $path; |
|||
726 | } |
||||
727 | |||||
728 | /** |
||||
729 | * Get metadata of partials. |
||||
730 | * |
||||
731 | * @param array $partials |
||||
732 | * |
||||
733 | * @return array |
||||
734 | */ |
||||
735 | 3 | protected function getPartialsMeta(array $partials) : array |
|||
736 | { |
||||
737 | 3 | $files = []; |
|||
738 | |||||
739 | 3 | foreach ($partials as $partial) { |
|||
740 | 3 | $fileMeta = $this->getCache()->get($partial); |
|||
741 | |||||
742 | 3 | $files[] = $fileMeta; |
|||
743 | } |
||||
744 | |||||
745 | 3 | return $files; |
|||
746 | } |
||||
747 | |||||
748 | /** |
||||
749 | * Delete expired resources. |
||||
750 | * |
||||
751 | * @return array |
||||
752 | */ |
||||
753 | 2 | public function handleExpiration() : array |
|||
754 | { |
||||
755 | 2 | $deleted = []; |
|||
756 | 2 | $cacheKeys = $this->cache->keys(); |
|||
757 | |||||
758 | 2 | foreach ($cacheKeys as $key) { |
|||
759 | 2 | $fileMeta = $this->cache->get($key, true); |
|||
760 | |||||
761 | 2 | if ( ! $this->isExpired($fileMeta)) { |
|||
762 | 1 | continue; |
|||
763 | } |
||||
764 | |||||
765 | 2 | if ( ! $this->cache->delete($key)) { |
|||
766 | 1 | continue; |
|||
767 | } |
||||
768 | |||||
769 | 1 | if (is_writable($fileMeta['file_path'])) { |
|||
770 | 1 | unlink($fileMeta['file_path']); |
|||
771 | } |
||||
772 | |||||
773 | 1 | $deleted[] = $fileMeta; |
|||
774 | } |
||||
775 | |||||
776 | 2 | return $deleted; |
|||
777 | } |
||||
778 | |||||
779 | /** |
||||
780 | * Verify max upload size. |
||||
781 | * |
||||
782 | * @return bool |
||||
783 | */ |
||||
784 | 1 | protected function verifyUploadSize() : bool |
|||
785 | { |
||||
786 | 1 | $maxUploadSize = $this->getMaxUploadSize(); |
|||
787 | |||||
788 | 1 | if ($maxUploadSize > 0 && $this->getRequest()->header('Upload-Length') > $maxUploadSize) { |
|||
789 | 1 | return false; |
|||
790 | } |
||||
791 | |||||
792 | 1 | return true; |
|||
793 | } |
||||
794 | |||||
795 | /** |
||||
796 | * Verify checksum if available. |
||||
797 | * |
||||
798 | * @param string $checksum |
||||
799 | * @param string $filePath |
||||
800 | * |
||||
801 | * @return bool |
||||
802 | */ |
||||
803 | 1 | protected function verifyChecksum(string $checksum, string $filePath) : bool |
|||
804 | { |
||||
805 | // Skip if checksum is empty. |
||||
806 | 1 | if (empty($checksum)) { |
|||
807 | 1 | return true; |
|||
808 | } |
||||
809 | |||||
810 | 1 | return $checksum === $this->getServerChecksum($filePath); |
|||
811 | } |
||||
812 | |||||
813 | /** |
||||
814 | * No other methods are allowed. |
||||
815 | * |
||||
816 | * @param string $method |
||||
817 | * @param array $params |
||||
818 | * |
||||
819 | * @return HttpResponse |
||||
820 | */ |
||||
821 | 1 | public function __call(string $method, array $params) |
|||
822 | { |
||||
823 | 1 | return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST); |
|||
824 | } |
||||
825 | } |
||||
826 |