Passed
Push — master ( ff76b0...84f20a )
by Ankit
02:41
created

Client::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 1

Importance

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