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

Server::setMiddleware()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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