Passed
Branch tus-concatenation (9e8141)
by Ankit
02:26
created

Server::handlePost()   B

Complexity

Conditions 4
Paths 4

Size

Total Lines 40
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 27
CRAP Score 4

Importance

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