Passed
Branch tus-concatenation (500f8f)
by Ankit
02:34
created

Server::getPartialsMeta()   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 1
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\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
        $headers = [
209 3
            'Upload-Offset' => (int) $offset,
210 3
            'Cache-Control' => 'no-store',
211 3
            'Tus-Resumable' => self::TUS_PROTOCOL_VERSION,
212
        ];
213
214 3
        if (self::UPLOAD_TYPE_FINAL === $fileMeta['upload_type'] && $fileMeta['size'] !== $fileMeta['offset']) {
215 1
            unset($headers['Upload-Offset']);
216
        }
217
218 3
        if (self::UPLOAD_TYPE_NORMAL !== $fileMeta['upload_type']) {
219 2
            $headers += ['Upload-Concat' => $fileMeta['upload_type']];
220
        }
221
222 3
        return $this->response->send(null, HttpResponse::HTTP_OK, $headers);
223
    }
224
225
    /**
226
     * Handle POST request.
227
     *
228
     * @return HttpResponse
229
     */
230 4
    protected function handlePost() : HttpResponse
231
    {
232 4
        $fileName   = $this->getRequest()->extractFileName();
233 4
        $uploadType = self::UPLOAD_TYPE_NORMAL;
234
235 4
        if (empty($fileName)) {
236 1
            return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
237
        }
238
239 3
        $checksum = $this->getUploadChecksum();
240 3
        $filePath = $this->uploadDir . DIRECTORY_SEPARATOR . $fileName;
241
242 3
        if ($this->getRequest()->isFinal()) {
243 1
            return $this->handleConcatenation($fileName, $filePath);
244
        }
245
246 2
        if ($this->getRequest()->isPartial()) {
247 1
            $filePath   = $this->getPathForPartialUpload($checksum) . $fileName;
248 1
            $uploadType = self::UPLOAD_TYPE_PARTIAL;
249
        }
250
251 2
        $location = $this->getRequest()->url() . '/' . basename($this->uploadDir) . '/' . $fileName;
252
253 2
        $file = $this->buildFile([
254 2
            'name' => $fileName,
255 2
            'offset' => 0,
256 2
            'size' => $this->getRequest()->header('Upload-Length'),
257 2
            'file_path' => $filePath,
258 2
            'location' => $location,
259 2
        ])->setChecksum($checksum);
260
261 2
        $this->cache->set($checksum, $file->details() + ['upload_type' => $uploadType]);
262
263 2
        return $this->response->send(
264 2
            ['data' => ['checksum' => $checksum]],
265 2
            HttpResponse::HTTP_CREATED,
266
            [
267 2
                'Location' => $location,
268 2
                'Upload-Expires' => $this->cache->get($checksum)['expires_at'],
269 2
                'Tus-Resumable' => self::TUS_PROTOCOL_VERSION,
270
            ]
271
        );
272
    }
273
274
    /**
275
     * Handle file concatenation.
276
     *
277
     * @param string $fileName
278
     * @param string $filePath
279
     *
280
     * @return HttpResponse
281
     */
282 2
    protected function handleConcatenation(string $fileName, string $filePath) : HttpResponse
283
    {
284 2
        $partials  = $this->getRequest()->extractPartials();
285 2
        $location  = $this->getRequest()->url() . '/' . basename($this->uploadDir) . '/' . $fileName;
286 2
        $files     = $this->getPartialsMeta($partials);
287 2
        $filePaths = array_column($files, 'file_path');
288
289 2
        $file = $this->buildFile([
290 2
            'name' => $fileName,
291 2
            'offset' => 0,
292 2
            'size' => 0,
293 2
            'file_path' => $filePath,
294 2
            'location' => $location,
295 2
        ])->setFilePath($filePath);
296
297 2
        $file->setOffset($file->merge($files));
298
299
        // Verify checksum.
300 2
        $checksum = $this->getChecksum($filePath);
301
302 2
        if ($checksum !== $this->getUploadChecksum()) {
303 1
            return $this->response->send(null, self::HTTP_CHECKSUM_MISMATCH);
304
        }
305
306 1
        $this->cache->set($checksum, $file->details() + ['upload_type' => self::UPLOAD_TYPE_FINAL]);
307
308
        // Cleanup.
309 1
        if ($file->delete($filePaths, true)) {
310 1
            $this->cache->deleteAll($partials);
311
        }
312
313 1
        return $this->response->send(
314 1
            ['data' => ['checksum' => $checksum]],
315 1
            HttpResponse::HTTP_CREATED,
316
            [
317 1
                'Location' => $location,
318 1
                'Tus-Resumable' => self::TUS_PROTOCOL_VERSION,
319
            ]
320
        );
321
    }
