Completed
Pull Request — master (#152)
by
unknown
02:20
created

Client::getUploadMetadataHeader()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

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