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

Server::serve()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
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 13
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
    /** @const int 24 hours access control max age header */
51
    const HEADER_ACCESS_CONTROL_MAX_AGE = 86400;
52
53
    /** @var Request */
54
    protected $request;
55
56
    /** @var Response */
57
    protected $response;
58
59
    /** @var string */
60
    protected $uploadDir;
61
62
    /** @var string */
63
    protected $uploadKey;
64
65
    /** @var array */
66
    protected $middleware;
67
68
    /**
69
     * @var int Max upload size in bytes
70
     *          Default 0, no restriction.
71
     */
72
    protected $maxUploadSize = 0;
73
74
    /**
75
     * TusServer constructor.
76
     *
77
     * @param Cacheable|string $cacheAdapter
78
     */
79 3
    public function __construct($cacheAdapter = 'file')
80
    {
81 3
        $this->request   = new Request;
82 3
        $this->response  = new Response;
83 3
        $this->uploadDir = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'uploads';
84
85 3
        $this->middleware = new Middleware();
0 ignored issues
show
Documentation Bug introduced by
It seems like new TusPhp\Middleware\Middleware() of type TusPhp\Middleware\Middleware is incompatible with the declared type array of property $middleware.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
86
87 3
        $this->setCache($cacheAdapter);
88 3
    }
89
90
    /**
91
     * Set upload dir.
92
     *
93
     * @param string $path
94
     *
95
     * @return Server
96
     */
97 1
    public function setUploadDir(string $path) : self
98
    {
99 1
        $this->uploadDir = $path;
100
101 1
        return $this;
102
    }
103
104
    /**
105
     * Get upload dir.
106
     *
107
     * @return string
108
     */
109 1
    public function getUploadDir() : string
110
    {
111 1
        return $this->uploadDir;
112
    }
113
114
    /**
115
     * Get request.
116
     *
117
     * @return Request
118
     */
119 1
    public function getRequest() : Request
120
    {
121 1
        return $this->request;
122
    }
123
124
    /**
125
     * Get request.
126
     *
127
     * @return Response
128
     */
129 1
    public function getResponse() : Response
130
    {
131 1
        return $this->response;
132
    }
133
134
    /**
135
     * Get file checksum.
136
     *
137
     * @param string $filePath
138
     *
139
     * @return string
140
     */
141 1
    public function getServerChecksum(string $filePath) : string
142
    {
143 1
        return hash_file($this->getChecksumAlgorithm(), $filePath);
144
    }
145
146
    /**
147
     * Get checksum algorithm.
148
     *
149
     * @return string|null
150
     */
151 1
    public function getChecksumAlgorithm()
152
    {
153 1
        $checksumHeader = $this->getRequest()->header('Upload-Checksum');
154
155 1
        if (empty($checksumHeader)) {
156 1
            return self::DEFAULT_CHECKSUM_ALGORITHM;
157
        }
158
159 1
        list($checksumAlgorithm) = explode(' ', $checksumHeader);
160
161 1
        return $checksumAlgorithm;
162
    }
163
164
    /**
165
     * Set upload key.
166
     *
167
     * @param string $key
168
     *
169
     * @return Server
170
     */
171
    public function setUploadKey(string $key) : self
172
    {
173
        $this->uploadKey = $key;
174
175
        return $this;
176
    }
177
178
    /**
179
     * Get upload key from header.
180
     *
181
     * @return string|HttpResponse
182
     */
183
    public function getUploadKey()
184
    {
185
        if ( ! empty($this->uploadKey)) {
186
            return $this->uploadKey;
187
        }
188
189
        $key = $this->getRequest()->header('Upload-Key') ?? Uuid::uuid4()->toString();
190
191
        if (empty($key)) {
192
            return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
193
        }
194
195
        $this->uploadKey = $key;
196
197
        return $this->uploadKey;
198
    }
199
200
    /**
201
     * Set middleware.
202
     *
203
     * @param Middleware $middleware
204
     *
205
     * @return self
206
     */
207
    public function setMiddleware(Middleware $middleware) : self
208
    {
209
        $this->middleware = $middleware;
0 ignored issues
show
Documentation Bug introduced by
It seems like $middleware of type TusPhp\Middleware\Middleware is incompatible with the declared type array of property $middleware.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
210
211
        return $this;
212
    }
