Completed
Pull Request — master (#11)
by Ankit
03:15
created

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