Completed
Pull Request — master (#49)
by Liuta
13:35
created

Client::fileExists()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 2
c 1
b 0
f 0
dl 0
loc 5
ccs 0
cts 3
cp 0
rs 10
cc 1
nc 1
nop 1
crap 2
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->accountId = $response['accountId'];
414 27
        $this->authToken = $response['authorizationToken'];
415 27
        $this->apiUrl = $response['apiUrl'].self::B2_API_V1;
416 27
        $this->downloadUrl = $response['downloadUrl'];
417 27
        $this->reAuthTime = Carbon::now('UTC');
418 27
        $this->reAuthTime->addSeconds($this->authTimeoutSeconds);
419 27
    }
420
421
    /**
422
     * Maps the provided bucket name to the appropriate bucket ID.
423
     *
424
     * @param $name
425
     *
426
     * @return mixed
427
     */
428
    protected function getBucketIdFromName($name)
429
    {
430
        $buckets = $this->listBuckets();
431
432
        foreach ($buckets as $bucket) {
433
            if ($bucket->getName() === $name) {
434
                return $bucket->getId();
435
            }
436
        }
437
    }
438
439
    /**
440
     * Maps the provided bucket ID to the appropriate bucket name.
441
     *
442
     * @param $id
443
     *
444
     * @return mixed
445
     */
446
    protected function getBucketNameFromId($id)
447
    {
448
        $buckets = $this->listBuckets();
449
450
        foreach ($buckets as $bucket) {
451
            if ($bucket->getId() === $id) {
452
                return $bucket->getName();
453
            }
454
        }
455
    }
456
457
    /**
458
     * @param $bucketName
459
     * @param $fileName
460
     *
461
     * @return mixed
462
     */
463
    protected function getFileIdFromBucketAndFileName($bucketName, $fileName)
464
    {
465
        $files = $this->listFiles([
466
            'BucketName' => $bucketName,
467
            'FileName'   => $fileName,
468
        ]);
469
470
        foreach ($files as $file) {
471
            if ($file->getName() === $fileName) {
472
                return $file->getId();
473
            }
474
        }
475
    }
476
477
    /**
478
     * Uploads a large file using b2 large file procedure.
479
     *
480
     * @param array $options
481
     *
482
     * @return File
483
     */
484
    public function uploadLargeFile(array $options)
485
    {
486
        if (substr($options['FileName'], 0, 1) === '/') {
487
            $options['FileName'] = ltrim($options['FileName'], '/');
488
        }
489
490
        //if last char of path is not a "/" then add a "/"
491
        if (substr($options['FilePath'], -1) != '/') {
492
            $options['FilePath'] = $options['FilePath'].'/';
493
        }
494
495
        if (!isset($options['BucketId']) && isset($options['BucketName'])) {
496
            $options['BucketId'] = $this->getBucketIdFromName($options['BucketName']);
497
        }
498
499
        if (!isset($options['FileContentType'])) {
500
            $options['FileContentType'] = 'b2/x-auto';
501
        }
502
503
        $this->authorizeAccount();
504
505
        // 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...
506
        $start = $this->startLargeFile($options['FileName'], $options['FileContentType'], $options['BucketId']);
507
508
        // 2) b2_get_upload_part_url for each thread uploading (takes fileId)
509
        $url = $this->getUploadPartUrl($start['fileId']);
510
511
        // 3) b2_upload_part for each part of the file
512
        $parts = $this->uploadParts($options['FilePath'].$options['FileName'], $url['uploadUrl'], $url['authorizationToken']);
513
514
        $sha1s = [];
515
516
        foreach ($parts as $part) {
517
            $sha1s[] = $part['contentSha1'];
518
        }
519
520
        // 4) b2_finish_large_file.
521
        return $this->finishLargeFile($start['fileId'], $sha1s);
522
    }
523
524
    /**
525
     * Starts the large file upload process.
526
     *
527
     * @param $fileName
528
     * @param $contentType
529
     * @param $bucketId
530
     *
531
     * @return mixed
532
     */
533
    protected function startLargeFile($fileName, $contentType, $bucketId)
534
    {
535
        return $this->sendAuthorizedRequest('POST', 'b2_start_large_file', [
536
            'fileName'      => $fileName,
537
            'contentType'   => $contentType,
538
            'bucketId'      => $bucketId,
539
        ]);
540
    }
541
542
    /**
543
     * Gets the url for the next large file part upload.
544
     *
545
     * @param $fileId
546
     *
547
     * @return mixed
548
     */
549
    protected function getUploadPartUrl($fileId)
550
    {
551
        return $this->sendAuthorizedRequest('POST', 'b2_get_upload_part_url', [
552
            'fileId' => $fileId,
553
        ]);
554
    }
555
556
    /**
557
     * Uploads the file as "parts" of 100MB each.
558
     *
559
     * @param $filePath
560
     * @param $uploadUrl
561
     * @param $largeFileAuthToken
562
     *
563
     * @return array
564
     */
565
    protected function uploadParts($filePath, $uploadUrl, $largeFileAuthToken)
566
    {
567
        $return = [];
568
569
        $minimum_part_size = 100 * (1000 * 1000);
570
571
        $local_file_size = filesize($filePath);
572
        $total_bytes_sent = 0;
573
        $bytes_sent_for_part = $minimum_part_size;
574
        $sha1_of_parts = [];
575
        $part_no = 1;
576
        $file_handle = fopen($filePath, 'r');
577
578
        while ($total_bytes_sent < $local_file_size) {
579
580
            // Determine the number of bytes to send based on the minimum part size
581
            if (($local_file_size - $total_bytes_sent) < $minimum_part_size) {
582
                $bytes_sent_for_part = ($local_file_size - $total_bytes_sent);
583
            }
584
585
            // Get a sha1 of the part we are going to send
586
            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

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

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

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