Passed
Pull Request — master (#41)
by Iulian
02:17
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
11
class Client
12
{
13
    private const B2_API_BASE_URL = 'https://api.backblazeb2.com';
14
    private const B2_API_V1 = '/b2api/v1/';
15
    protected $accountId;
16
    protected $applicationKey;
17
    protected $authToken;
18
    protected $apiUrl;
19
    protected $downloadUrl;
20
    protected $client;
21
    protected $reAuthTime;
22
    protected $authTimeoutSeconds;
23
24
    /**
25
     * Accepts the account ID, application key and an optional array of options.
26
     *
27
     * @param $accountId
28
     * @param $applicationKey
29
     * @param array $options
30
     *
31
     * @throws \Exception
32
     */
33 28
    public function __construct($accountId, $applicationKey, array $options = [])
34
    {
35 28
        $this->accountId = $accountId;
36 28
        $this->applicationKey = $applicationKey;
37
38 28
        $this->authTimeoutSeconds = 12 * 60 * 60; // 12 hour default
39 28
        if (isset($options['auth_timeout_seconds'])) {
40 1
            $this->authTimeoutSeconds = $options['auth_timeout_seconds'];
41
        }
42
43
        // set reauthorize time to force an authentication to take place
44 28
        $this->reAuthTime = Carbon::now('UTC')->subSeconds($this->authTimeoutSeconds * 2);
45
46 28
        $this->client = new HttpClient(['exceptions' => false]);
47 28
        if (isset($options['client'])) {
48 28
            $this->client = $options['client'];
49
        }
50 28
    }
51
52
    /**
53
     * Create a bucket with the given name and type.
54
     *
55
     * @param array $options
56
     *
57
     * @throws ValidationException
58
     *
59
     * @return Bucket
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
     * @throws ValidationException
84
     *
85
     * @return Bucket
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
     */
135 3
    public function deleteBucket(array $options)
136
    {
137 3
        if (!isset($options['BucketId']) && isset($options['BucketName'])) {
138
            $options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
139
        }
140
141 3
        $this->sendAuthorizedRequest('POST', 'b2_delete_bucket', [
142 3
            'accountId' => $this->accountId,
143 3
            'bucketId'  => $options['BucketId'],
144
        ]);
145
146 1
        return true;
147
    }
148
149
    /**
150
     * Uploads a file to a bucket and returns a File object.
151
     *
152
     * @param array $options
153
     *
154
     * @return File
155
     */
156 3
    public function upload(array $options)
157
    {
158
        // Clean the path if it starts with /.
159 3
        if (substr($options['FileName'], 0, 1) === '/') {
160
            $options['FileName'] = ltrim($options['FileName'], '/');
161
        }
162
163 3
        if (!isset($options['BucketId']) && isset($options['BucketName'])) {
164
            $options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
165
        }
166
167
        // Retrieve the URL that we should be uploading to.
168
169 3
        $response = $this->sendAuthorizedRequest('POST', 'b2_get_upload_url', [
170 3
            'bucketId' => $options['BucketId'],
171
        ]);
172
173 3
        $uploadEndpoint = $response['uploadUrl'];
174 3
        $uploadAuthToken = $response['authorizationToken'];
175
176 3
        if (is_resource($options['Body'])) {
177
            // We need to calculate the file's hash incrementally from the stream.
178 1
            $context = hash_init('sha1');
179 1
            hash_update_stream($context, $options['Body']);
180 1
            $hash = hash_final($context);
181
182
            // Similarly, we have to use fstat to get the size of the stream.
183 1
            $size = fstat($options['Body'])['size'];
184
185
            // Rewind the stream before passing it to the HTTP client.
186 1
            rewind($options['Body']);
187
        } else {
188
            // We've been given a simple string body, it's super simple to calculate the hash and size.
189 2
            $hash = sha1($options['Body']);
190 2
            $size = strlen($options['Body']);
191
        }
192
193 3
        if (!isset($options['FileLastModified'])) {
194 2
            $options['FileLastModified'] = round(microtime(true) * 1000);
195
        }
196
197 3
        if (!isset($options['FileContentType'])) {
198 2
            $options['FileContentType'] = 'b2/x-auto';
199
        }
200
201 3
        $response = $this->client->request('POST', $uploadEndpoint, [
202
            'headers' => [
203 3
                'Authorization'                      => $uploadAuthToken,
204 3
                'Content-Type'                       => $options['FileContentType'],
205 3
                'Content-Length'                     => $size,
206 3
                'X-Bz-File-Name'                     => $options['FileName'],
207 3
                'X-Bz-Content-Sha1'                  => $hash,
208 3
                'X-Bz-Info-src_last_modified_millis' => $options['FileLastModified'],
209
            ],
210 3
            'body' => $options['Body'],
211
        ]);
212
213 3
        return new File(
214 3
            $response['fileId'],
215 3
            $response['fileName'],
216 3
            $response['contentSha1'],
217 3
            $response['contentLength'],
218 3
            $response['contentType'],
219 3
            $response['fileInfo']
220
        );
221
    }
222
223
    /**
224
     * Download a file from a B2 bucket.
225
     *
226
     * @param array $options
227
     *
228
     * @return bool
229
     */
230 6
    public function download(array $options)
231
    {
232 6
        $requestUrl = null;
233
        $requestOptions = [
234 6
            'headers' => [
235 6
                'Authorization' => $this->authToken,
236
            ],
237 6
            'sink' => isset($options['SaveAs']) ? $options['SaveAs'] : null,
238
        ];
239
240 6
        if (isset($options['FileId'])) {
241 3
            $requestOptions['query'] = ['fileId' => $options['FileId']];
242 3
            $requestUrl = $this->downloadUrl.'/b2api/v1/b2_download_file_by_id';
243
        } else {
244 3
            if (!isset($options['BucketName']) && isset($options['BucketId'])) {
245
                $options['BucketName'] = $this->getBucketNameFromId($options['BucketId']);
246
            }
247
248 3
            $requestUrl = sprintf('%s/file/%s/%s', $this->downloadUrl, $options['BucketName'], $options['FileName']);
249
        }
250
251 6
        $this->authorizeAccount();
252
253 6
        $response = $this->client->request('GET', $requestUrl, $requestOptions, false);
254
255 4
        return isset($options['SaveAs']) ? true : $response;
256
    }
257
258
    /**
259
     * Retrieve a collection of File objects representing the files stored inside a bucket.
260
     *
261
     * @param array $options
262
     *
263
     * @return array
264
     */
265 2
    public function listFiles(array $options)
266
    {
267
        // if FileName is set, we only attempt to retrieve information about that single file.
268 2
        $fileName = !empty($options['FileName']) ? $options['FileName'] : null;
269
270 2
        $nextFileName = null;
271 2
        $maxFileCount = 1000;
272
273 2
        $prefix = isset($options['Prefix']) ? $options['Prefix'] : '';
274 2
        $delimiter = isset($options['Delimiter']) ? $options['Delimiter'] : null;
275
276 2
        $files = [];
277
278 2
        if (!isset($options['BucketId']) && isset($options['BucketName'])) {
279
            $options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
280
        }
281
282 2
        if ($fileName) {
283
            $nextFileName = $fileName;
284
            $maxFileCount = 1;
285
        }
286
287 2
        $this->authorizeAccount();
288
289
        // B2 returns, at most, 1000 files per "page". Loop through the pages and compile an array of File objects.
290 2
        while (true) {
291 2
            $response = $this->sendAuthorizedRequest('POST', 'b2_list_file_names', [
292 2
                'bucketId'      => $options['BucketId'],
293 2
                'startFileName' => $nextFileName,
294 2
                'maxFileCount'  => $maxFileCount,
295 2
                'prefix'        => $prefix,
296 2
                'delimiter'     => $delimiter,
297
            ]);
298
299 2
            foreach ($response['files'] as $file) {
300
                // if we have a file name set, only retrieve information if the file name matches
301 1
                if (!$fileName || ($fileName === $file['fileName'])) {
302 1
                    $files[] = new File($file['fileId'], $file['fileName'], null, $file['size']);
303
                }
304
            }
305
306 2
            if ($fileName || $response['nextFileName'] === null) {
307
                // We've got all the files - break out of loop.
308 2
                break;
309
            }
310
311 1
            $nextFileName = $response['nextFileName'];
312
        }
313
314 2
        return $files;
315
    }
316
317
    /**
318
     * Test whether a file exists in B2 for the given bucket.
319
     *
320
     * @param array $options
321
     *
322
     * @return bool
323
     */
324
    public function fileExists(array $options)
325
    {
326
        $files = $this->listFiles($options);
327
328
        return !empty($files);
329
    }
330
331
    /**
332
     * Returns a single File object representing a file stored on B2.
333
     *
334
     * @param array $options
335
     *
336
     * @throws GuzzleException
337
     * @throws NotFoundException If no file id was provided and BucketName + FileName does not resolve to a file, a NotFoundException is thrown.
338
     *
339
     * @return File
340
     */
341 4
    public function getFile(array $options)
342
    {
343 4
        if (!isset($options['FileId']) && isset($options['BucketName']) && isset($options['FileName'])) {
344
            $options['FileId'] = $this->getFileIdFromBucketAndFileName($options['BucketName'], $options['FileName']);
345
346
            if (!$options['FileId']) {
347
                throw new NotFoundException();
348
            }
349
        }
350
351 4
        $response = $this->sendAuthorizedRequest('POST', 'b2_get_file_info', [
352 4
            'fileId' => $options['FileId'],
353
        ]);
354
355 3
        return new File(
356 3
            $response['fileId'],
357 3
            $response['fileName'],
358 3
            $response['contentSha1'],
359 3
            $response['contentLength'],
360 3
            $response['contentType'],
361 3
            $response['fileInfo'],
362 3
            $response['bucketId'],
363 3
            $response['action'],
364 3
            $response['uploadTimestamp']
365
        );
366
    }
367
368
    /**
369
     * Deletes the file identified by ID from Backblaze B2.
370
     *
371
     * @param array $options
372
     *
373
     * @throws GuzzleException
374
     * @throws NotFoundException
375
     *
376
     * @return bool
377
     */
378 3
    public function deleteFile(array $options)
379
    {
380 3
        if (!isset($options['FileName'])) {
381 2
            $file = $this->getFile($options);
382
383 2
            $options['FileName'] = $file->getName();
384
        }
385
386 3
        if (!isset($options['FileId']) && isset($options['BucketName']) && isset($options['FileName'])) {
387
            $file = $this->getFile($options);
388
389
            $options['FileId'] = $file->getId();
390
        }
391
392 3
        $this->sendAuthorizedRequest('POST', 'b2_delete_file_version', [
393 3
            'fileName' => $options['FileName'],
394 3
            'fileId'   => $options['FileId'],
395
        ]);
396
397 2
        return true;
398
    }
399
400
    /**
401
     * Authorize the B2 account in order to get an auth token and API/download URLs.
402
     */
403 27
    protected function authorizeAccount()
404
    {
405 27
        if (Carbon::now('UTC')->timestamp < $this->reAuthTime->timestamp) {
406 4
            return;
407
        }
408
409 27
        $response = $this->client->request('GET', self::B2_API_BASE_URL.self::B2_API_V1.'/b2_authorize_account', [
410 27
            'auth' => [$this->accountId, $this->applicationKey],
411
        ]);
412
413 27
        $this->authToken = $response['authorizationToken'];
414 27
        $this->apiUrl = $response['apiUrl'] . self::B2_API_V1;
415 27
        $this->downloadUrl = $response['downloadUrl'];
416 27
        $this->reAuthTime = Carbon::now('UTC');
417 27
        $this->reAuthTime->addSeconds($this->authTimeoutSeconds);
418 27
    }
419
420
    /**
421
     * Maps the provided bucket name to the appropriate bucket ID.
422
     *
423
     * @param $name
424
     *
425
     * @return mixed
426
     */
427
    protected function getBucketIdFromName($name)
428
    {
429
        $buckets = $this->listBuckets();
430
431
        foreach ($buckets as $bucket) {
432
            if ($bucket->getName() === $name) {
433
                return $bucket->getId();
434
            }
435
        }
436
    }
437
438
    /**
439
     * Maps the provided bucket ID to the appropriate bucket name.
440
     *
441
     * @param $id
442
     *
443
     * @return mixed
444
     */
445
    protected function getBucketNameFromId($id)
446
    {
447
        $buckets = $this->listBuckets();
448
449
        foreach ($buckets as $bucket) {
450
            if ($bucket->getId() === $id) {
451
                return $bucket->getName();
452
            }
453
        }
454
    }
455
456
    /**
457
     * @param $bucketName
458
     * @param $fileName
459
     *
460
     * @return mixed
461
     */
462
    protected function getFileIdFromBucketAndFileName($bucketName, $fileName)
463
    {
464
        $files = $this->listFiles([
465
            'BucketName' => $bucketName,
466
            'FileName'   => $fileName,
467
        ]);
468
469
        foreach ($files as $file) {
470
            if ($file->getName() === $fileName) {
471
                return $file->getId();
472
            }
473
        }
474
    }
475
476
    /**
477
     * Uploads a large file using b2 large file procedure.
478
     *
479
     * @param array $options
480
     *
481
     * @return File
482
     */
483
    public function uploadLargeFile(array $options)
484
    {
485
        if (substr($options['FileName'], 0, 1) === '/') {
486
            $options['FileName'] = ltrim($options['FileName'], '/');
487
        }
488
489
        //if last char of path is not a "/" then add a "/"
490
        if (substr($options['FilePath'], -1) != '/') {
491
            $options['FilePath'] = $options['FilePath'].'/';
492
        }
493
494
        if (!isset($options['BucketId']) && isset($options['BucketName'])) {
495
            $options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
496
        }
497
498
        if (!isset($options['FileContentType'])) {
499
            $options['FileContentType'] = 'b2/x-auto';
500
        }
501
502
        $this->authorizeAccount();
503
504
        // 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...
505
        $start = $this->startLargeFile($options['FileName'], $options['FileContentType'], $options['BucketId']);
506
507
        // 2) b2_get_upload_part_url for each thread uploading (takes fileId)
508
        $url = $this->getUploadPartUrl($start['fileId']);
509
510
        // 3) b2_upload_part for each part of the file
511
        $parts = $this->uploadParts($options['FilePath'].$options['FileName'], $url['uploadUrl'], $url['authorizationToken']);
512
513
        $sha1s = [];
514
515
        foreach ($parts as $part) {
516
            $sha1s[] = $part['contentSha1'];
517
        }
518
519
        // 4) b2_finish_large_file.
520
        return $this->finishLargeFile($start['fileId'], $sha1s);
521
    }
522
523
    /**
524
     * Starts the large file upload process.
525
     *
526
     * @param $fileName
527
     * @param $contentType
528
     * @param $bucketId
529
     *
530
     * @return mixed
531
     */
532
    protected function startLargeFile($fileName, $contentType, $bucketId)
533
    {
534
        return $this->sendAuthorizedRequest('POST', 'b2_start_large_file', [
535
            'fileName'      => $fileName,
536
            'contentType'   => $contentType,
537
            'bucketId'      => $bucketId,
538
        ]);
539
    }
540
541
    /**
542
     * Gets the url for the next large file part upload.
543
     *
544
     * @param $fileId
545
     *
546
     * @return mixed
547
     */
548
    protected function getUploadPartUrl($fileId)
549
    {
550
        return $this->sendAuthorizedRequest('POST', 'b2_get_upload_part_url', [
551
            'fileId' => $fileId,
552
        ]);
553
    }
554
555
    /**
556
     * Uploads the file as "parts" of 100MB each.
557
     *
558
     * @param $filePath
559
     * @param $uploadUrl
560
     * @param $largeFileAuthToken
561
     *
562
     * @return array
563
     */
564
    protected function uploadParts($filePath, $uploadUrl, $largeFileAuthToken)
565
    {
566
        $return = [];
567
568
        $minimum_part_size = 100 * (1000 * 1000);
569
570
        $local_file_size = filesize($filePath);
571
        $total_bytes_sent = 0;
572
        $bytes_sent_for_part = $minimum_part_size;
573
        $sha1_of_parts = [];
574
        $part_no = 1;
575
        $file_handle = fopen($filePath, 'r');
576
577
        while ($total_bytes_sent < $local_file_size) {
578
579
            // Determine the number of bytes to send based on the minimum part size
580
            if (($local_file_size - $total_bytes_sent) < $minimum_part_size) {
581
                $bytes_sent_for_part = ($local_file_size - $total_bytes_sent);
582
            }
583
584
            // Get a sha1 of the part we are going to send
585
            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

585
            fseek(/** @scrutinizer ignore-type */ $file_handle, $total_bytes_sent);
Loading history...
586
            $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

586
            $data_part = fread(/** @scrutinizer ignore-type */ $file_handle, $bytes_sent_for_part);
Loading history...
587
            array_push($sha1_of_parts, sha1($data_part));
588
            fseek($file_handle, $total_bytes_sent);
589
590
            $response = $this->client->request('POST', $uploadUrl, [
591
                'headers' => [
592
                    'Authorization'                      => $largeFileAuthToken,
593
                    'Content-Length'                     => $bytes_sent_for_part,
594
                    'X-Bz-Part-Number'                   => $part_no,
595
                    'X-Bz-Content-Sha1'                  => $sha1_of_parts[$part_no - 1],
596
                ],
597
                'body' => $data_part,
598
            ]);
599
600
            $return[] = $response;
601
602
            // Prepare for the next iteration of the loop
603
            $part_no++;
604
            $total_bytes_sent = $bytes_sent_for_part + $total_bytes_sent;
605
        }
606
607
        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

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