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

Server::buildFile()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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