322
323
    /**
324
     * Handle PATCH request.
325
     *
326
     * @return HttpResponse
327
     */
328 7
    protected function handlePatch() : HttpResponse
329
    {
330 7
        $checksum = $this->request->checksum();
331
332 7
        if ( ! $meta = $this->cache->get($checksum)) {
333 1
            return $this->response->send(null, HttpResponse::HTTP_GONE);
334
        }
335
336 6
        if (self::UPLOAD_TYPE_FINAL === $meta['upload_type']) {
337 1
            return $this->response->send(null, HttpResponse::HTTP_FORBIDDEN);
338
        }
339
340 5
        $file = $this->buildFile($meta);
341
342
        try {
343 5
            $fileSize = $file->getFileSize();
344 5
            $offset   = $file->setChecksum($checksum)->upload($fileSize);
345
346
            // If upload is done, verify checksum.
347 2
            if ($offset === $fileSize && $checksum !== $this->getUploadChecksum()) {
348 2
                return $this->response->send(null, self::HTTP_CHECKSUM_MISMATCH);
349
            }
350 3
        } catch (FileException $e) {
351 1
            return $this->response->send($e->getMessage(), HttpResponse::HTTP_UNPROCESSABLE_ENTITY);
352 2
        } catch (OutOfRangeException $e) {
353 1
            return $this->response->send(null, HttpResponse::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE);
354 1
        } catch (ConnectionException $e) {
355 1
            return $this->response->send(null, HttpResponse::HTTP_CONTINUE);
356
        }
357
358 1
        return $this->response->send(null, HttpResponse::HTTP_NO_CONTENT, [
359 1
            'Upload-Expires' => $this->cache->get($checksum)['expires_at'],
360 1
            'Upload-Offset' => $offset,
361 1
            'Tus-Resumable' => self::TUS_PROTOCOL_VERSION,
362
        ]);
363
    }
364
365
    /**
366
     * Handle GET request.
367
     *
368
     * @return BinaryFileResponse|HttpResponse
369
     */
370 4
    protected function handleGet()
371
    {
372 4
        $checksum = $this->request->checksum();
373
374 4
        if (empty($checksum)) {
375 1
            return $this->response->send('400 bad request.', HttpResponse::HTTP_BAD_REQUEST);
376
        }
377
378 3
        $fileMeta = $this->cache->get($checksum);
379
380 3
        if ( ! $fileMeta) {
381 1
            return $this->response->send('404 upload not found.', HttpResponse::HTTP_NOT_FOUND);
382
        }
383
384 2
        $resource = $fileMeta['file_path'] ?? null;
385 2
        $fileName = $fileMeta['name'] ?? null;
386
387 2
        if ( ! $resource || ! file_exists($resource)) {
388 1
            return $this->response->send('404 upload not found.', HttpResponse::HTTP_NOT_FOUND);
389
        }
390
391 1
        return $this->response->download($resource, $fileName);
392
    }
393
394
    /**
395
     * Handle DELETE request.
396
     *
397
     * @return HttpResponse
398
     */
399 3
    protected function handleDelete() : HttpResponse
400
    {
401 3
        $checksum = $this->request->checksum();
402 3
        $fileMeta = $this->cache->get($checksum);
403 3
        $resource = $fileMeta['file_path'] ?? null;
404
405 3
        if ( ! $resource) {
406 1
            return $this->response->send(null, HttpResponse::HTTP_NOT_FOUND);
407
        }
408
409 2
        $isDeleted = $this->cache->delete($checksum);
410
411 2
        if ( ! $isDeleted || ! file_exists($resource)) {
412 1
            return $this->response->send(null, HttpResponse::HTTP_GONE);
413
        }
414
415 1
        unlink($resource);
416
417 1
        return $this->response->send(null, HttpResponse::HTTP_NO_CONTENT, [
418 1
            'Tus-Resumable' => self::TUS_PROTOCOL_VERSION,
419 1
            'Tus-Extension' => self::TUS_EXTENSION_TERMINATION,
420
        ]);
421
    }
422
423
    /**
424
     * Build file object.
425
     *
426
     * @param array $meta
427
     *
428
     * @return File
429
     */
430 1
    protected function buildFile(array $meta) : File