213
214
    /**
215
     * Get middleware.
216
     *
217
     * @return Middleware
218
     */
219
    public function middleware() : Middleware
220
    {
221
        return $this->middleware;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->middleware returns the type array which is incompatible with the type-hinted return TusPhp\Middleware\Middleware.
Loading history...
222
    }
223
224
    /**
225
     * Set max upload size.
226
     *
227
     * @param int $uploadSize
228
     *
229
     * @return Server
230
     */
231 1
    public function setMaxUploadSize(int $uploadSize) : self
232
    {
233 1
        $this->maxUploadSize = $uploadSize;
234
235 1
        return $this;
236
    }
237
238
    /**
239
     * Get max upload size.
240
     *
241
     * @return int
242
     */
243 1
    public function getMaxUploadSize() : int
244
    {
245 1
        return $this->maxUploadSize;
246
    }
247
248
    /**
249
     * Handle all HTTP request.
250
     *
251
     * @return HttpResponse
252
     */
253 3
    public function serve() : HttpResponse
254
    {
255 3
        $requestMethod = $this->getRequestMethod();
256
257 3
        if ( ! in_array($requestMethod, $this->getRequest()->allowedHttpVerbs())) {
258 1
            return $this->response->send(null, HttpResponse::HTTP_METHOD_NOT_ALLOWED);
259
        }
260
261 2
        $this->applyMiddleware();
262
263 2
        $method = 'handle' . ucfirst(strtolower($requestMethod));
264
265 2
        return $this->{$method}();
266
    }
267
268
    /**
269
     * Get actual request method.
270
     *
271
     * @return null|string
272
     */
273
    protected function getRequestMethod()
274
    {
275
        $request = $this->getRequest();
276
277
        $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
        $newMethod = $request->header('X-HTTP-Method-Override');
283
284
        if ( ! empty($newMethod)) {
285
            $requestMethod = $newMethod;
286
        }
287
288
        return $requestMethod;
289
    }
290
291
    /**
292
     * Apply middleware.
293
     *
294
     * @return null
295
     */
296
    protected function applyMiddleware()
297
    {
298
        $middleware = $this->middleware()->list();
299
300
        foreach ($middleware as $m) {
301
            $m->handle($this->getRequest(), $this->getResponse());
302
        }
303
    }
304
305
    /**
306
     * Handle OPTIONS request.
307
     *
308
     * @return HttpResponse
309
     */
310
    protected function handleOptions() : HttpResponse
311
    {
312
        $headers = [
313
            'Allow' => implode(',', $this->request->allowedHttpVerbs()),
314
            'Tus-Version' => self::TUS_PROTOCOL_VERSION,
315
            'Tus-Extension' => implode(',', self::TUS_EXTENSIONS),
316
            'Tus-Checksum-Algorithm' => $this->getSupportedHashAlgorithms(),
317
        ];
318
319
        $maxUploadSize = $this->getMaxUploadSize();
320
321
        if ($maxUploadSize > 0) {
322
            $headers['Tus-Max-Size'] = $maxUploadSize;
323
        }
324
325
        return $this->response->send(null, HttpResponse::HTTP_OK, $headers);
326
    }
327
328
    /**
329
     * Handle HEAD request.
330
     *
331
     * @return HttpResponse
332
     */
333
    protected function handleHead() : HttpResponse
334
    {
335
        $key = $this->request->key();
336
337
        if ( ! $fileMeta = $this->cache->get($key)) {
338
            return $this->response->send(null, HttpResponse::HTTP_NOT_FOUND);
339
        }
340
341
        $offset = $fileMeta['offset'] ?? false;
342
343
        if (false === $offset) {
344
            return $this->response->send(null, HttpResponse::HTTP_GONE);
345
        }
346
347
        return $this->response->send(null, HttpResponse::HTTP_OK, $this->getHeadersForHeadRequest($fileMeta));
348
    }
349
350
    /**
351
     * Handle POST request.
352
     *
353
     * @return HttpResponse
354
     */
355
    protected function handlePost() : HttpResponse
