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

Server::getRequestMethod()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 16
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2

Importance

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