Passed
Branch master (0cf34c)
by Ankit
02:34
created

Server::isExpired()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 4
nc 4
nop 1
dl 0
loc 9
ccs 5
cts 5
cp 1
crap 4
rs 9.2
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 460 Checksum Mismatch */
31
    const HTTP_CHECKSUM_MISMATCH = 460;
32
33
    /** @var Request */
34
    protected $request;
35
36
    /** @var Response */
37
    protected $response;
38
39
    /** @var string */
40
    protected $uploadDir;
41
42
    /**
43
     * TusServer constructor.
44
     *
45
     * @param Cacheable|string $cacheAdapter
46
     */
47 3
    public function __construct($cacheAdapter = 'file')
48
    {
49 3
        $this->request   = new Request;
50 3
        $this->response  = new Response;
51 3
        $this->uploadDir = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'uploads';
52
53 3
        $this->setCache($cacheAdapter);
54 3
    }
55
56
    /**
57
     * Set upload dir.
58
     *
59
     * @param string $path
60
     *
61
     * @return void
62
     */
63 1
    public function setUploadDir(string $path)
64
    {
65 1
        $this->uploadDir = $path;
66 1
    }
67
68
    /**
69
     * Get upload dir.
70
     *
71
     * @return string
72
     */
73 1
    public function getUploadDir() : string
74
    {
75 1
        return $this->uploadDir;
76
    }
77
78
    /**
79
     * Get request.
80
     *
81
     * @return Request
82
     */
83 1
    public function getRequest() : Request
84
    {
85 1
        return $this->request;
86
    }
87
88
    /**
89
     * Get request.
90
     *
91
     * @return Response
92
     */
93 1
    public function getResponse() : Response
94
    {
95 1
        return $this->response;
96
    }
97
98
    /**
99
     * Handle all HTTP request.
100
     *
101
     * @return null|HttpResponse
102
     */
103 2
    public function serve()
104
    {
105 2
        $method = $this->getRequest()->method();
106
107 2
        if ( ! in_array($method, $this->request->allowedHttpVerbs())) {
108 1
            return $this->response->send(null, HttpResponse::HTTP_METHOD_NOT_ALLOWED);
109
        }
110
111 1
        $method = 'handle' . ucfirst(strtolower($method));
112
113 1
        $this->{$method}();
114
115 1
        $this->exit();
116 1
    }
117
118
    /**
119
     * Exit from current php process.
120
     *
121
     * @codeCoverageIgnore
122
     */
123
    protected function exit()
124
    {
125
        exit(0);
126
    }
127
128
    /**
129
     * Handle OPTIONS request.
130
     *
131
     * @return HttpResponse
132
     */
133 1
    protected function handleOptions() : HttpResponse
134
    {
135 1
        return $this->response->send(
136 1
            null,
137 1
            HttpResponse::HTTP_OK,
138
            [
139 1
                'Allow' => $this->request->allowedHttpVerbs(),
140 1
                'Tus-Version' => self::TUS_PROTOCOL_VERSION,
141 1
                'Tus-Extension' => implode(',', [
142 1
                    self::TUS_EXTENSION_CREATION,
143 1
                    self::TUS_EXTENSION_TERMINATION,
144 1
                    self::TUS_EXTENSION_CHECKSUM,
145 1
                    self::TUS_EXTENSION_EXPIRATION,
146
                ]),
147 1
                'Tus-Checksum-Algorithm' => $this->getSupportedHashAlgorithms(),
148
            ]
149
        );
150
    }
151
152
    /**
153
     * Handle HEAD request.
154
     *
155
     * @return HttpResponse
156
     */
157 3
    protected function handleHead() : HttpResponse
158
    {
159 3
        $checksum = $this->request->checksum();
160
161 3
        if ( ! $this->cache->get($checksum)) {
162 1
            return $this->response->send(null, HttpResponse::HTTP_NOT_FOUND);
163
        }
164
165 2
        $offset = $this->cache->get($checksum)['offset'] ?? false;
166
167 2
        if (false === $offset) {
168 1
            return $this->response->send(null, HttpResponse::HTTP_GONE);
169
        }
170
171 1
        return $this->response->send(null, HttpResponse::HTTP_OK, [
172 1
            'Upload-Offset' => (int) $offset,
173 1
            'Cache-Control' => 'no-store',
174 1
            'Tus-Resumable' => self::TUS_PROTOCOL_VERSION,
175
        ]);
176
    }