356
    {
357
        $fileName   = $this->getRequest()->extractFileName();
358
        $uploadType = self::UPLOAD_TYPE_NORMAL;
359
360
        if (empty($fileName)) {
361
            return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
362
        }
363
364
        if ( ! $this->verifyUploadSize()) {
365
            return $this->response->send(null, HttpResponse::HTTP_REQUEST_ENTITY_TOO_LARGE);
366
        }
367
368
        $uploadKey = $this->getUploadKey();
369
        $filePath  = $this->uploadDir . DIRECTORY_SEPARATOR . $fileName;
370
371
        if ($this->getRequest()->isFinal()) {
372
            return $this->handleConcatenation($fileName, $filePath);
373
        }
374
375
        if ($this->getRequest()->isPartial()) {
376
            $filePath   = $this->getPathForPartialUpload($uploadKey) . $fileName;
377
            $uploadType = self::UPLOAD_TYPE_PARTIAL;
378
        }
379
380
        $checksum = $this->getClientChecksum();
381
        $location = $this->getRequest()->url() . $this->getApiPath() . '/' . $uploadKey;
382
383
        $file = $this->buildFile([
384
            'name' => $fileName,
385
            'offset' => 0,
386
            'size' => $this->getRequest()->header('Upload-Length'),
387
            'file_path' => $filePath,
388
            'location' => $location,
389
        ])->setChecksum($checksum);
390
391
        $this->cache->set($uploadKey, $file->details() + ['upload_type' => $uploadType]);
392
393
        return $this->response->send(
394
            ['data' => ['checksum' => $checksum]],
395
            HttpResponse::HTTP_CREATED,
396
            [
397
                'Location' => $location,
398
                'Upload-Expires' => $this->cache->get($uploadKey)['expires_at'],
399
            ]
400
        );
401
    }
402
403
    /**
404
     * Handle file concatenation.
405
     *
406
     * @param string $fileName
407
     * @param string $filePath
408
     *
409
     * @return HttpResponse
410
     */
411
    protected function handleConcatenation(string $fileName, string $filePath) : HttpResponse
412
    {
413
        $partials  = $this->getRequest()->extractPartials();
414
        $uploadKey = $this->getUploadKey();
415
        $files     = $this->getPartialsMeta($partials);
416
        $filePaths = array_column($files, 'file_path');
417
        $location  = $this->getRequest()->url() . $this->getApiPath() . '/' . $uploadKey;
418
419
        $file = $this->buildFile([
420
            'name' => $fileName,
421
            'offset' => 0,
422
            'size' => 0,
423
            'file_path' => $filePath,
424
            'location' => $location,
425
        ])->setFilePath($filePath);
426
427
        $file->setOffset($file->merge($files));
428
429
        // Verify checksum.
430
        $checksum = $this->getServerChecksum($filePath);
431
432
        if ($checksum !== $this->getClientChecksum()) {
433
            return $this->response->send(null, self::HTTP_CHECKSUM_MISMATCH);
434
        }
435
436
        $this->cache->set($uploadKey, $file->details() + ['upload_type' => self::UPLOAD_TYPE_FINAL]);
437
438
        // Cleanup.
439
        if ($file->delete($filePaths, true)) {
440
            $this->cache->deleteAll($partials);
441
        }
442
443
        return $this->response->send(
444
            ['data' => ['checksum' => $checksum]],
445
            HttpResponse::HTTP_CREATED,
446
            [
447
                'Location' => $location,
448
            ]
449
        );
450
    }
451
452
    /**
453
     * Handle PATCH request.
454
     *
455
     * @return HttpResponse
456
     */
457
    protected function handlePatch() : HttpResponse
458
    {
459
        $uploadKey = $this->request->key();
460
461
        if ( ! $meta = $this->cache->get($uploadKey)) {
462
            return $this->response->send(null, HttpResponse::HTTP_GONE);
463
        }
464
465
        if (self::UPLOAD_TYPE_FINAL === $meta['upload_type']) {
466
            return $this->response->send(null, HttpResponse::HTTP_FORBIDDEN);
467
        }
468
469
        $file     = $this->buildFile($meta);
470
        $checksum = $meta['checksum'];
471
472
        try {
473
            $fileSize = $file->getFileSize();
474
            $offset   = $file->setKey($uploadKey)->setChecksum($checksum)->upload($fileSize);
475
476
            // If upload is done, verify checksum.
477
            if ($offset === $fileSize && ! $this->verifyChecksum($checksum, $meta['file_path'])) {
478
                return $this->response->send(null, self::HTTP_CHECKSUM_MISMATCH);
479
            }
480
        } catch (FileException $e) {
481
            return $this->response->send($e->getMessage(), HttpResponse::HTTP_UNPROCESSABLE_ENTITY);
482
        } catch (OutOfRangeException $e) {
483
            return $this->response->send(null, HttpResponse::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE);
484
        } catch (ConnectionException $e) {
485
            return $this->response->send(null, HttpResponse::HTTP_CONTINUE);
486
        }
487
488
        return $this->response->send(null, HttpResponse::HTTP_NO_CONTENT, [
489
            'Content-Type' => 'application/offset+octet-stream',
490
            'Upload-Expires' => $this->cache->get($uploadKey)['expires_at'],
491
            'Upload-Offset' => $offset,
492
        ]);
493
    }
