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

Server::verifyChecksum()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

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