Completed
Pull Request — master (#22)
by Ankit
02:42
created

Server::getMaxUploadSize()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

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