494
495
    /**
496
     * Handle GET request.
497
     *
498
     * @return BinaryFileResponse|HttpResponse
499
     */
500
    protected function handleGet()
501
    {
502
        $key = $this->request->key();
503
504
        if (empty($key)) {
505
            return $this->response->send('400 bad request.', HttpResponse::HTTP_BAD_REQUEST);
506
        }
507
508
        if ( ! $fileMeta = $this->cache->get($key)) {
509
            return $this->response->send('404 upload not found.', HttpResponse::HTTP_NOT_FOUND);
510
        }
511
512
        $resource = $fileMeta['file_path'] ?? null;
513
        $fileName = $fileMeta['name'] ?? null;
514
515
        if ( ! $resource || ! file_exists($resource)) {
516
            return $this->response->send('404 upload not found.', HttpResponse::HTTP_NOT_FOUND);
517
        }
518
519
        return $this->response->download($resource, $fileName);
520
    }
521
522
    /**
523
     * Handle DELETE request.
524
     *
525
     * @return HttpResponse
526
     */
527
    protected function handleDelete() : HttpResponse
528
    {
529
        $key      = $this->request->key();
530
        $fileMeta = $this->cache->get($key);
531
        $resource = $fileMeta['file_path'] ?? null;
532
533
        if ( ! $resource) {
534
            return $this->response->send(null, HttpResponse::HTTP_NOT_FOUND);
535
        }
536
537
        $isDeleted = $this->cache->delete($key);
538
539
        if ( ! $isDeleted || ! file_exists($resource)) {
540
            return $this->response->send(null, HttpResponse::HTTP_GONE);
541
        }
542
543
        unlink($resource);
544
545
        return $this->response->send(null, HttpResponse::HTTP_NO_CONTENT, [
546
            'Tus-Extension' => self::TUS_EXTENSION_TERMINATION,
547
        ]);
548
    }
549
550
    /**
551
     * Get required headers for head request.
552
     *
553
     * @param array $fileMeta
554
     *
555
     * @return array
556
     */
557
    protected function getHeadersForHeadRequest(array $fileMeta) : array
558
    {
559
        $headers = [
560
            'Upload-Length' => (int) $fileMeta['size'],
561
            'Upload-Offset' => (int) $fileMeta['offset'],
562
            'Cache-Control' => 'no-store',
563
        ];
564
565
        if (self::UPLOAD_TYPE_FINAL === $fileMeta['upload_type'] && $fileMeta['size'] !== $fileMeta['offset']) {
566
            unset($headers['Upload-Offset']);
567
        }
568
569
        if (self::UPLOAD_TYPE_NORMAL !== $fileMeta['upload_type']) {
570
            $headers += ['Upload-Concat' => $fileMeta['upload_type']];
571
        }
572
573
        return $headers;
574
    }
575
576
    /**
577
     * Build file object.
578
     *
579
     * @param array $meta
580
     *
581
     * @return File
582
     */
583
    protected function buildFile(array $meta) : File
584
    {
585
        $file = new File($meta['name'], $this->cache);
586
587
        if (array_key_exists('offset', $meta)) {
588
            $file->setMeta($meta['offset'], $meta['size'], $meta['file_path'], $meta['location']);
589
        }
590
591
        return $file;
592
    }
593
594
    /**
595
     * Get list of supported hash algorithms.
596
     *
597
     * @return string
598
     */
599
    protected function getSupportedHashAlgorithms() : string
600
    {
601
        $supportedAlgorithms = hash_algos();
602
603
        $algorithms = [];
604
        foreach ($supportedAlgorithms as $hashAlgo) {
605
            if (false !== strpos($hashAlgo, ',')) {
606
                $algorithms[] = "'{$hashAlgo}'";
607
            } else {
608
                $algorithms[] = $hashAlgo;
609
            }
610
        }
611
612
        return implode(',', $algorithms);
613
    }
