Passed
Pull Request — master (#21)
by Ankit
03:47
created

Server::isExpired()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 4

Importance

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