Completed
Pull Request — master (#21)
by Ankit
03:01
created

Server::serve()   B

Complexity

Conditions 3
Paths 4

Size

Total Lines 29
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 3

Importance

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