431
    {
432 1
        $file = new File($meta['name'], $this->cache);
433
434 1
        if (array_key_exists('offset', $meta)) {
435 1
            $file->setMeta($meta['offset'], $meta['size'], $meta['file_path'], $meta['location']);
436
        }
437
438 1
        return $file;
439
    }
440
441
    /**
442
     * Get list of supported hash algorithms.
443
     *
444
     * @return string
445
     */
446 1
    protected function getSupportedHashAlgorithms()
447
    {
448 1
        $supportedAlgorithms = hash_algos();
449
450 1
        $algorithms = [];
451 1
        foreach ($supportedAlgorithms as $hashAlgo) {
452 1
            if (false !== strpos($hashAlgo, ',')) {
453 1
                $algorithms[] = "'{$hashAlgo}'";
454
            } else {
455 1
                $algorithms[] = $hashAlgo;
456
            }
457
        }
458
459 1
        return implode(',', $algorithms);
460
    }
461
462
    /**
463
     * Verify and get upload checksum from header.
464
     *
465
     * @return string|HttpResponse
466
     */
467 4
    protected function getUploadChecksum()
468
    {
469 4
        $checksumHeader = $this->getRequest()->header('Upload-Checksum');
470
471 4
        if (empty($checksumHeader)) {
472 1
            return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
473
        }
474
475 3
        list($checksumAlgorithm, $checksum) = explode(' ', $checksumHeader);
476
477 3
        $checksum = base64_decode($checksum);
478
479 3
        if ( ! in_array($checksumAlgorithm, hash_algos()) || false === $checksum) {
480 2
            return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
481
        }
482
483 1
        return $checksum;
484
    }
485
486
    /**
487
     * Get expired and incomplete uploads.
488
     *
489
     * @param array|null $contents
490
     *
491
     * @return bool
492
     */
493 3
    protected function isExpired($contents) : bool
494
    {
495 3
        $isExpired = empty($contents['expires_at']) || Carbon::parse($contents['expires_at'])->lt(Carbon::now());
496
497 3
        if ($isExpired && $contents['offset'] !== $contents['size']) {
498 3
            return true;
499
        }
500
501 2
        return false;
502
    }
503
504
    /**
505
     * Get path for partial upload.
506
     *
507
     * @param string $checksum
508
     *
509
     * @return string
510
     */
511 1
    protected function getPathForPartialUpload(string $checksum) : string
512
    {
513 1
        list($actualChecksum) = explode(self::PARTIAL_UPLOAD_NAME_SEPARATOR, $checksum);
514
515 1
        $path = $this->uploadDir . DIRECTORY_SEPARATOR . $actualChecksum . DIRECTORY_SEPARATOR;
516
517 1
        if ( ! file_exists($path)) {
518 1
            mkdir($path);
519
        }
520
521 1
        return $path;
522
    }
523
524
    /**
525
     * Get metadata of partials.
526
     *
527
     * @param array $partials
528
     *
529
     * @return array
530
     */
531 3
    protected function getPartialsMeta(array $partials)
532
    {
533 3
        $files = [];
534
535 3
        foreach ($partials as $partial) {
536 3
            $fileMeta = $this->getCache()->get($partial);
537
538 3
            $files[] = $fileMeta;
539
        }
540
541 3
        return $files;
542
    }
543
544
    /**
545
     * Delete expired resources.
546
     *
547
     * @return array
548
     */
549 2
    public function handleExpiration()
550
    {
551 2
        $deleted   = [];
552 2
        $cacheKeys = $this->cache->keys();
553
554 2
        foreach ($cacheKeys as $key) {
555 2
            $fileMeta = $this->cache->get($key, true);
556
557 2
            if ( ! $this->isExpired($fileMeta)) {
558 1
                continue;
559
            }
560
561 2
            $cacheDeleted = $this->cache->delete($key);
562
563 2
            if ( ! $cacheDeleted) {
564 1
                continue;
565
            }
566
567 1
            if (file_exists($fileMeta['file_path']) && is_writable($fileMeta['file_path'])) {
568 1
                unlink($fileMeta['file_path']);
569
            }
570
571 1
            $deleted[] = $fileMeta;
572
        }
573
574 2
        return $deleted;
575
    }
576
577
    /**
578
     * No other methods are allowed.
579
     *
580
     * @param string $method
581
     * @param array  $params
582
     *
583
     * @return HttpResponse|BinaryFileResponse
584
     */
585 1
    public function __call(string $method, array $params)
586
    {
587 1
        return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
588
    }
589
}
590