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

Server::handlePatch()   C

Complexity

Conditions 8
Paths 16

Size

Total Lines 35
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 22
CRAP Score 8

Importance

Changes 0
Metric Value
cc 8
eloc 22
nc 16
nop 0
dl 0
loc 35
ccs 22
cts 22
cp 1
crap 8
rs 5.3846
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 Middleware */
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();
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 2
    public function setUploadDir(string $path) : self
98
    {
99 2
        $this->uploadDir = $path;
100
101 2
        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 1
    public function setUploadKey(string $key) : self
172
    {
173 1
        $this->uploadKey = $key;
174
175 1
        return $this;
176
    }
177
178
    /**
179
     * Get upload key from header.
180
     *
181
     * @return string|HttpResponse
182
     */
183 4
    public function getUploadKey()
184
    {
185 4
        if ( ! empty($this->uploadKey)) {
186 1
            return $this->uploadKey;
187
        }
188
189 3
        $key = $this->getRequest()->header('Upload-Key') ?? Uuid::uuid4()->toString();
190
191 3
        if (empty($key)) {
192 1
            return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
193
        }
194
195 2
        $this->uploadKey = $key;
196
197 2
        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;
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;
222
    }
223
224
    /**
225
     * Set max upload size.
226
     *
227
     * @param int $uploadSize
228
     *
229
     * @return Server
230
     */
231 2
    public function setMaxUploadSize(int $uploadSize) : self
232
    {
233 2
        $this->maxUploadSize = $uploadSize;
234
235 2
        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 2
    protected function handleOptions() : HttpResponse
311
    {
312
        $headers = [
313 2
            'Allow' => implode(',', $this->request->allowedHttpVerbs()),
314 2
            'Tus-Version' => self::TUS_PROTOCOL_VERSION,
315 2
            'Tus-Extension' => implode(',', self::TUS_EXTENSIONS),
316 2
            'Tus-Checksum-Algorithm' => $this->getSupportedHashAlgorithms(),
317
        ];
318
319 2
        $maxUploadSize = $this->getMaxUploadSize();
320
321 2
        if ($maxUploadSize > 0) {
322 1
            $headers['Tus-Max-Size'] = $maxUploadSize;
323
        }
324
325 2
        return $this->response->send(null, HttpResponse::HTTP_OK, $headers);
326
    }
327
328
    /**
329
     * Handle HEAD request.
330
     *
331
     * @return HttpResponse
332
     */
333 5
    protected function handleHead() : HttpResponse
334
    {
335 5
        $key = $this->request->key();
336
337 5
        if ( ! $fileMeta = $this->cache->get($key)) {
338 1
            return $this->response->send(null, HttpResponse::HTTP_NOT_FOUND);
339
        }
340
341 4
        $offset = $fileMeta['offset'] ?? false;
342
343 4
        if (false === $offset) {
344 1
            return $this->response->send(null, HttpResponse::HTTP_GONE);
345
        }
346
347 3
        return $this->response->send(null, HttpResponse::HTTP_OK, $this->getHeadersForHeadRequest($fileMeta));
348
    }
349
350
    /**
351
     * Handle POST request.
352
     *
353
     * @return HttpResponse
354
     */
355 5
    protected function handlePost() : HttpResponse
356
    {
357 5
        $fileName   = $this->getRequest()->extractFileName();
358 5
        $uploadType = self::UPLOAD_TYPE_NORMAL;
359
360 5
        if (empty($fileName)) {
361 1
            return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
362
        }
363
364 4
        if ( ! $this->verifyUploadSize()) {
365 1
            return $this->response->send(null, HttpResponse::HTTP_REQUEST_ENTITY_TOO_LARGE);
366
        }
367
368 3
        $uploadKey = $this->getUploadKey();
369 3
        $filePath  = $this->uploadDir . DIRECTORY_SEPARATOR . $fileName;
370
371 3
        if ($this->getRequest()->isFinal()) {
372 1
            return $this->handleConcatenation($fileName, $filePath);
373
        }
374
375 2
        if ($this->getRequest()->isPartial()) {
376 1
            $filePath   = $this->getPathForPartialUpload($uploadKey) . $fileName;
377 1
            $uploadType = self::UPLOAD_TYPE_PARTIAL;
378
        }
379
380 2
        $checksum = $this->getClientChecksum();
381 2
        $location = $this->getRequest()->url() . $this->getApiPath() . '/' . $uploadKey;
382
383 2
        $file = $this->buildFile([
384 2
            'name' => $fileName,
385 2
            'offset' => 0,
386 2
            'size' => $this->getRequest()->header('Upload-Length'),
387 2
            'file_path' => $filePath,
388 2
            'location' => $location,
389 2
        ])->setChecksum($checksum);
390
391 2
        $this->cache->set($uploadKey, $file->details() + ['upload_type' => $uploadType]);
392
393 2
        return $this->response->send(
394 2
            ['data' => ['checksum' => $checksum]],
395 2
            HttpResponse::HTTP_CREATED,
396
            [
397 2
                'Location' => $location,
398 2
                '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 2
    protected function handleConcatenation(string $fileName, string $filePath) : HttpResponse
412
    {
413 2
        $partials  = $this->getRequest()->extractPartials();
414 2
        $uploadKey = $this->getUploadKey();
415 2
        $files     = $this->getPartialsMeta($partials);
416 2
        $filePaths = array_column($files, 'file_path');
417 2
        $location  = $this->getRequest()->url() . $this->getApiPath() . '/' . $uploadKey;
418
419 2
        $file = $this->buildFile([
420 2
            'name' => $fileName,
421 2
            'offset' => 0,
422 2
            'size' => 0,
423 2
            'file_path' => $filePath,
424 2
            'location' => $location,
425 2
        ])->setFilePath($filePath);
426
427 2
        $file->setOffset($file->merge($files));
428
429
        // Verify checksum.
430 2
        $checksum = $this->getServerChecksum($filePath);
431
432 2
        if ($checksum !== $this->getClientChecksum()) {
433 1
            return $this->response->send(null, self::HTTP_CHECKSUM_MISMATCH);
434
        }
435
436 1
        $this->cache->set($uploadKey, $file->details() + ['upload_type' => self::UPLOAD_TYPE_FINAL]);
437
438
        // Cleanup.
439 1
        if ($file->delete($filePaths, true)) {
440 1
            $this->cache->deleteAll($partials);
441
        }
442
443 1
        return $this->response->send(
444 1
            ['data' => ['checksum' => $checksum]],
445 1
            HttpResponse::HTTP_CREATED,
446
            [
447 1
                'Location' => $location,
448
            ]
449
        );
450
    }
451
452
    /**
453
     * Handle PATCH request.
454
     *
455
     * @return HttpResponse
456
     */
457 7
    protected function handlePatch() : HttpResponse
458
    {
459 7
        $uploadKey = $this->request->key();
460
461 7
        if ( ! $meta = $this->cache->get($uploadKey)) {
462 1
            return $this->response->send(null, HttpResponse::HTTP_GONE);
463
        }
464
465 6
        if (self::UPLOAD_TYPE_FINAL === $meta['upload_type']) {
466 1
            return $this->response->send(null, HttpResponse::HTTP_FORBIDDEN);
467
        }
468
469 5
        $file     = $this->buildFile($meta);
470 5
        $checksum = $meta['checksum'];
471
472
        try {
473 5
            $fileSize = $file->getFileSize();
474 5
            $offset   = $file->setKey($uploadKey)->setChecksum($checksum)->upload($fileSize);
475
476
            // If upload is done, verify checksum.
477 2
            if ($offset === $fileSize && ! $this->verifyChecksum($checksum, $meta['file_path'])) {
478 2
                return $this->response->send(null, self::HTTP_CHECKSUM_MISMATCH);
479
            }
480 3
        } catch (FileException $e) {
481 1
            return $this->response->send($e->getMessage(), HttpResponse::HTTP_UNPROCESSABLE_ENTITY);
482 2
        } catch (OutOfRangeException $e) {
483 1
            return $this->response->send(null, HttpResponse::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE);
484 1
        } catch (ConnectionException $e) {
485 1
            return $this->response->send(null, HttpResponse::HTTP_CONTINUE);
486
        }
487
488 1
        return $this->response->send(null, HttpResponse::HTTP_NO_CONTENT, [
489 1
            'Content-Type' => 'application/offset+octet-stream',
490 1
            'Upload-Expires' => $this->cache->get($uploadKey)['expires_at'],
491 1
            'Upload-Offset' => $offset,
492
        ]);
493
    }
494
495
    /**
496
     * Handle GET request.
497
     *
498
     * @return BinaryFileResponse|HttpResponse
499
     */
500 4
    protected function handleGet()
501
    {
502 4
        $key = $this->request->key();
503
504 4
        if (empty($key)) {
505 1
            return $this->response->send('400 bad request.', HttpResponse::HTTP_BAD_REQUEST);
506
        }
507
508 3
        if ( ! $fileMeta = $this->cache->get($key)) {
509 1
            return $this->response->send('404 upload not found.', HttpResponse::HTTP_NOT_FOUND);
510
        }
511
512 2
        $resource = $fileMeta['file_path'] ?? null;
513 2
        $fileName = $fileMeta['name'] ?? null;
514
515 2
        if ( ! $resource || ! file_exists($resource)) {
516 1
            return $this->response->send('404 upload not found.', HttpResponse::HTTP_NOT_FOUND);
517
        }
518
519 1
        return $this->response->download($resource, $fileName);
520
    }
521
522
    /**
523
     * Handle DELETE request.
524
     *
525
     * @return HttpResponse
526
     */
527 3
    protected function handleDelete() : HttpResponse
528
    {
529 3
        $key      = $this->request->key();
530 3
        $fileMeta = $this->cache->get($key);
531 3
        $resource = $fileMeta['file_path'] ?? null;
532
533 3
        if ( ! $resource) {
534 1
            return $this->response->send(null, HttpResponse::HTTP_NOT_FOUND);
535
        }
536
537 2
        $isDeleted = $this->cache->delete($key);
538
539 2
        if ( ! $isDeleted || ! file_exists($resource)) {
540 1
            return $this->response->send(null, HttpResponse::HTTP_GONE);
541
        }
542
543 1
        unlink($resource);
544
545 1
        return $this->response->send(null, HttpResponse::HTTP_NO_CONTENT, [
546 1
            '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 4
    protected function getHeadersForHeadRequest(array $fileMeta) : array
558
    {
559
        $headers = [
560 4
            'Upload-Length' => (int) $fileMeta['size'],
561 4
            'Upload-Offset' => (int) $fileMeta['offset'],
562 4
            'Cache-Control' => 'no-store',
563
        ];
564
565 4
        if (self::UPLOAD_TYPE_FINAL === $fileMeta['upload_type'] && $fileMeta['size'] !== $fileMeta['offset']) {
566 2
            unset($headers['Upload-Offset']);
567
        }
568
569 4
        if (self::UPLOAD_TYPE_NORMAL !== $fileMeta['upload_type']) {
570 3
            $headers += ['Upload-Concat' => $fileMeta['upload_type']];
571
        }
572
573 4
        return $headers;
574
    }
575
576
    /**
577
     * Build file object.
578
     *
579
     * @param array $meta
580
     *
581
     * @return File
582
     */
583 1
    protected function buildFile(array $meta) : File
584
    {
585 1
        $file = new File($meta['name'], $this->cache);
586
587 1
        if (array_key_exists('offset', $meta)) {
588 1
            $file->setMeta($meta['offset'], $meta['size'], $meta['file_path'], $meta['location']);
589
        }
590
591 1
        return $file;
592
    }
593
594
    /**
595
     * Get list of supported hash algorithms.
596
     *
597
     * @return string
598
     */
599 1
    protected function getSupportedHashAlgorithms() : string
600
    {
601 1
        $supportedAlgorithms = hash_algos();
602
603 1
        $algorithms = [];
604 1
        foreach ($supportedAlgorithms as $hashAlgo) {
605 1
            if (false !== strpos($hashAlgo, ',')) {
606 1
                $algorithms[] = "'{$hashAlgo}'";
607
            } else {
608 1
                $algorithms[] = $hashAlgo;
609
            }
610
        }
611
612 1
        return implode(',', $algorithms);
613
    }
614
615
    /**
616
     * Verify and get upload checksum from header.
617
     *
618
     * @return string|HttpResponse
619
     */
620 4
    protected function getClientChecksum()
621
    {
622 4
        $checksumHeader = $this->getRequest()->header('Upload-Checksum');
623
624 4
        if (empty($checksumHeader)) {
625 1
            return '';
626
        }
627
628 3
        list($checksumAlgorithm, $checksum) = explode(' ', $checksumHeader);
629
630 3
        $checksum = base64_decode($checksum);
631
632 3
        if ( ! in_array($checksumAlgorithm, hash_algos()) || false === $checksum) {
633 2
            return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
634
        }
635
636 1
        return $checksum;
637
    }
638
639
    /**
640
     * Get expired but incomplete uploads.
641
     *
642
     * @param array|null $contents
643
     *
644
     * @return bool
645
     */
646 3
    protected function isExpired($contents) : bool
647
    {
648 3
        $isExpired = empty($contents['expires_at']) || Carbon::parse($contents['expires_at'])->lt(Carbon::now());
649
650 3
        if ($isExpired && $contents['offset'] !== $contents['size']) {
651 3
            return true;
652
        }
653
654 2
        return false;
655
    }
656
657
    /**
658
     * Get path for partial upload.
659
     *
660
     * @param string $key
661
     *
662
     * @return string
663
     */
664 1
    protected function getPathForPartialUpload(string $key) : string
665
    {
666 1
        list($actualKey) = explode(self::PARTIAL_UPLOAD_NAME_SEPARATOR, $key);
667
668 1
        $path = $this->uploadDir . DIRECTORY_SEPARATOR . $actualKey . DIRECTORY_SEPARATOR;
669
670 1
        if ( ! file_exists($path)) {
671 1
            mkdir($path);
672
        }
673
674 1
        return $path;
675
    }
676
677
    /**
678
     * Get metadata of partials.
679
     *
680
     * @param array $partials
681
     *
682
     * @return array
683
     */
684 3
    protected function getPartialsMeta(array $partials) : array
685
    {
686 3
        $files = [];
687
688 3
        foreach ($partials as $partial) {
689 3
            $fileMeta = $this->getCache()->get($partial);
690
691 3
            $files[] = $fileMeta;
692
        }
693
694 3
        return $files;
695
    }
696
697
    /**
698
     * Delete expired resources.
699
     *
700
     * @return array
701
     */
702 2
    public function handleExpiration() : array
703
    {
704 2
        $deleted   = [];
705 2
        $cacheKeys = $this->cache->keys();
706
707 2
        foreach ($cacheKeys as $key) {
708 2
            $fileMeta = $this->cache->get($key, true);
709
710 2
            if ( ! $this->isExpired($fileMeta)) {
711 1
                continue;
712
            }
713
714 2
            if ( ! $this->cache->delete($key)) {
715 1
                continue;
716
            }
717
718 1
            if (is_writable($fileMeta['file_path'])) {
719 1
                unlink($fileMeta['file_path']);
720
            }
721
722 1
            $deleted[] = $fileMeta;
723
        }
724
725 2
        return $deleted;
726
    }
727
728
    /**
729
     * Verify max upload size.
730
     *
731
     * @return bool
732
     */
733 1
    protected function verifyUploadSize() : bool
734
    {
735 1
        $maxUploadSize = $this->getMaxUploadSize();
736
737 1
        if ($maxUploadSize > 0 && $this->getRequest()->header('Upload-Length') > $maxUploadSize) {
738 1
            return false;
739
        }
740
741 1
        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 1
    protected function verifyChecksum(string $checksum, string $filePath) : bool
753
    {
754
        // Skip if checksum is empty.
755 1
        if (empty($checksum)) {
756 1
            return true;
757
        }
758
759 1
        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 1
    public function __call(string $method, array $params)
771
    {
772 1
        return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
773
    }
774
}
775