Passed
Pull Request — master (#326)
by Individual IT
10:07
created

Client::getOffset()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 2
eloc 5
c 2
b 0
f 0
nc 2
nop 0
dl 0
loc 9
ccs 5
cts 5
cp 1
crap 2
rs 10
1
<?php
2
3
namespace TusPhp\Tus;
4
5
use TusPhp\File;
6
use Carbon\Carbon;
7
use TusPhp\Config;
8
use Ramsey\Uuid\Uuid;
9
use TusPhp\Exception\TusException;
10
use TusPhp\Exception\FileException;
11
use GuzzleHttp\Client as GuzzleClient;
12
use GuzzleHttp\Exception\ClientException;
13
use TusPhp\Exception\ConnectionException;
14
use GuzzleHttp\Exception\ConnectException;
15
use Symfony\Component\HttpFoundation\Response as HttpResponse;
16
17
class Client extends AbstractTus
18
{
19
    /** @var GuzzleClient */
20
    protected $client;
21
22
    /** @var string */
23
    protected $filePath;
24
25
    /** @var int */
26
    protected $fileSize = 0;
27
28
    /** @var string */
29
    protected $fileName;
30
31
    /** @var string */
32
    protected $key;
33
34
    /** @var string */
35
    protected $url;
36
37
    /** @var string */
38
    protected $checksum;
39
40
    /** @var int */
41
    protected $partialOffset = -1;
42
43
    /** @var bool */
44
    protected $partial = false;
45
46
    /** @var string */
47
    protected $checksumAlgorithm = 'sha256';
48
49
    /** @var array */
50
    protected $metadata = [];
51
52
    /** @var array */
53
    protected $headers = [];
54
55
    /**
56
     * Client constructor.
57
     *
58
     * @param string $baseUri
59
     * @param array  $options
60
     *
61
     * @throws \ReflectionException
62
     */
63 3
    public function __construct(string $baseUri, array $options = [])
64
    {
65 3
        $this->headers      = $options['headers'] ?? [];
66 3
        $options['headers'] = [
67 3
            'Tus-Resumable' => self::TUS_PROTOCOL_VERSION,
68 3
        ] + ($this->headers);
69
70 3
        $this->client = new GuzzleClient(
71 3
            ['base_uri' => $baseUri] + $options
72
        );
73
74 3
        Config::set(__DIR__ . '/../Config/client.php');
75
76 3
        $this->setCache('file');
77 3
    }
78
79
    /**
80
     * Set file properties.
81
     *
82
     * @param string $file File path.
83
     * @param string $name File name.
84
     *
85
     * @return Client
86
     */
87 2
    public function file(string $file, string $name = null) : self
88
    {
89 2
        $this->filePath = $file;
90
91 2
        if ( ! file_exists($file) || ! is_readable($file)) {
92 1
            throw new FileException('Cannot read file: ' . $file);
93
        }
94
95 1
        $this->fileName = $name ?? basename($this->filePath);
96 1
        $this->fileSize = filesize($file);
97
98 1
        $this->addMetadata('filename', $this->fileName);
99
100 1
        return $this;
101
    }
102
103
    /**
104
     * Get file path.
105
     *
106
     * @return string|null
107
     */
108 1
    public function getFilePath() : ?string
109
    {
110 1
        return $this->filePath;
111
    }
112
113
    /**
114
     * Set file name.
115
     *
116
     * @param string $name
117
     *
118
     * @return Client
119
     */
120 1
    public function setFileName(string $name) : self
121
    {
122 1
        $this->addMetadata('filename', $this->fileName = $name);
123
124 1
        return $this;
125
    }
126
127
    /**
128
     * Get file name.
129
     *
130
     * @return string|null
131
     */
132 2
    public function getFileName() : ?string
133
    {
134 2
        return $this->fileName;
135
    }
136
137
    /**
138
     * Get file size.
139
     *
140
     * @return int
141
     */
142 1
    public function getFileSize() : int
143
    {
144 1
        return $this->fileSize;
145
    }
146
147
    /**
148
     * Get guzzle client.
149
     *
150
     * @return GuzzleClient
151
     */
152 2
    public function getClient() : GuzzleClient
153
    {
154 2
        return $this->client;
155
    }
156
157
    /**
158
     * Set checksum.
159
     *
160
     * @param string $checksum
161
     *
162
     * @return Client
163
     */
164 1
    public function setChecksum(string $checksum) : self
165
    {
166 1
        $this->checksum = $checksum;
167
168 1
        return $this;
169
    }
170
171
    /**
172
     * Get checksum.
173
     *
174
     * @return string
175
     */
176 1
    public function getChecksum() : string
177
    {
178 1
        if (empty($this->checksum)) {
179 1
            $this->setChecksum(hash_file($this->getChecksumAlgorithm(), $this->getFilePath()));
180
        }
181
182 1
        return $this->checksum;
183
    }
184
185
    /**
186
     * Add metadata.
187
     *
188
     * @param string $key
189
     * @param string $value
190
     *
191
     * @return Client
192
     */
193 1
    public function addMetadata(string $key, string $value) : self
194
    {
195 1
        $this->metadata[$key] = base64_encode($value);
196
197 1
        return $this;
198
    }
199
200
    /**
201
     * Remove metadata.
202
     *
203
     * @param string $key
204
     *
205
     * @return Client
206
     */
207 1
    public function removeMetadata(string $key) : self
208
    {
209 1
        unset($this->metadata[$key]);
210
211 1
        return $this;
212
    }
213
214
    /**
215
     * Set metadata.
216
     *
217
     * @param array $items
218
     *
219
     * @return Client
220
     */
221 1
    public function setMetadata(array $items) : self
222
    {
223 1
        $items = array_map('base64_encode', $items);
224
225 1
        $this->metadata = $items;
226
227 1
        return $this;
228
    }
229
230
    /**
231
     * Get metadata.
232
     *
233
     * @return array
234
     */
235 1
    public function getMetadata() : array
236
    {
237 1
        return $this->metadata;
238
    }
239
240
    /**
241
     * Get metadata for Upload-Metadata header.
242
     *
243
     * @return string
244
     */
245 1
    protected function getUploadMetadataHeader() : string
246
    {
247 1
        $metadata = [];
248
249 1
        foreach ($this->getMetadata() as $key => $value) {
250 1
            $metadata[] = "{$key} {$value}";
251
        }
252
253 1
        return implode(',', $metadata);
254
    }
255
256
    /**
257
     * Set key.
258
     *
259
     * @param string $key
260
     *
261
     * @return Client
262
     */
263 1
    public function setKey(string $key) : self
264
    {
265 1
        $this->key = $key;
266
267 1
        return $this;
268
    }
269
270
    /**
271
     * Get key.
272
     *
273
     * @return string
274
     */
275 1
    public function getKey() : string
276
    {
277 1
        return $this->key;
278
    }
279
280
    /**
281
     * Get url.
282
     *
283
     * @return string|null
284
     */
285 2
    public function getUrl() : ?string
286
    {
287 2
        $this->url = $this->getCache()->get($this->getKey())['location'] ?? null;
288
289 2
        if ( ! $this->url) {
290 1
            throw new FileException('File not found.');
291
        }
292
293 1
        return $this->url;
294
    }
295
296
    /**
297
     * Set checksum algorithm.
298
     *
299
     * @param string $algorithm
300
     *
301
     * @return Client
302
     */
303 1
    public function setChecksumAlgorithm(string $algorithm) : self
304
    {
305 1
        $this->checksumAlgorithm = $algorithm;
306
307 1
        return $this;
308
    }
309
310
    /**
311
     * Get checksum algorithm.
312
     *
313
     * @return string
314
     */
315 1
    public function getChecksumAlgorithm() : string
316
    {
317 1
        return $this->checksumAlgorithm;
318
    }
319
320
    /**
321
     * Check if current upload is expired.
322
     *
323
     * @return bool
324
     */
325 2
    public function isExpired() : bool
326
    {
327 2
        $expiresAt = $this->getCache()->get($this->getKey())['expires_at'] ?? null;
328
329 2
        return empty($expiresAt) || Carbon::parse($expiresAt)->lt(Carbon::now());
330
    }
331
332
    /**
333
     * Check if this is a partial upload request.
334
     *
335
     * @return bool
336
     */
337 2
    public function isPartial() : bool
338
    {
339 2
        return $this->partial;
340
    }
341
342
    /**
343
     * Get partial offset.
344
     *
345
     * @return int
346
     */
347 1
    public function getPartialOffset() : int
348
    {
349 1
        return $this->partialOffset;
350
    }
351
352
    /**
353
     * Set offset and force this to be a partial upload request.
354
     *
355
     * @param int $offset
356
     *
357
     * @return self
358
     */
359 1
    public function seek(int $offset) : self
360
    {
361 1
        $this->partialOffset = $offset;
362
363 1
        $this->partial();
364
365 1
        return $this;
366
    }
367
368
    /**
369
     * Upload file.
370
     *
371
     * @param int $bytes Bytes to upload
372
     *
373
     * @throws TusException
374
     * @throws ConnectionException
375
     *
376
     * @return int
377
     */
378 6
    public function upload(int $bytes = -1) : int
379
    {
380 6
        $bytes  = $bytes < 0 ? $this->getFileSize() : $bytes;
381 6
        $offset = $this->partialOffset < 0 ? 0 : $this->partialOffset;
0 ignored issues
show
Unused Code introduced by
The assignment to $offset is dead and can be removed.
Loading history...
382
383
        try {
384
            // Check if this upload exists with HEAD request.
385 6
            $offset = $this->sendHeadRequest();
386 3
        } catch (FileException | ClientException $e) {
387
            // Create a new upload.
388 2
            $this->url = $this->create($this->getKey());
389 1
        } catch (ConnectException $e) {
390 1
            throw new ConnectionException("Couldn't connect to server.");
391
        }
392
393
        // Verify that upload is not yet expired.
394 5
        if ($this->isExpired()) {
395 1
            throw new TusException('Upload expired.');
396
        }
397
398
        // Now, resume upload with PATCH request.
399 4
        return $this->sendPatchRequest($bytes, $offset);
400
    }
401
402
    /**
403
     * Returns offset if file is partially uploaded.
404
     *
405
     * @return bool|int
406
     */
407 3
    public function getOffset()
408
    {
409
        try {
410 3
            $offset = $this->sendHeadRequest();
411 2
        } catch (FileException | ClientException $e) {
412 2
            return false;
413
        }
414
415 1
        return $offset;
416
    }
417
418
    /**
419
     * Create resource with POST request.
420
     *
421
     * @param string $key
422
     *
423
     * @throws FileException
424
     *
425
     * @return string
426
     */
427 3
    public function create(string $key) : string
428
    {
429 3
        return $this->createWithUpload($key, 0)['location'];
430 3
    }
431 3
432 3
    /**
433 3
     * Create resource with POST request and upload data using the creation-with-upload extension
434
     *
435
     * @see https://tus.io/protocols/resumable-upload.html#creation-with-upload
436 3
     *
437 1
     * @param string $key
438
     * @param int $bytes -1 => all data; 0 => no data
439
     * @return array ['location' => $uploadLocation, 'offset' => $offset]
440 3
     * @throws \GuzzleHttp\Exception\GuzzleException
441 3
     */
442
    public function createWithUpload(string $key, int $bytes = -1) : array
443
    {
444 3
        $bytes  = $bytes < 0 ? $this->fileSize : $bytes;
445
        $data   = '';
446 3
        if ($bytes > 0) {
447 1
            $data = $this->getData(0, $bytes);
448
        }
449
        $headers = $this->headers + [
450 2
            'Upload-Length' => $this->fileSize,
451
            'Upload-Key' => $key,
452 2
            'Upload-Checksum' => $this->getUploadChecksumHeader(),
453 2
            'Upload-Metadata' => $this->getUploadMetadataHeader(),
454 2
        ];
455
456
        if ($bytes > 0) {
457 2
            $headers += ['Content-Type' => self::HEADER_CONTENT_TYPE];
458
            $headers += ['Content-Length' => \strlen($data)];
459
        }
460
        if ($this->isPartial()) {
461
            $headers += ['Upload-Concat' => 'partial'];
462
        }
463
464
        try {
465
            $response = $this->getClient()->post($this->apiPath, [
466
                'body' => $data,
467
                'headers' => $headers,
468 3
            ]);
469
        } catch (ClientException $e) {
470 3
            $response = $e->getResponse();
471 3
        }
472 3
473 3
        $statusCode = $response->getStatusCode();
474 3
475 3
        if (HttpResponse::HTTP_CREATED !== $statusCode) {
476 3
            throw new FileException('Unable to create resource.');
477
        }
478
479
        $uploadLocation = current($response->getHeader('location'));
480 3
        $offset         = null;
481 3
        if ($bytes > 0) {
482 3
            $offset = current($response->getHeader('upload-offset'));
483
        }
484 3
485 2
        $this->getCache()->set($this->getKey(), [
486
            'location' => $uploadLocation,
487
            'expires_at' => Carbon::now()->addSeconds($this->getCache()->getTtl())->format($this->getCache()::RFC_7231),
488 1
        ]);
489
490
        return ['location' => $uploadLocation, 'offset' => $offset];
491
    }
492
493
    /**
494
     * Concatenate 2 or more partial uploads.
495
     *
496
     * @param string $key
497
     * @param mixed  $partials
498 3
     *
499
     * @return string
500
     */
501 3
    public function concat(string $key, ...$partials) : string
502 2
    {
503 2
        $response = $this->getClient()->post($this->apiPath, [
504
            'headers' => $this->headers + [
505 2
                'Upload-Length' => $this->fileSize,
506 2
                'Upload-Key' => $key,
507
                'Upload-Checksum' => $this->getUploadChecksumHeader(),
508
                'Upload-Metadata' => $this->getUploadMetadataHeader(),
509 1
                'Upload-Concat' => self::UPLOAD_TYPE_FINAL . ';' . implode(' ', $partials),
510
            ],
511
        ]);
512
513
        $data       = json_decode($response->getBody(), true);
514
        $checksum   = $data['data']['checksum'] ?? null;
515
        $statusCode = $response->getStatusCode();
516
517
        if (HttpResponse::HTTP_CREATED !== $statusCode || ! $checksum) {
518 3
            throw new FileException('Unable to create resource.');
519
        }
520 3
521
        return $checksum;
522 3
    }
523 1
524
    /**
525
     * Send DELETE request.
526 2
     *
527
     * @throws FileException
528 2
     *
529 1
     * @return void
530
     */
531
    public function delete()
532 2
    {
533 2
        try {
534
            $this->getClient()->delete($this->getUrl());
535
        } catch (ClientException $e) {
536
            $statusCode = $e->getResponse()->getStatusCode();
537
538
            if (HttpResponse::HTTP_NOT_FOUND === $statusCode || HttpResponse::HTTP_GONE === $statusCode) {
539
                throw new FileException('File not found.');
540
            }
541
        }
542 2
    }
543
544 2
    /**
545 2
     * Set as partial request.
546
     *
547 2
     * @param bool $state
548 1
     *
549
     * @return void
550
     */
551 1
    protected function partial(bool $state = true)
552
    {
553
        $this->partial = $state;
554
555
        if ( ! $this->partial) {
556
            return;
557
        }
558
559
        $key = $this->getKey();
560
561
        if (false !== strpos($key, self::PARTIAL_UPLOAD_NAME_SEPARATOR)) {
562
            [$key, /* $partialKey */] = explode(self::PARTIAL_UPLOAD_NAME_SEPARATOR, $key);
563
        }
564
565
        $this->key = $key . self::PARTIAL_UPLOAD_NAME_SEPARATOR . Uuid::uuid4()->toString();
566 7
    }
567
568 7
    /**
569 7
     * Send HEAD request.
570 7
     *
571 7
     * @throws FileException
572 7
     *
573
     * @return int
574
     */
575 7
    protected function sendHeadRequest() : int
576 1
    {
577
        $response   = $this->getClient()->head($this->getUrl());
578 6
        $statusCode = $response->getStatusCode();
579
580
        if (HttpResponse::HTTP_OK !== $statusCode) {
581
            throw new FileException('File not found.');
582 7
        }
583 7
584 7
        return (int) current($response->getHeader('upload-offset'));
585
    }
586
587 2
    /**
588 5
     * Send PATCH request.
589 4
     *
590 1
     * @param int $bytes
591 1
     * @param int $offset
592
     *
593
     * @throws TusException
594
     * @throws FileException
595
     * @throws ConnectionException
596
     *
597
     * @return int
598
     */
599
    protected function sendPatchRequest(int $bytes, int $offset) : int
600
    {
601
        $data    = $this->getData($offset, $bytes);
602 4
        $headers = $this->headers + [
603
            'Content-Type' => self::HEADER_CONTENT_TYPE,
604 4
            'Content-Length' => \strlen($data),
605 4
            'Upload-Checksum' => $this->getUploadChecksumHeader(),
606
        ];
607 4
608 1
        if ($this->isPartial()) {
609
            $headers += ['Upload-Concat' => self::UPLOAD_TYPE_PARTIAL];
610
        } else {
611 3
            $headers += ['Upload-Offset' => $offset];
612 1
        }
613
614
        try {
615 2
            $response = $this->getClient()->patch($this->getUrl(), [
616 1
                'body' => $data,
617
                'headers' => $headers,
618
            ]);
619 1
620
            return (int) current($response->getHeader('upload-offset'));
621
        } catch (ClientException $e) {
622
            throw $this->handleClientException($e);
623
        } catch (ConnectException $e) {
624
            throw new ConnectionException("Couldn't connect to server.");
625
        }
626
    }
627
628
    /**
629
     * Handle client exception during patch request.
630 2
     *
631
     * @param ClientException $e
632 2
     *
633 2
     * @return mixed
634
     */
635 2
    protected function handleClientException(ClientException $e)
636
    {
637 2
        $response   = $e->getResponse();
638
        $statusCode = $response !== null ? $response->getStatusCode() : HttpResponse::HTTP_INTERNAL_SERVER_ERROR;
639 2
640
        if (HttpResponse::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE === $statusCode) {
641 2
            return new FileException('The uploaded file is corrupt.');
642
        }
643
644
        if (HttpResponse::HTTP_CONTINUE === $statusCode) {
645
            return new ConnectionException('Connection aborted by user.');
646
        }
647
648
        if (HttpResponse::HTTP_UNSUPPORTED_MEDIA_TYPE === $statusCode) {
649 1
            return new TusException('Unsupported media types.');
650
        }
651 1
652
        return new TusException($response->getBody(), $statusCode);
653
    }
654
655
    /**
656
     * Get X bytes of data from file.
657
     *
658
     * @param int $offset
659
     * @param int $bytes
660
     *
661
     * @return string
662
     */
663
    protected function getData(int $offset, int $bytes) : string
664
    {
665
        $file   = new File;
666
        $handle = $file->open($this->getFilePath(), $file::READ_BINARY);
667
668
        $file->seek($handle, $offset);
669
670
        $data = $file->read($handle, $bytes);
671
672
        $file->close($handle);
673
674
        return $data;
675
    }
676
677
    /**
678
     * Get upload checksum header.
679
     *
680
     * @return string
681
     */
682
    protected function getUploadChecksumHeader() : string
683
    {
684
        return $this->getChecksumAlgorithm() . ' ' . base64_encode($this->getChecksum());
685
    }
686
}
687