Passed
Pull Request — master (#145)
by
unknown
02:09
created

Client::sendHeadRequest()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
eloc 5
nc 2
nop 0
dl 0
loc 10
ccs 6
cts 6
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
    public function addMetadata(string $key, string $value) : self
187
    {
188
        $this->metadata[$key] = base64_encode($value);
189
190
        return $this;
191
    }
192
193
    /**
194
     * Remove metadata.
195
     *
196
     * @param string $key
197
     *
198
     * @return Client
199
     */
200
    public function removeMetadata(string $key) : self
201
    {
202
        unset($this->metadata[$key]);
203
204
        return $this;
205
    }
206
207
    /**
208
     * Set metadata.
209
     *
210
     * @param array $items
211
     *
212
     * @return Client
213
     */
214
    public function setMetadata(array $items) : self
215
    {
216
        $items = array_map('base64_encode', $items);
217
218
        $this->metadata = $items;
219
220
        return $this;
221
    }
222
223
    /**
224
     * Get metadata.
225
     *
226
     * @return array
227
     */
228
    public function getMetadata() : array
229
    {
230
        $this->addMetadata('filename ', $this->fileName);
231
232
        return $this->metadata;
233
    }
234
235
    /**
236
     * Get metadata for Upload-Metadata header.
237
     *
238
     * @return string
239
     */
240
    protected function getUploadMetadata() : string
241
    {
242
        return implode(',', $this->getMetadata());
243
    }
244
245
    /**
246
     * Set key.
247
     *
248
     * @param string $key
249
     *
250
     * @return Client
251
     */
252 1
    public function setKey(string $key) : self
253
    {
254 1
        $this->key = $key;
255
256 1
        return $this;
257
    }
258
259
    /**
260
     * Get key.
261
     *
262
     * @return string
263
     */
264 1
    public function getKey() : string
265
    {
266 1
        return $this->key;
267
    }
268
269
    /**
270
     * Get url.
271
     *
272
     * @return string|null
273
     */
274 2
    public function getUrl() : ?string
275
    {
276 2
        $this->url = $this->getCache()->get($this->getKey())['location'] ?? null;
277
278 2
        if ( ! $this->url) {
279 1
            throw new FileException('File not found.');
280
        }
281
282 1
        return $this->url;
283
    }
284
285
    /**
286
     * Set checksum algorithm.
287
     *
288
     * @param string $algorithm
289
     *
290
     * @return Client
291
     */
292 1
    public function setChecksumAlgorithm(string $algorithm) : self
293
    {
294 1
        $this->checksumAlgorithm = $algorithm;
295
296 1
        return $this;
297
    }
298
299
    /**
300
     * Get checksum algorithm.
301
     *
302
     * @return string
303
     */
304 1
    public function getChecksumAlgorithm() : string
305
    {
306 1
        return $this->checksumAlgorithm;
307
    }
308
309
    /**
310
     * Check if current upload is expired.
311
     *
312
     * @return bool
313
     */
314 2
    public function isExpired() : bool
315
    {
316 2
        $expiresAt = $this->getCache()->get($this->getKey())['expires_at'] ?? null;
317
318 2
        return empty($expiresAt) || Carbon::parse($expiresAt)->lt(Carbon::now());
319
    }
320
321
    /**
322
     * Check if this is a partial upload request.
323
     *
324
     * @return bool
325
     */
326 2
    public function isPartial() : bool
327
    {
328 2
        return $this->partial;
329
    }
330
331
    /**
332
     * Get partial offset.
333
     *
334
     * @return int
335
     */
336 1
    public function getPartialOffset() : int
337
    {
338 1
        return $this->partialOffset;
339
    }
340
341
    /**
342
     * Set offset and force this to be a partial upload request.
343
     *
344
     * @param int $offset
345
     *
346
     * @return self
347
     */
348 1
    public function seek(int $offset) : self
349
    {
350 1
        $this->partialOffset = $offset;
351
352 1
        $this->partial();
353
354 1
        return $this;
355
    }
356
357
    /**
358
     * Upload file.
359
     *
360
     * @param int $bytes Bytes to upload
361
     *
362
     * @throws TusException
363
     * @throws ConnectionException
364
     *
365
     * @return int
366
     */
367 6
    public function upload(int $bytes = -1) : int
368
    {
369 6
        $bytes  = $bytes < 0 ? $this->getFileSize() : $bytes;
370 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...
371
372
        try {
373
            // Check if this upload exists with HEAD request.
374 6
            $offset = $this->sendHeadRequest();
375 3
        } catch (FileException | ClientException $e) {
376
            // Create a new upload.
377 2
            $this->url = $this->create($this->getKey());
378 1
        } catch (ConnectException $e) {
379 1
            throw new ConnectionException("Couldn't connect to server.");
380
        }
381
382
        // Verify that upload is not yet expired.
383 5
        if ($this->isExpired()) {
384 1
            throw new TusException('Upload expired.');
385
        }
386
387
        // Now, resume upload with PATCH request.
388 4
        return $this->sendPatchRequest($bytes, $offset);
389
    }
390
391
    /**
392
     * Returns offset if file is partially uploaded.
393
     *
394
     * @return bool|int
395
     */
396 3
    public function getOffset()
397
    {
398
        try {
399 3
            $offset = $this->sendHeadRequest();
400 2
        } catch (FileException | ClientException $e) {
401 2
            return false;
402
        }
403
404 1
        return $offset;
405
    }
406
407
    /**
408
     * Create resource with POST request.
409
     *
410
     * @param string $key
411
     *
412
     * @throws FileException
413
     *
414
     * @return string
415
     */
416
    public function create(string $key) : string
417
    {
418
        $headers = [
419
            'Upload-Length' => $this->fileSize,
420
            'Upload-Key' => $key,
421
            'Upload-Checksum' => $this->getUploadChecksumHeader(),
422
            'Upload-Metadata' => $this->getUploadMetadata(),
423
        ];
424
425
        if ($this->isPartial()) {
426
            $headers += ['Upload-Concat' => 'partial'];
427
        }
428
429
        $response = $this->getClient()->post($this->apiPath, [
430
            'headers' => $headers,
431
        ]);
432
433
        $statusCode = $response->getStatusCode();
434
435
        if (HttpResponse::HTTP_CREATED !== $statusCode) {
436
            throw new FileException('Unable to create resource.');
437
        }
438
439
        $uploadLocation = current($response->getHeader('location'));
440
441
        $this->getCache()->set($this->getKey(), [
442
            'location' => $uploadLocation,
443
            'expires_at' => Carbon::now()->addSeconds($this->getCache()->getTtl())->format($this->getCache()::RFC_7231),
444
        ]);
445
446
        return $uploadLocation;
447
    }
448
449
    /**
450
     * Concatenate 2 or more partial uploads.
451
     *
452
     * @param string $key
453
     * @param mixed  $partials
454
     *
455
     * @return string
456
     */
457
    public function concat(string $key, ...$partials) : string
458
    {
459
        $response = $this->getClient()->post($this->apiPath, [
460
            'headers' => [
461
                'Upload-Length' => $this->fileSize,
462
                'Upload-Key' => $key,
463
                'Upload-Checksum' => $this->getUploadChecksumHeader(),
464
                'Upload-Metadata' => $this->getUploadMetadata(),
465
                'Upload-Concat' => self::UPLOAD_TYPE_FINAL . ';' . implode(' ', $partials),
466
            ],
467
        ]);
468
469
        $data       = json_decode($response->getBody(), true);
470
        $checksum   = $data['data']['checksum'] ?? null;
471
        $statusCode = $response->getStatusCode();
472
473
        if (HttpResponse::HTTP_CREATED !== $statusCode || ! $checksum) {
474
            throw new FileException('Unable to create resource.');
475
        }
476
477
        return $checksum;
478
    }
479
480
    /**
481
     * Send DELETE request.
482
     *
483
     * @throws FileException
484
     *
485
     * @return void
486
     */
487
    public function delete()
488
    {
489
        try {
490
            $this->getClient()->delete($this->getUrl());
491
        } catch (ClientException $e) {
492
            $statusCode = $e->getResponse()->getStatusCode();
493
494
            if (HttpResponse::HTTP_NOT_FOUND === $statusCode || HttpResponse::HTTP_GONE === $statusCode) {
495
                throw new FileException('File not found.');
496
            }
497
        }
498
    }
499
500
    /**
501
     * Set as partial request.
502
     *
503
     * @param bool $state
504
     *
505
     * @return void
506
     */
507 3
    protected function partial(bool $state = true)
508
    {
509 3
        $this->partial = $state;
510
511 3
        if ( ! $this->partial) {
512 1
            return;
513
        }
514
515 2
        $key = $this->getKey();
516
517 2
        if (false !== strpos($key, self::PARTIAL_UPLOAD_NAME_SEPARATOR)) {
518 1
            list($key, /* $partialKey */) = explode(self::PARTIAL_UPLOAD_NAME_SEPARATOR, $key);
519
        }
520
521 2
        $this->key = $key . uniqid(self::PARTIAL_UPLOAD_NAME_SEPARATOR);
522 2
    }
523
524
    /**
525
     * Send HEAD request.
526
     *
527
     * @throws FileException
528
     *
529
     * @return int
530
     */
531 2
    protected function sendHeadRequest() : int
532
    {
533 2
        $response   = $this->getClient()->head($this->getUrl());
534 2
        $statusCode = $response->getStatusCode();
535
536 2
        if (HttpResponse::HTTP_OK !== $statusCode) {
537 1
            throw new FileException('File not found.');
538
        }
539
540 1
        return (int) current($response->getHeader('upload-offset'));
541
    }
542
543
    /**
544
     * Send PATCH request.
545
     *
546
     * @param int $bytes
547
     * @param int $offset
548
     *
549
     * @throws TusException
550
     * @throws FileException
551
     * @throws ConnectionException
552
     *
553
     * @return int
554
     */
555 7
    protected function sendPatchRequest(int $bytes, int $offset) : int
556
    {
557 7
        $data    = $this->getData($offset, $bytes);
558
        $headers = [
559 7
            'Content-Type' => self::HEADER_CONTENT_TYPE,
560 7
            'Content-Length' => strlen($data),
561 7
            'Upload-Checksum' => $this->getUploadChecksumHeader(),
562
        ];
563
564 7
        if ($this->isPartial()) {
565 1
            $headers += ['Upload-Concat' => self::UPLOAD_TYPE_PARTIAL];
566
        } else {
567 6
            $headers += ['Upload-Offset' => $offset];
568
        }
569
570
        try {
571 7
            $response = $this->getClient()->patch($this->getUrl(), [
572 7
                'body' => $data,
573 7
                'headers' => $headers,
574
            ]);
575
576 2
            return (int) current($response->getHeader('upload-offset'));
577 5
        } catch (ClientException $e) {
578 4
            throw $this->handleClientException($e);
579 1
        } catch (ConnectException $e) {
580 1
            throw new ConnectionException("Couldn't connect to server.");
581
        }
582
    }
583
584
    /**
585
     * Handle client exception during patch request.
586
     *
587
     * @param ClientException $e
588
     *
589
     * @return mixed
590
     */
591 4
    protected function handleClientException(ClientException $e)
592
    {
593 4
        $statusCode = $e->getResponse()->getStatusCode();
594
595 4
        if (HttpResponse::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE === $statusCode) {
596 1
            return new FileException('The uploaded file is corrupt.');
597
        }
598
599 3
        if (HttpResponse::HTTP_CONTINUE === $statusCode) {
600 1
            return new ConnectionException('Connection aborted by user.');
601
        }
602
603 2
        if (HttpResponse::HTTP_UNSUPPORTED_MEDIA_TYPE === $statusCode) {
604 1
            return new TusException('Unsupported media types.');
605
        }
606
607 1
        return new TusException($e->getResponse()->getBody(), $statusCode);
608
    }
609
610
    /**
611
     * Get X bytes of data from file.
612
     *
613
     * @param int $offset
614
     * @param int $bytes
615
     *
616
     * @return string
617
     */
618
    protected function getData(int $offset, int $bytes) : string
619
    {
620
        $file   = new File;
621
        $handle = $file->open($this->getFilePath(), $file::READ_BINARY);
622
623
        $file->seek($handle, $offset);
624
625
        $data = $file->read($handle, $bytes);
626
627
        $file->close($handle);
628
629
        return (string) $data;
630
    }
631
632
    /**
633
     * Get upload checksum header.
634
     *
635
     * @return string
636
     */
637
    protected function getUploadChecksumHeader() : string
638
    {
639
        return $this->getChecksumAlgorithm() . ' ' . base64_encode($this->getChecksum());
640
    }
641
}
642