Completed
Pull Request — master (#11)
by Ankit
02:22
created

Server::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 1

Importance

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