blue-twilight/app/Services/BackblazeB2Service.php

400 lines
12 KiB
PHP

<?php
namespace App\Services;
use App\Exceptions\BackblazeRetryException;
use Illuminate\Support\Facades\Log;
class BackblazeB2Service
{
/**
* The individual URL for the account to use to access the API
* @var string
*/
private $accountApiUrl;
/**
* ID of the account in Backblaze B2.
* @var string
*/
private $accountID;
/**
* The base URL for public access to the account's files.
* @var string
*/
private $downloadUrl;
/**
* Authorisation header for authenticating to the API.
* @var string
*/
private $authHeader;
/**
* Authorisation token for accessing the API post-authentication.
* @var string
*/
private $authToken;
/**
* ID of the bucket.
* @var string
*/
private $bucketId;
/**
* Type of the bucket.
* @var integer
*/
private $bucketType;
/**
* Configuration related to the Backblaze B2 service.
* @var \Illuminate\Config\Repository|mixed
*/
private $config;
/**
* Current file upload token.
* @var string
*/
private $uploadAuthToken;
/**
* Current upload URL.
* @var string
*/
private $uploadUrl;
public function __construct()
{
$this->config = config('services.backblaze_b2');
}
public function authorizeAccount($force = false)
{
if (empty($this->authToken) || $force)
{
$result = $this->sendRequest($this->config['auth_url']);
if (!isset($result->authorizationToken))
{
throw new \Exception('Authorisation to Backblaze failed. Is the API key correct?');
}
$this->authToken = $result->authorizationToken;
$this->accountApiUrl = $result->apiUrl;
$this->accountID = $result->accountId;
$this->downloadUrl = $result->downloadUrl;
}
}
public function deleteFile($fileID, $fileName)
{
$this->sendRequest(
sprintf('%s/b2api/v2/b2_delete_file_version', $this->accountApiUrl),
'POST',
[
'fileId' => $fileID,
'fileName' => $fileName
]
);
}
public function downloadFile($fileID)
{
return $this->sendRequest(
sprintf('%s/b2api/v2/b2_download_file_by_id?fileId=%s', $this->accountApiUrl, urlencode($fileID)),
'GET',
null,
[
'http_headers' => [
sprintf('Authorization: %s', $this->authToken)
],
'response_body_is_json' => false
]
);
}
public function getBucketType()
{
return $this->bucketType;
}
public function getDownloadAuthToken()
{
$result = $this->sendRequest(
sprintf('%s/b2api/v2/b2_get_download_authorization', $this->accountApiUrl),
'POST',
[
'bucketId' => $this->bucketId,
'validDurationInSeconds' => intval($this->config['download_token_lifetime']),
'fileNamePrefix' => ''
]
);
return $result->authorizationToken;
}
public function getDownloadUrl()
{
return $this->downloadUrl;
}
public function setBucketName($bucketName)
{
$bucketDetails = $this->getBucketDetailsFromName($bucketName);
$this->bucketId = $bucketDetails->bucketId;
$this->bucketType = $bucketDetails->bucketType;
}
public function setCredentials($applicationKeyID, $applicationKey)
{
$this->authHeader = sprintf('%s:%s', $applicationKeyID, $applicationKey);
}
public function uploadFile($pathToFileToUpload, $pathToStorage)
{
// Get a URL to upload our file to
list($uploadUrl, $authorizationToken) = $this->getUploadUrl();
if (empty($uploadUrl) || empty($authorizationToken))
{
throw new \Exception('No upload URL/authorization token returned from Backblaze B2.');
}
$exponentialBackoff = 1;
$numberOfRetries = 5; // this effectively gives us 31 seconds of retries (1+2+4+8+16)
$numberOfTimesTried = 0;
while ($numberOfTimesTried < $numberOfRetries)
{
try
{
return $this->uploadFileReal($pathToFileToUpload, $pathToStorage, $uploadUrl, $authorizationToken);
}
catch (BackblazeRetryException $ex)
{
sleep($exponentialBackoff);
// Get a new upload token
$this->uploadAuthToken = null;
$this->uploadUrl = null;
list($uploadUrl, $authorizationToken) = $this->getUploadUrl();
// Keep backing off
$exponentialBackoff *= $exponentialBackoff;
$numberOfTimesTried++;
}
}
}
private function uploadFileReal($pathToFileToUpload, $pathToStorage, $uploadUrl, $authorizationToken)
{
$fileSize = filesize($pathToFileToUpload);
$handle = fopen($pathToFileToUpload, 'r');
$fileContents = fread($handle, $fileSize);
fclose($handle);
$fileContentsSha1 = sha1_file($pathToFileToUpload);
$httpHeaders = [
sprintf('Authorization: %s', $authorizationToken),
'Content-Type: b2/x-auto',
sprintf('X-Bz-Content-Sha1: %s', $fileContentsSha1),
sprintf('X-Bz-File-Name: %s', urlencode($pathToStorage))
];
$result = $this->sendRequestReal(
$uploadUrl,
'POST',
$fileContents,
[
'http_headers' => $httpHeaders,
'post_body_is_json' => false
]
);
return $result->fileId;
}
private function getBucketDetailsFromName($bucketName)
{
$result = $this->sendRequest(
sprintf('%s/b2api/v2/b2_list_buckets', $this->accountApiUrl),
'POST',
[
'accountId' => $this->accountID,
'bucketName' => $bucketName
]
);
if (isset($result->buckets) && is_array($result->buckets) && count($result->buckets) >= 1)
{
return $result->buckets[0];
}
throw new \Exception(sprintf('The bucket \'%s\' was not found or your API key does not have access.', $bucketName));
}
private function getUploadUrl($alwaysGetNewToken = false)
{
if (is_null($this->uploadAuthToken) || $alwaysGetNewToken)
{
$result = $this->sendRequest(
sprintf('%s/b2api/v2/b2_get_upload_url', $this->accountApiUrl),
'POST',
['bucketId' => $this->bucketId]
);
$this->uploadAuthToken = $result->authorizationToken;
$this->uploadUrl = $result->uploadUrl;
}
return [$this->uploadUrl, $this->uploadAuthToken];
}
private function getBasicHttpClient($url, $method = 'GET', array $httpHeaders = [])
{
$httpHeaders = array_merge(
[
'Accept: application/json'
],
$httpHeaders
);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, $httpHeaders);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
switch (strtoupper($method))
{
case 'GET':
curl_setopt($ch, CURLOPT_HTTPGET, true);
break;
case 'POST':
curl_setopt($ch, CURLOPT_POST, true);
break;
}
return $ch;
}
private function sendRequest($url, $method = 'GET', $postData = null, array $postOptions = [])
{
$exponentialBackoff = 1;
$numberOfRetries = 5; // this effectively gives us 31 seconds of retries (1+2+4+8+16)
$numberOfTimesTried = 0;
while ($numberOfTimesTried < $numberOfRetries)
{
try
{
return $this->sendRequestReal($url, $method, $postData, $postOptions);
}
catch (BackblazeRetryException $ex)
{
// Clear the upload token if requested
if (isset($postOptions['clear_upload_token_on_retry']) && $postOptions['clear_upload_token_on_retry'])
{
$this->uploadAuthToken = null;
$this->uploadUrl = null;
}
// Keep backing off
sleep($exponentialBackoff);
$exponentialBackoff *= $exponentialBackoff;
$numberOfTimesTried++;
}
}
}
private function sendRequestReal($url, $method = 'GET', $postData = null, array $postOptions = [])
{
$postOptions = array_merge(
[
'authorization_token' => null,
'http_headers' => [],
'post_body_is_json' => true,
'response_body_is_json' => true
],
$postOptions
);
$httpHeaders = $postOptions['http_headers'];
// Some methods may need to override the authorization token used
if (empty($postOptions['authorization_token']))
{
// No override - work out which auth token to use
if (is_null($this->authToken))
{
// No auth token yet, use username/password
$httpHeaders[] = sprintf('Authorization: Basic %s', base64_encode($this->authHeader));
}
else
{
// Use the auth token we have
$httpHeaders[] = sprintf('Authorization: %s', $this->authToken);
}
}
else
{
// Override - use the auth token specified
$httpHeaders[] = sprintf('Authorization: %s', $postOptions['authorization_token']);
}
$ch = $this->getBasicHttpClient($url, $method, $httpHeaders);
if (!is_null($postData))
{
if ($postOptions['post_body_is_json'])
{
$postData = json_encode($postData);
}
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
}
Log::info(sprintf('%s: %s', strtoupper($method), $url));
Log::debug('HTTP headers:', $httpHeaders);
// Only log a post body if we have one and it's in JSON format (i.e. not a file upload)
if (!is_null($postData) && $postOptions['post_body_is_json'])
{
Log::debug($postData);
}
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
Log::info(sprintf('Received HTTP code %d', $httpCode));
// Only log a result if we have one and it's in JSON format (i.e. not a file download)
if (!is_null($result) && $result !== false && $postOptions['response_body_is_json'])
{
Log::debug($result);
}
// According to the Backblaze B2 Protocol, if we get a 500/503, we should retry the request
if ($httpCode == 500 || $httpCode == 503)
{
throw new BackblazeRetryException(
$httpCode,
new \Exception(sprintf('Exception from Backblaze B2: %s', $result))
);
}
else if ($httpCode != 200 && $httpCode != 304)
{
throw new \Exception(sprintf('Exception from Backblaze B2: %s', $result));
}
curl_close($ch);
return $postOptions['response_body_is_json']
? json_decode($result)
: $result;
}
}