614
615
    /**
616
     * Verify and get upload checksum from header.
617
     *
618
     * @return string|HttpResponse
619
     */
620
    protected function getClientChecksum()
621
    {
622
        $checksumHeader = $this->getRequest()->header('Upload-Checksum');
623
624
        if (empty($checksumHeader)) {
625
            return '';
626
        }
627
628
        list($checksumAlgorithm, $checksum) = explode(' ', $checksumHeader);
629
630
        $checksum = base64_decode($checksum);
631
632
        if ( ! in_array($checksumAlgorithm, hash_algos()) || false === $checksum) {
633
            return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
634
        }
635
636
        return $checksum;
637
    }
638
639
    /**
640
     * Get expired but incomplete uploads.
641
     *
642
     * @param array|null $contents
643
     *
644
     * @return bool
645
     */
646
    protected function isExpired($contents) : bool
647
    {
648
        $isExpired = empty($contents['expires_at']) || Carbon::parse($contents['expires_at'])->lt(Carbon::now());
649
650
        if ($isExpired && $contents['offset'] !== $contents['size']) {
651
            return true;
652
        }
653
654
        return false;
655
    }
656
657
    /**
658
     * Get path for partial upload.
659
     *
660
     * @param string $key
661
     *
662
     * @return string
663
     */
664
    protected function getPathForPartialUpload(string $key) : string
665
    {
666
        list($actualKey) = explode(self::PARTIAL_UPLOAD_NAME_SEPARATOR, $key);
667
668
        $path = $this->uploadDir . DIRECTORY_SEPARATOR . $actualKey . DIRECTORY_SEPARATOR;
669
670
        if ( ! file_exists($path)) {
671
            mkdir($path);
672
        }
673
674
        return $path;
675
    }
676
677
    /**
678
     * Get metadata of partials.
679
     *
680
     * @param array $partials
681
     *
682
     * @return array
683
     */
684
    protected function getPartialsMeta(array $partials) : array
685
    {
686
        $files = [];
687
688
        foreach ($partials as $partial) {
689
            $fileMeta = $this->getCache()->get($partial);
690
691
            $files[] = $fileMeta;
692
        }
693
694
        return $files;
695
    }
696
697
    /**
698
     * Delete expired resources.
699
     *
700
     * @return array
701
     */
702
    public function handleExpiration() : array
703
    {
704
        $deleted   = [];
705
        $cacheKeys = $this->cache->keys();
706
707
        foreach ($cacheKeys as $key) {
708
            $fileMeta = $this->cache->get($key, true);
709
710
            if ( ! $this->isExpired($fileMeta)) {
711
                continue;
712
            }
713
714
            if ( ! $this->cache->delete($key)) {
715
                continue;
716
            }
717
718
            if (is_writable($fileMeta['file_path'])) {
719
                unlink($fileMeta['file_path']);
720
            }
721
722
            $deleted[] = $fileMeta;
723
        }
724
725
        return $deleted;
726
    }
727
728
    /**
729
     * Verify max upload size.
730
     *
731
     * @return bool
732
     */
733
    protected function verifyUploadSize() : bool
734
    {
735
        $maxUploadSize = $this->getMaxUploadSize();
736
737
        if ($maxUploadSize > 0 && $this->getRequest()->header('Upload-Length') > $maxUploadSize) {
738
            return false;
739
        }
740
741
        return true;
742
    }
743
744
    /**
745
     * Verify checksum if available.
746
     *
747
     * @param string $checksum
748
     * @param string $filePath
749
     *
750
     * @return bool
751
     */
752
    protected function verifyChecksum(string $checksum, string $filePath) : bool
753
    {
754
        // Skip if checksum is empty.
755
        if (empty($checksum)) {
756
            return true;
757
        }
758
759
        return $checksum === $this->getServerChecksum($filePath);
760
    }
761
762
    /**
763
     * No other methods are allowed.
764
     *
765
     * @param string $method
766
     * @param array  $params
767
     *
768
     * @return HttpResponse|BinaryFileResponse
769
     */
770
    public function __call(string $method, array $params)
771
    {
772
        return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
773
    }
774
}
775