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

Server::handlePost()   B

Complexity

Conditions 4
Paths 4

Size

Total Lines 41
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 28
CRAP Score 4

Importance

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