Completed
Push — master ( adc87c...18f2cf )
by Ankit
02:21
created

Server::getChecksumAlgorithm()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2

Importance

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