177
178
    /**
179
     * Handle POST request.
180
     *
181
     * @return HttpResponse
182
     */
183 2
    protected function handlePost() : HttpResponse
184
    {
185 2
        $fileName = $this->getRequest()->extractFileName();
186
187 2
        if (empty($fileName)) {
188 1
            return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
189
        }
190
191 1
        $checksum = $this->getUploadChecksum();
192 1
        $location = $this->getRequest()->url() . '/' . basename($this->uploadDir) . '/' . $fileName;
193
194 1
        $file = $this->buildFile([
195 1
            'name' => $fileName,
196 1
            'offset' => 0,
197 1
            'size' => $this->getRequest()->header('Upload-Length'),
198 1
            'file_path' => $this->uploadDir . DIRECTORY_SEPARATOR . $fileName,
199 1
            'location' => $location,
200 1
        ])->setChecksum($checksum);
201
202 1
        $this->cache->set($checksum, $file->details());
203
204 1
        return $this->response->send(
205 1
            ['data' => ['checksum' => $checksum]],
206 1
            HttpResponse::HTTP_CREATED,
207
            [
208 1
                'Location' => $location,
209 1
                'Upload-Expires' => $this->cache->get($checksum)['expires_at'],
210 1
                'Tus-Resumable' => self::TUS_PROTOCOL_VERSION,
211
            ]
212
        );
213
    }
214
215
    /**
216
     * Handle PATCH request.
217
     *
218
     * @return HttpResponse
219
     */
220 6
    protected function handlePatch() : HttpResponse
221
    {
222 6
        $checksum = $this->request->checksum();
223
224 6
        if ( ! $this->cache->get($checksum)) {
225 1
            return $this->response->send(null, HttpResponse::HTTP_GONE);
226
        }
227
228 5
        $meta = $this->cache->get($checksum);
229 5
        $file = $this->buildFile($meta);
230
231
        try {
232 5
            $fileSize = $file->getFileSize();
233 5
            $offset   = $file->setChecksum($checksum)->upload($fileSize);
234
235
            // If upload is done, verify checksum.
236 2
            if ($offset === $fileSize && $checksum !== $this->getUploadChecksum()) {
237 2
                return $this->response->send(null, self::HTTP_CHECKSUM_MISMATCH);
238
            }
239 3
        } catch (FileException $e) {
240 1
            return $this->response->send($e->getMessage(), HttpResponse::HTTP_UNPROCESSABLE_ENTITY);
241 2
        } catch (OutOfRangeException $e) {
242 1
            return $this->response->send(null, HttpResponse::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE);
243 1
        } catch (ConnectionException $e) {
244 1
            return $this->response->send(null, HttpResponse::HTTP_CONTINUE);
245
        }
246
247 1
        return $this->response->send(null, HttpResponse::HTTP_NO_CONTENT, [
248 1
            'Upload-Expires' => $this->cache->get($checksum)['expires_at'],
249 1
            'Upload-Offset' => $offset,
250 1
            'Tus-Resumable' => self::TUS_PROTOCOL_VERSION,
251
        ]);
252
    }
253
254
    /**
255
     * Handle GET request.
256
     *
257
     * @return BinaryFileResponse|HttpResponse
258
     */
259 4
    protected function handleGet()
260
    {
261 4
        $checksum = $this->request->checksum();
262
263 4
        if (empty($checksum)) {
264 1
            return $this->response->send('400 bad request.', HttpResponse::HTTP_BAD_REQUEST);
265
        }
266
267 3
        $fileMeta = $this->cache->get($checksum);
268
269 3
        if ( ! $fileMeta) {
270 1
            return $this->response->send('404 upload not found.', HttpResponse::HTTP_NOT_FOUND);
271
        }
272
273 2
        $resource = $fileMeta['file_path'] ?? null;
274 2
        $fileName = $fileMeta['name'] ?? null;
275
276 2
        if ( ! $resource || ! file_exists($resource)) {
277 1
            return $this->response->send('404 upload not found.', HttpResponse::HTTP_NOT_FOUND);
278
        }
279
280 1
        return $this->response->download($resource, $fileName);
281
    }
282
283
    /**
284
     * Handle DELETE request.
285
     *
286
     * @return HttpResponse
287
     */
288 3
    protected function handleDelete() : HttpResponse
