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

Server::handleDelete()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 20
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

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