Passed
Pull Request — master (#41)
by Iulian
02:09
created

Client::sendAuthorizedRequest()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 5
c 0
b 0
f 0
dl 0
loc 9
ccs 5
cts 5
cp 1
rs 10
cc 1
nc 1
nop 3
crap 1
1
<?php
2
3
namespace BackblazeB2;
4
5
use BackblazeB2\Exceptions\NotFoundException;
6
use BackblazeB2\Exceptions\ValidationException;
7
use BackblazeB2\Http\Client as HttpClient;
8
use Carbon\Carbon;
9
use GuzzleHttp\Exception\GuzzleException;
10
use Psr\Http\Message\ResponseInterface;
11
12
class Client
13
{
14
    private const B2_API_BASE_URL = 'https://api.backblazeb2.com';
15
    private const B2_API_V1 = '/b2api/v1';
16
    protected $accountId;
17
    protected $applicationKey;
18
    protected $authToken;
19
    protected $apiUrl;
20
    protected $downloadUrl;
21
    protected $client;
22
    protected $reAuthTime;
23
    protected $authTimeoutSeconds;
24
25
    /**
26
     * Accepts the account ID, application key and an optional array of options.
27
     *
28
     * @param $accountId
29
     * @param $applicationKey
30
     * @param array $options
31
     *
32
     * @throws \Exception
33
     */
34 28
    public function __construct($accountId, $applicationKey, array $options = [])
35
    {
36 28
        $this->accountId = $accountId;
37 28
        $this->applicationKey = $applicationKey;
38
39 28
        $this->authTimeoutSeconds = 12 * 60 * 60; // 12 hour default
40 28
        if (isset($options['auth_timeout_seconds'])) {
41 1
            $this->authTimeoutSeconds = $options['auth_timeout_seconds'];
42
        }
43
44
        // set reauthorize time to force an authentication to take place
45 28
        $this->reAuthTime = Carbon::now('UTC')->subSeconds($this->authTimeoutSeconds * 2);
46
47 28
        $this->client = new HttpClient(['exceptions' => false]);
48 28
        if (isset($options['client'])) {
49 28
            $this->client = $options['client'];
50
        }
51 28
    }
52
53
    /**
54
     * Create a bucket with the given name and type.
55
     *
56
     * @param array $options
57
     *
58
     * @return Bucket
59
     * @throws ValidationException
60
     */
61 5
    public function createBucket(array $options)
62
    {
63 5
        if (!in_array($options['BucketType'], [Bucket::TYPE_PUBLIC, Bucket::TYPE_PRIVATE])) {
64 1
            throw new ValidationException(
65 1
                sprintf('Bucket type must be %s or %s', Bucket::TYPE_PRIVATE, Bucket::TYPE_PUBLIC)
66
            );
67
        }
68
69 4
        $response = $this->sendAuthorizedRequest('POST', 'b2_create_bucket', [
70 4
            'accountId'  => $this->accountId,
71 4
            'bucketName' => $options['BucketName'],
72 4
            'bucketType' => $options['BucketType'],
73
        ]);
74
75 3
        return new Bucket($response['bucketId'], $response['bucketName'], $response['bucketType']);
76
    }
77
78
    /**
79
     * Updates the type attribute of a bucket by the given ID.
80
     *
81
     * @param array $options
82
     *
83
     * @return Bucket
84
     * @throws GuzzleException
85
     * @throws ValidationException
86
     */
87 2
    public function updateBucket(array $options)
88
    {
89 2
        if (!in_array($options['BucketType'], [Bucket::TYPE_PUBLIC, Bucket::TYPE_PRIVATE])) {
90
            throw new ValidationException(
91
                sprintf('Bucket type must be %s or %s', Bucket::TYPE_PRIVATE, Bucket::TYPE_PUBLIC)
92
            );
93
        }
94
95 2
        if (!isset($options['BucketId']) && isset($options['BucketName'])) {
96
            $options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
97
        }
98
99 2
        $response = $this->sendAuthorizedRequest('POST', 'b2_update_bucket', [
100 2
            'accountId'  => $this->accountId,
101 2
            'bucketId'   => $options['BucketId'],
102 2
            'bucketType' => $options['BucketType'],
103
        ]);
104
105 2
        return new Bucket($response['bucketId'], $response['bucketName'], $response['bucketType']);
106
    }
107
108
    /**
109
     * Returns a list of bucket objects representing the buckets on the account.
110
     *
111
     * @return array
112
     */
113 2
    public function listBuckets()
114
    {
115 2
        $buckets = [];
116
117 2
        $response = $this->sendAuthorizedRequest('POST', 'b2_list_buckets', [
118 2
            'accountId' => $this->accountId,
119
        ]);
120
121 2
        foreach ($response['buckets'] as $bucket) {
122 1
            $buckets[] = new Bucket($bucket['bucketId'], $bucket['bucketName'], $bucket['bucketType']);
123
        }
124
125 2
        return $buckets;
126
    }
127
128
    /**
129
     * Deletes the bucket identified by its ID.
130
     *
131
     * @param array $options
132
     *
133
     * @return bool
134
     * @throws GuzzleException
135
     */
136 3
    public function deleteBucket(array $options)
137
    {
138 3
        if (!isset($options['BucketId']) && isset($options['BucketName'])) {
139
            $options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
140
        }
141
142 3
        $this->sendAuthorizedRequest('POST', 'b2_delete_bucket', [
143 3
            'accountId' => $this->accountId,
144 3
            'bucketId'  => $options['BucketId'],
145
        ]);
146
147 1
        return true;
148
    }
149
150
    /**
151
     * Uploads a file to a bucket and returns a File object.
152
     *
153
     * @param array $options
154
     *
155
     * @return File
156
     * @throws GuzzleException
157
     */
158 3
    public function upload(array $options)
159
    {
160
        // Clean the path if it starts with /.
161 3
        if (substr($options['FileName'], 0, 1) === '/') {
162
            $options['FileName'] = ltrim($options['FileName'], '/');
163
        }
164
165 3
        if (!isset($options['BucketId']) && isset($options['BucketName'])) {
166
            $options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
167
        }
168
169
        // Retrieve the URL that we should be uploading to.
170
171 3
        $response = $this->sendAuthorizedRequest('POST', 'b2_get_upload_url', [
172 3
            'bucketId' => $options['BucketId'],
173
        ]);
174
175 3
        $uploadEndpoint = $response['uploadUrl'];
176 3
        $uploadAuthToken = $response['authorizationToken'];
177
178 3
        if (is_resource($options['Body'])) {
179
            // We need to calculate the file's hash incrementally from the stream.
180 1
            $context = hash_init('sha1');
181 1
            hash_update_stream($context, $options['Body']);
182 1
            $hash = hash_final($context);
183
184
            // Similarly, we have to use fstat to get the size of the stream.
185 1
            $size = fstat($options['Body'])['size'];
186
187
            // Rewind the stream before passing it to the HTTP client.
188 1
            rewind($options['Body']);
189
        } else {
190
            // We've been given a simple string body, it's super simple to calculate the hash and size.
191 2
            $hash = sha1($options['Body']);
192 2
            $size = strlen($options['Body']);
193
        }
194
195 3
        if (!isset($options['FileLastModified'])) {
196 2
            $options['FileLastModified'] = round(microtime(true) * 1000);
197
        }
198
199 3
        if (!isset($options['FileContentType'])) {
200 2
            $options['FileContentType'] = 'b2/x-auto';
201
        }
202
203 3
        $response = $this->client->request('POST', $uploadEndpoint, [
204
            'headers' => [
205 3
                'Authorization'                      => $uploadAuthToken,
206 3
                'Content-Type'                       => $options['FileContentType'],
207 3
                'Content-Length'                     => $size,
208 3
                'X-Bz-File-Name'                     => $options['FileName'],
209 3
                'X-Bz-Content-Sha1'                  => $hash,
210 3
                'X-Bz-Info-src_last_modified_millis' => $options['FileLastModified'],
211
            ],
212 3
            'body' => $options['Body'],
213
        ]);
214
215 3
        return new File(
216 3
            $response['fileId'],
217 3
            $response['fileName'],
218 3
            $response['contentSha1'],
219 3
            $response['contentLength'],
220 3
            $response['contentType'],
221 3
            $response['fileInfo']
222
        );
223
    }
224
225
    /**
226
     * Download a file from a B2 bucket.
227
     *
228
     * @param array $options
229
     *
230
     * @return bool|mixed|string
231
     * @throws GuzzleException
232
     */
233 6
    public function download(array $options)
234
    {
235 6
        $requestUrl = null;
236
        $requestOptions = [
237 6
            'headers' => [
238 6
                'Authorization' => $this->authToken,
239
            ],
240 6
            'sink' => isset($options['SaveAs']) ? $options['SaveAs'] : null,
241
        ];
242
243 6
        if (isset($options['FileId'])) {
244 3
            $requestOptions['query'] = ['fileId' => $options['FileId']];
245 3
            $requestUrl = $this->downloadUrl.'/b2api/v1/b2_download_file_by_id';
246
        } else {
247 3
            if (!isset($options['BucketName']) && isset($options['BucketId'])) {
248
                $options['BucketName'] = $this->getBucketNameFromId($options['BucketId']);
249
            }
250
251 3
            $requestUrl = sprintf('%s/file/%s/%s', $this->downloadUrl, $options['BucketName'], $options['FileName']);
252
        }
253
254 6
        $this->authorizeAccount();
255
256 6
        $response = $this->client->request('GET', $requestUrl, $requestOptions, false);
257
258 4
        return isset($options['SaveAs']) ? true : $response;
259
    }
260
261
    /**
262
     * Retrieve a collection of File objects representing the files stored inside a bucket.
263
     *
264
     * @param array $options
265
     *
266
     * @return array
267
     * @throws GuzzleException
268
     */
269 2
    public function listFiles(array $options)
270
    {
271
        // if FileName is set, we only attempt to retrieve information about that single file.
272 2
        $fileName = !empty($options['FileName']) ? $options['FileName'] : null;
273
274 2
        $nextFileName = null;
275 2
        $maxFileCount = 1000;
276
277 2
        $prefix = isset($options['Prefix']) ? $options['Prefix'] : '';
278 2
        $delimiter = isset($options['Delimiter']) ? $options['Delimiter'] : null;
279
280 2
        $files = [];
281
282 2
        if (!isset($options['BucketId']) && isset($options['BucketName'])) {
283
            $options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
284
        }
285
286 2
        if ($fileName) {
287
            $nextFileName = $fileName;
288
            $maxFileCount = 1;
289
        }
290
291 2
        $this->authorizeAccount();
292
293
        // B2 returns, at most, 1000 files per "page". Loop through the pages and compile an array of File objects.
294 2
        while (true) {
295 2
            $response = $this->sendAuthorizedRequest('POST', 'b2_list_file_names', [
296 2
                'bucketId'      => $options['BucketId'],
297 2
                'startFileName' => $nextFileName,
298 2
                'maxFileCount'  => $maxFileCount,
299 2
                'prefix'        => $prefix,
300 2
                'delimiter'     => $delimiter,
301
            ]);
302
303 2
            foreach ($response['files'] as $file) {
304
                // if we have a file name set, only retrieve information if the file name matches
305 1
                if (!$fileName || ($fileName === $file['fileName'])) {
306 1
                    $files[] = new File($file['fileId'], $file['fileName'], null, $file['size']);
307
                }
308
            }
309
310 2
            if ($fileName || $response['nextFileName'] === null) {
311
                // We've got all the files - break out of loop.
312 2
                break;
313
            }
314
315 1
            $nextFileName = $response['nextFileName'];
316
        }
317
318 2
        return $files;
319
    }
320
321
    /**
322
     * Test whether a file exists in B2 for the given bucket.
323
     *
324
     * @param array $options
325
     *
326
     * @throws \Exception
327
     * @throws GuzzleException
328
     *
329
     * @return bool
330
     */
331
    public function fileExists(array $options)
332
    {
333
        $files = $this->listFiles($options);
334
335
        return !empty($files);
336
    }
337
338
    /**
339
     * Returns a single File object representing a file stored on B2.
340
     *
341
     * @param array $options
342
     *
343
     * @return File
344
     * @throws GuzzleException
345
     * @throws NotFoundException If no file id was provided and BucketName + FileName does not resolve to a file, a NotFoundException is thrown.
346
     */
347 4
    public function getFile(array $options)
348
    {
349 4
        if (!isset($options['FileId']) && isset($options['BucketName']) && isset($options['FileName'])) {
350
            $options['FileId'] = $this->getFileIdFromBucketAndFileName($options['BucketName'], $options['FileName']);
351
352
            if (!$options['FileId']) {
353
                throw new NotFoundException();
354
            }
355
        }
356
357 4
        $response = $this->sendAuthorizedRequest('POST', 'b2_get_file_info', [
358 4
            'fileId' => $options['FileId'],
359
        ]);
360
361 3
        return new File(
362 3
            $response['fileId'],
363 3
            $response['fileName'],
364 3
            $response['contentSha1'],
365 3
            $response['contentLength'],
366 3
            $response['contentType'],
367 3
            $response['fileInfo'],
368 3
            $response['bucketId'],
369 3
            $response['action'],
370 3
            $response['uploadTimestamp']
371
        );
372
    }
373
374
    /**
375
     * Deletes the file identified by ID from Backblaze B2.
376
     *
377
     * @param array $options
378
     *
379
     * @return bool
380
     * @throws GuzzleException
381
     * @throws NotFoundException
382
     */
383 3
    public function deleteFile(array $options)
384
    {
385 3
        if (!isset($options['FileName'])) {
386 2
            $file = $this->getFile($options);
387
388 2
            $options['FileName'] = $file->getName();
389
        }
390
391 3
        if (!isset($options['FileId']) && isset($options['BucketName']) && isset($options['FileName'])) {
392
            $file = $this->getFile($options);
393
394
            $options['FileId'] = $file->getId();
395
        }
396
397 3
        $this->sendAuthorizedRequest('POST', 'b2_delete_file_version', [
398 3
            'fileName' => $options['FileName'],
399 3
            'fileId'   => $options['FileId'],
400
        ]);
401
402 2
        return true;
403
    }
404
405
    /**
406
     * Authorize the B2 account in order to get an auth token and API/download URLs.
407
     */
408 27
    protected function authorizeAccount()
409
    {
410 27
        if (Carbon::now('UTC')->timestamp < $this->reAuthTime->timestamp) {
411 4
            return;
412
        }
413
414 27
        $response = $this->client->request('GET', self::B2_API_BASE_URL . self::B2_API_V1 . '/b2_authorize_account', [
415 27
            'auth' => [$this->accountId, $this->applicationKey],
416
        ]);
417
418 27
        $this->authToken = $response['authorizationToken'];
419 27
        $this->apiUrl = $response['apiUrl'] . self::B2_API_V1 . '/';
420 27
        $this->downloadUrl = $response['downloadUrl'];
421 27
        $this->reAuthTime = Carbon::now('UTC');
422 27
        $this->reAuthTime->addSeconds($this->authTimeoutSeconds);
423 27
    }
424
425
    /**
426
     * Maps the provided bucket name to the appropriate bucket ID.
427
     *
428
     * @param $name
429
     *
430
     * @return mixed
431
     */
432
    protected function getBucketIdFromName($name)
433
    {
434
        $buckets = $this->listBuckets();
435
436
        foreach ($buckets as $bucket) {
437
            if ($bucket->getName() === $name) {
438
                return $bucket->getId();
439
            }
440
        }
441
    }
442
443
    /**
444
     * Maps the provided bucket ID to the appropriate bucket name.
445
     *
446
     * @param $id
447
     *
448
     * @return mixed
449
     */
450
    protected function getBucketNameFromId($id)
451
    {
452
        $buckets = $this->listBuckets();
453
454
        foreach ($buckets as $bucket) {
455
            if ($bucket->getId() === $id) {
456
                return $bucket->getName();
457
            }
458
        }
459
    }
460
461
    /**
462
     * @param $bucketName
463
     * @param $fileName
464
     *
465
     * @return mixed
466
     * @throws GuzzleException
467
     */
468
    protected function getFileIdFromBucketAndFileName($bucketName, $fileName)
469
    {
470
        $files = $this->listFiles([
471
            'BucketName' => $bucketName,
472
            'FileName'   => $fileName,
473
        ]);
474
475
        foreach ($files as $file) {
476
            if ($file->getName() === $fileName) {
477
                return $file->getId();
478
            }
479
        }
480
    }
481
482
    /**
483
     * Uploads a large file using b2 large file procedure.
484
     *
485
     * @param array $options
486
     *
487
     * @return File
488
     * @throws GuzzleException
489
     */
490
    public function uploadLargeFile(array $options)
491
    {
492
        if (substr($options['FileName'], 0, 1) === '/') {
493
            $options['FileName'] = ltrim($options['FileName'], '/');
494
        }
495
496
        //if last char of path is not a "/" then add a "/"
497
        if (substr($options['FilePath'], -1) != '/') {
498
            $options['FilePath'] = $options['FilePath'].'/';
499
        }
500
501
        if (!isset($options['BucketId']) && isset($options['BucketName'])) {
502
            $options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
503
        }
504
505
        if (!isset($options['FileContentType'])) {
506
            $options['FileContentType'] = 'b2/x-auto';
507
        }
508
509
        $this->authorizeAccount();
510
511
        // 1) b2_start_large_file, (returns fileId)
0 ignored issues
show
Unused Code Comprehensibility introduced by
37% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
512
        $start = $this->startLargeFile($options['FileName'], $options['FileContentType'], $options['BucketId']);
513
514
        // 2) b2_get_upload_part_url for each thread uploading (takes fileId)
515
        $url = $this->getUploadPartUrl($start['fileId']);
516
517
        // 3) b2_upload_part for each part of the file
518
        $parts = $this->uploadParts($options['FilePath'].$options['FileName'], $url['uploadUrl'], $url['authorizationToken']);
519
520
        $sha1s = [];
521
522
        foreach ($parts as $part) {
523
            $sha1s[] = $part['contentSha1'];
524
        }
525
526
        // 4) b2_finish_large_file.
527
        return $this->finishLargeFile($start['fileId'], $sha1s);
528
    }
529
530
    /**
531
     * Starts the large file upload process.
532
     *
533
     * @param $fileName
534
     * @param $contentType
535
     * @param $bucketId
536
     *
537
     * @return mixed
538
     */
539
    protected function startLargeFile($fileName, $contentType, $bucketId)
540
    {
541
        return $this->sendAuthorizedRequest('POST', 'b2_start_large_file', [
542
            'fileName'      => $fileName,
543
            'contentType'   => $contentType,
544
            'bucketId'      => $bucketId,
545
        ]);
546
    }
547
548
    /**
549
     * Gets the url for the next large file part upload.
550
     *
551
     * @param $fileId
552
     *
553
     * @return mixed|ResponseInterface|string
554
     * @throws GuzzleException
555
     */
556
    protected function getUploadPartUrl($fileId)
557
    {
558
        return $this->sendAuthorizedRequest('POST', 'b2_get_upload_part_url', [
559
            'fileId' => $fileId,
560
        ]);
561
    }
562
563
    /**
564
     * Uploads the file as "parts" of 100MB each.
565
     *
566
     * @param $filePath
567
     * @param $uploadUrl
568
     * @param $largeFileAuthToken
569
     *
570
     * @return array
571
     * @throws GuzzleException
572
     */
573
    protected function uploadParts($filePath, $uploadUrl, $largeFileAuthToken)
574
    {
575
        $return = [];
576
577
        $minimum_part_size = 100 * (1000 * 1000);
578
579
        $local_file_size = filesize($filePath);
580
        $total_bytes_sent = 0;
581
        $bytes_sent_for_part = $minimum_part_size;
582
        $sha1_of_parts = [];
583
        $part_no = 1;
584
        $file_handle = fopen($filePath, 'r');
585
586
        while ($total_bytes_sent < $local_file_size) {
587
588
            // Determine the number of bytes to send based on the minimum part size
589
            if (($local_file_size - $total_bytes_sent) < $minimum_part_size) {
590
                $bytes_sent_for_part = ($local_file_size - $total_bytes_sent);
591
            }
592
593
            // Get a sha1 of the part we are going to send
594
            fseek($file_handle, $total_bytes_sent);
0 ignored issues
show
Bug introduced by
It seems like $file_handle can also be of type false; however, parameter $handle of fseek() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

594
            fseek(/** @scrutinizer ignore-type */ $file_handle, $total_bytes_sent);
Loading history...
595
            $data_part = fread($file_handle, $bytes_sent_for_part);
0 ignored issues
show
Bug introduced by
It seems like $file_handle can also be of type false; however, parameter $handle of fread() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

595
            $data_part = fread(/** @scrutinizer ignore-type */ $file_handle, $bytes_sent_for_part);
Loading history...
596
            array_push($sha1_of_parts, sha1($data_part));
597
            fseek($file_handle, $total_bytes_sent);
598
599
            $response = $this->client->request('POST', $uploadUrl, [
600
                'headers' => [
601
                    'Authorization'                      => $largeFileAuthToken,
602
                    'Content-Length'                     => $bytes_sent_for_part,
603
                    'X-Bz-Part-Number'                   => $part_no,
604
                    'X-Bz-Content-Sha1'                  => $sha1_of_parts[$part_no - 1],
605
                ],
606
                'body' => $data_part,
607
            ]);
608
609
            $return[] = $response;
610
611
            // Prepare for the next iteration of the loop
612
            $part_no++;
613
            $total_bytes_sent = $bytes_sent_for_part + $total_bytes_sent;
614
        }
615
616
        fclose($file_handle);
0 ignored issues
show
Bug introduced by
It seems like $file_handle can also be of type false; however, parameter $handle of fclose() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

616
        fclose(/** @scrutinizer ignore-type */ $file_handle);
Loading history...
617
618
        return $return;
619
    }
620
621
    /**
622
     * Finishes the large file upload procedure.
623
     *
624
     * @param       $fileId
625
     * @param array $sha1s
626
     *
627
     * @return File
628
     */
629
    protected function finishLargeFile($fileId, array $sha1s)
630
    {
631
        $response = $this->sendAuthorizedRequest('POST', 'b2_finish_large_file', [
632
            'fileId'        => $fileId,
633
            'partSha1Array' => $sha1s,
634
        ]);
635
636
        return new File(
637
            $response['fileId'],
638
            $response['fileName'],
639
            $response['contentSha1'],
640
            $response['contentLength'],
641
            $response['contentType'],
642
            $response['fileInfo'],
643
            $response['bucketId'],
644
            $response['action'],
645
            $response['uploadTimestamp']
646
        );
647
    }
648
649
    /**
650
     * Sends a authorized request to b2 API
651
     *
652
     * @param string $method
653
     * @param string $route
654
     * @param array  $json
655
     *
656
     * @return mixed
657
     */
658 21
    protected function sendAuthorizedRequest($method, $route, $json = [])
659
    {
660 21
        $this->authorizeAccount();
661
662 21
        return $this->client->request($method, $this->apiUrl . $route, [
663
            'headers' => [
664 21
                'Authorization' => $this->authToken,
665
            ],
666 21
            'json' => $json,
667
        ]);
668
    }
669
}
670