289
    {
290 3
        $checksum = $this->request->checksum();
291 3
        $fileMeta = $this->cache->get($checksum);
292 3
        $resource = $fileMeta['file_path'] ?? null;
293
294 3
        if ( ! $resource) {
295 1
            return $this->response->send(null, HttpResponse::HTTP_NOT_FOUND);
296
        }
297
298 2
        $isDeleted = $this->cache->delete($checksum);
299
300 2
        if ( ! $isDeleted || ! file_exists($resource)) {
301 1
            return $this->response->send(null, HttpResponse::HTTP_GONE);
302
        }
303
304 1
        return $this->response->send(null, HttpResponse::HTTP_NO_CONTENT, [
305 1
            'Tus-Resumable' => self::TUS_PROTOCOL_VERSION,
306 1
            'Tus-Extension' => self::TUS_EXTENSION_TERMINATION,
307
        ]);
308
    }
309
310
    /**
311
     * Build file object.
312
     *
313
     * @param array $meta
314
     *
315
     * @return File
316
     */
317 1
    protected function buildFile(array $meta) : File
318
    {
319 1
        return (new File($meta['name'], $this->cache))
320 1
            ->setMeta($meta['offset'], $meta['size'], $meta['file_path'], $meta['location']);
321
    }
322
323
    /**
324
     * Get list of supported hash algorithms.
325
     *
326
     * @return string
327
     */
328 1
    protected function getSupportedHashAlgorithms()
329
    {
330 1
        $supportedAlgorithms = hash_algos();
331
332 1
        $algorithms = [];
333 1
        foreach ($supportedAlgorithms as $hashAlgo) {
334 1
            if (false !== strpos($hashAlgo, ',')) {
335 1
                $algorithms[] = "'{$hashAlgo}'";
336
            } else {
337 1
                $algorithms[] = $hashAlgo;
338
            }
339
        }
340
341 1
        return implode(',', $algorithms);
342
    }
343
344
    /**
345
     * Verify and get upload checksum from header.
346
     *
347
     * @return string|HttpResponse
348
     */
349 4
    protected function getUploadChecksum()
350
    {
351 4
        $checksumHeader = $this->getRequest()->header('Upload-Checksum');
352
353 4
        if (empty($checksumHeader)) {
354 1
            return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
355
        }
356
357 3
        list($checksumAlgorithm, $checksum) = explode(' ', $checksumHeader);
358
359 3
        $checksum = base64_decode($checksum);
360
361 3
        if ( ! in_array($checksumAlgorithm, hash_algos()) || false === $checksum) {
362 2
            return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
363
        }
364
365 1
        return $checksum;
366
    }
367
368
    /**
369
     * Get expired and incomplete uploads.
370
     *
371
     * @param array|null $contents
372
     *
373
     * @return bool
374
     */
375 3
    protected function isExpired($contents) : bool
376
    {
377 3
        $isExpired = empty($contents['expires_at']) || Carbon::parse($contents['expires_at'])->lt(Carbon::now());
378
379 3
        if ($isExpired && $contents['offset'] !== $contents['size']) {
380 3
            return true;
381
        }
382
383 2
        return false;
384
    }
385
386
    /**
387
     * Delete expired resources.
388
     *
389
     * @return array
390
     */
391 2
    public function handleExpiration()
392
    {
393 2
        $deleted   = [];
394 2
        $cacheKeys = $this->cache->keys();
395
396 2
        foreach ($cacheKeys as $key) {
397 2
            $contents = $this->cache->get($key, true);
398
399 2
            if ( ! $this->isExpired($contents)) {
400 1
                continue;
401
            }
402
403 2
            $cacheDeleted = $this->cache->delete($key);
404
405 2
            if ( ! $cacheDeleted) {
406 1
                continue;
407
            }
408
409 1
            if (file_exists($contents['file_path']) && is_writable($contents['file_path'])) {
410 1
                unlink($contents['file_path']);
411
            }
412
413 1
            $deleted[] = $contents;
414
        }
415
416 2
        return $deleted;
417
    }
418
419
    /**
420
     * No other methods are allowed.
421
     *
422
     * @param string $method
423
     * @param array  $params
424
     *
425
     * @return HttpResponse|BinaryFileResponse
426
     */
427 1
    public function __call(string $method, array $params)
428
    {
429 1
        return $this->response->send(null, HttpResponse::HTTP_BAD_REQUEST);
430
    }
431
}
432