From 4b6bdeba15e7761fc043f19bc2dda0deabca9750 Mon Sep 17 00:00:00 2001
From: Andy Heathershaw
Date: Mon, 9 Sep 2019 20:35:32 +0100
Subject: [PATCH 1/8] Backblaze #135 - added the storage UI and initial
connectivity to B2
---
app/AlbumSources/BackblazeB2Source.php | 117 ++++++++++++
app/Helpers/ConfigHelper.php | 2 +
app/Services/BackblazeB2Service.php | 177 ++++++++++++++++++
composer.json | 1 +
config/services.php | 4 +
resources/lang/en/admin.php | 1 +
resources/lang/en/forms.php | 2 +
resources/lang/en/global.php | 1 +
.../base/admin/create_storage.blade.php | 4 +
.../themes/base/admin/edit_storage.blade.php | 5 +
...in_storages_backblaze_b2_options.blade.php | 42 +++++
11 files changed, 356 insertions(+)
create mode 100644 app/AlbumSources/BackblazeB2Source.php
create mode 100644 app/Services/BackblazeB2Service.php
create mode 100644 resources/views/themes/base/partials/admin_storages_backblaze_b2_options.blade.php
diff --git a/app/AlbumSources/BackblazeB2Source.php b/app/AlbumSources/BackblazeB2Source.php
new file mode 100644
index 0000000..acde3e4
--- /dev/null
+++ b/app/AlbumSources/BackblazeB2Source.php
@@ -0,0 +1,117 @@
+backblaze = new BackblazeB2Service();
+ }
+
+ /**
+ * Deletes an entire album's media contents.
+ * @return void
+ */
+ public function deleteAlbumContents()
+ {
+ // TODO: Implement deleteAlbumContents() method.
+ }
+
+ /**
+ * Deletes a thumbnail file for a photo.
+ * @param Photo $photo Photo to delete the thumbnail from.
+ * @param string $thumbnail Thumbnail to delete (or null to delete the original.)
+ * @return void
+ */
+ public function deleteThumbnail(Photo $photo, $thumbnail = null)
+ {
+ // TODO: Implement deleteThumbnail() method.
+ }
+
+ /**
+ * Fetches the contents of a thumbnail for a photo.
+ * @param Photo $photo Photo to fetch the thumbnail for.
+ * @param string $thumbnail Thumbnail to fetch (or null to fetch the original.)
+ * @return EntityBody
+ */
+ public function fetchPhotoContent(Photo $photo, $thumbnail = null)
+ {
+ // TODO: Implement fetchPhotoContent() method.
+ }
+
+ /**
+ * Gets the name of this album source.
+ * @return string
+ */
+ public function getName()
+ {
+ return 'global.album_sources.backblaze_b2';
+ }
+
+ /**
+ * Gets the absolute URL to the given photo file.
+ * @param Photo $photo Photo to get the URL to.
+ * @param string $thumbnail Thumbnail to get the image to.
+ * @return string
+ */
+ public function getUrlToPhoto(Photo $photo, $thumbnail = null)
+ {
+ // TODO: Implement getUrlToPhoto() method.
+ }
+
+ /**
+ * Saves a generated thumbnail to its permanent location.
+ * @param Photo $photo Photo the image relates to.
+ * @param string $tempFilename Filename containing the image.
+ * @param string $thumbnail Name of the thumbnail (or null for the original.)
+ * @return mixed
+ */
+ public function saveThumbnail(Photo $photo, $tempFilename, $thumbnail = null)
+ {
+ $pathOnStorage = $this->getPathToPhoto($photo, $thumbnail);
+
+ $this->getClient()->uploadFile($tempFilename, $pathOnStorage);
+ }
+
+ public function setConfiguration(Storage $configuration)
+ {
+ parent::setConfiguration($configuration);
+
+ $this->backblaze->setCredentials(decrypt($configuration->access_key), decrypt($configuration->secret_key));
+ }
+
+ private function getClient()
+ {
+ $this->backblaze->authorizeAccount();
+ $this->backblaze->setBucketName($this->configuration->container_name);
+
+ return $this->backblaze;
+ }
+
+ private function getOriginalsFolder()
+ {
+ return '_originals';
+ }
+
+ private function getPathToPhoto(Photo $photo, $thumbnail = null)
+ {
+ return sprintf(
+ '%s/%s/%s',
+ $this->album->url_alias,
+ is_null($thumbnail) ? $this->getOriginalsFolder() : $thumbnail,
+ $photo->storage_file_name
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/Helpers/ConfigHelper.php b/app/Helpers/ConfigHelper.php
index 2ae648c..c432d46 100644
--- a/app/Helpers/ConfigHelper.php
+++ b/app/Helpers/ConfigHelper.php
@@ -3,6 +3,7 @@
namespace App\Helpers;
use App\AlbumSources\AmazonS3Source;
+use App\AlbumSources\BackblazeB2Source;
use App\AlbumSources\IAlbumSource;
use App\AlbumSources\LocalFilesystemSource;
use App\AlbumSources\OpenStackSource;
@@ -46,6 +47,7 @@ class ConfigHelper
$classes = [
LocalFilesystemSource::class,
AmazonS3Source::class,
+ BackblazeB2Source::class,
OpenStackSource::class,
RackspaceSource::class
];
diff --git a/app/Services/BackblazeB2Service.php b/app/Services/BackblazeB2Service.php
new file mode 100644
index 0000000..eb98438
--- /dev/null
+++ b/app/Services/BackblazeB2Service.php
@@ -0,0 +1,177 @@
+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;
+ }
+ }
+
+ public function setBucketName($bucketName)
+ {
+ $this->bucketId = $this->getBucketIdFromName($bucketName);
+ }
+
+ 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.');
+ }
+
+ $fileSize = filesize($pathToFileToUpload);
+ $handle = fopen($pathToFileToUpload, 'r');
+ $fileContents = fread($handle, $fileSize);
+ fclose($handle);
+ $fileContentsSha1 = sha1_file($pathToFileToUpload);
+
+ $ch = $this->getBasicHttpClient($uploadUrl, 'POST', [
+ sprintf('Authorization: %s', $authorizationToken),
+ 'Content-Type: b2/x-auto',
+ sprintf('X-Bz-Content-Sha1: %s', $fileContentsSha1),
+ sprintf('X-Bz-File-Name: %s', urlencode($pathToStorage))
+ ]);
+
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $fileContents);
+
+ $result = curl_exec($ch);
+
+ var_dump($result);
+ exit();
+ }
+
+ private function getBucketIdFromName($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]->bucketId;
+ }
+
+ throw new \Exception(sprintf('The bucket \'%s\' was not found or your API key does not have access.', $bucketName));
+ }
+
+ private function getUploadUrl()
+ {
+ $result = $this->sendRequest(
+ sprintf('%s/b2api/v2/b2_get_upload_url', $this->accountApiUrl),
+ 'POST',
+ ['bucketId' => $this->bucketId]
+ );
+
+ return [$result->uploadUrl, $result->authorizationToken];
+ }
+
+ 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)
+ {
+ $httpHeaders = [];
+
+ 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);
+ }
+
+ $ch = $this->getBasicHttpClient($url, $method, $httpHeaders);
+
+ if (!is_null($postData))
+ {
+ curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData));
+ }
+
+ $result = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+
+ if ($httpCode != 200 && $httpCode != 304)
+ {
+ throw new \Exception(sprintf('Exception from Backblaze B2: %s', $result));
+ }
+
+ curl_close($ch);
+
+ return json_decode($result);
+ }
+}
\ No newline at end of file
diff --git a/composer.json b/composer.json
index f0988ea..5cdad6d 100644
--- a/composer.json
+++ b/composer.json
@@ -6,6 +6,7 @@
"type": "project",
"require": {
"php": ">=7.0.0",
+ "ext-curl": "*",
"ext-json": "*",
"laravel/framework": "5.5.*",
"rackspace/php-opencloud": "^1.16",
diff --git a/config/services.php b/config/services.php
index eab4683..c658a92 100644
--- a/config/services.php
+++ b/config/services.php
@@ -14,6 +14,10 @@ return [
|
*/
+ 'backblaze_b2' => [
+ 'auth_url' => 'https://api.backblazeb2.com/b2api/v2/b2_authorize_account'
+ ],
+
'gitea' => [
'api_url' => 'https://apps.andysh.uk/api/v1',
'cache_time_seconds' => 3600,
diff --git a/resources/lang/en/admin.php b/resources/lang/en/admin.php
index 6940c04..b844d06 100644
--- a/resources/lang/en/admin.php
+++ b/resources/lang/en/admin.php
@@ -323,6 +323,7 @@ return [
'photos' => 'photo|photos',
'users' => 'user|users',
],
+ 'storage_backblaze_access_key_id_help' => 'To use your account\'s master key, enter your account ID here.',
'storage_title' => 'Storage Locations',
'sysinfo_panel' => 'System information',
'sysinfo_widget' => [
diff --git a/resources/lang/en/forms.php b/resources/lang/en/forms.php
index ceda533..14674f1 100644
--- a/resources/lang/en/forms.php
+++ b/resources/lang/en/forms.php
@@ -98,6 +98,8 @@ return [
'storage_access_key_label' => 'Access key:',
'storage_active_label' => 'Location is active. Uncheck to prevent creating new albums in this location.',
'storage_api_key_label' => 'API key:',
+ 'storage_application_key_id_label' => 'Application key ID:',
+ 'storage_application_key_label' => 'Application key:',
'storage_auth_url_label' => 'Authentication URL:',
'storage_bucket_name_label' => 'Bucket name:',
'storage_cdn_url_label' => 'Public CDN URL (if supported and enabled):',
diff --git a/resources/lang/en/global.php b/resources/lang/en/global.php
index 0f83c7b..c5da0bd 100644
--- a/resources/lang/en/global.php
+++ b/resources/lang/en/global.php
@@ -2,6 +2,7 @@
return [
'album_sources' => [
'amazon_s3' => 'Amazon S3 (or S3-compatible)',
+ 'backblaze_b2' => 'Backblaze B2 Cloud',
'filesystem' => 'Local filesystem',
'openstack' => 'OpenStack cloud storage',
'rackspace' => 'Rackspace cloud storage'
diff --git a/resources/views/themes/base/admin/create_storage.blade.php b/resources/views/themes/base/admin/create_storage.blade.php
index f14e2cb..679e144 100644
--- a/resources/views/themes/base/admin/create_storage.blade.php
+++ b/resources/views/themes/base/admin/create_storage.blade.php
@@ -63,6 +63,10 @@
@include(Theme::viewName('partials.admin_storages_rackspace_options'))
+
+ @include(Theme::viewName('partials.admin_storages_backblaze_b2_options'))
+
+
+
\ No newline at end of file
--
2.47.0
From ce03b2596ff18d187de565cb649b63a11c485ef8 Mon Sep 17 00:00:00 2001
From: Andy Heathershaw
Date: Tue, 10 Sep 2019 15:11:53 +0100
Subject: [PATCH 4/8] Backblaze #135 - album storage driver is now cached to
maintain state within the same request, prevents multiple calls to B2. Images
can now be deleted and (I think) edited.
---
app/Album.php | 6 +-
app/AlbumSources/AlbumSourceBase.php | 25 ++++
app/AlbumSources/BackblazeB2Source.php | 50 +++++++-
app/BackblazeB2FileIdCache.php | 19 +++
app/Services/BackblazeB2Service.php | 111 ++++++++++++++----
...eate_backblaze_b2_file_id_caches_table.php | 38 ++++++
6 files changed, 220 insertions(+), 29 deletions(-)
create mode 100644 app/BackblazeB2FileIdCache.php
create mode 100644 database/migrations/2019_09_10_085020_create_backblaze_b2_file_id_caches_table.php
diff --git a/app/Album.php b/app/Album.php
index e375df9..158357c 100644
--- a/app/Album.php
+++ b/app/Album.php
@@ -2,6 +2,7 @@
namespace App;
+use App\AlbumSources\AlbumSourceBase;
use App\AlbumSources\IAlbumSource;
use App\AlbumSources\LocalFilesystemSource;
use App\Helpers\MiscHelper;
@@ -158,10 +159,7 @@ class Album extends Model
*/
public function getAlbumSource()
{
- $fullClassName = sprintf('App\AlbumSources\%s', $this->storage->source);
-
- /** @var IAlbumSource $source */
- $source = new $fullClassName;
+ $source = AlbumSourceBase::make($this->storage->source);
$source->setAlbum($this);
$source->setConfiguration($this->storage);
diff --git a/app/AlbumSources/AlbumSourceBase.php b/app/AlbumSources/AlbumSourceBase.php
index 364bc39..374e17f 100644
--- a/app/AlbumSources/AlbumSourceBase.php
+++ b/app/AlbumSources/AlbumSourceBase.php
@@ -17,6 +17,31 @@ abstract class AlbumSourceBase
*/
protected $configuration;
+ /**
+ * @var mixed
+ */
+ private static $albumSourceCache = [];
+
+ /**
+ * Makes an album source class for the given source name (relative class name.)
+ * @param string $sourceName Name of the source.
+ * @return IAlbumSource
+ */
+ public static function make($sourceName)
+ {
+ $fullClassName = sprintf('App\AlbumSources\%s', $sourceName);
+
+ if (!array_key_exists($fullClassName, self::$albumSourceCache))
+ {
+ /** @var IAlbumSource $source */
+ $source = app($fullClassName);
+
+ self::$albumSourceCache[$fullClassName] = $source;
+ }
+
+ return self::$albumSourceCache[$fullClassName];
+ }
+
public function setAlbum(Album $album)
{
$this->album = $album;
diff --git a/app/AlbumSources/BackblazeB2Source.php b/app/AlbumSources/BackblazeB2Source.php
index c6c21d7..b7e3986 100644
--- a/app/AlbumSources/BackblazeB2Source.php
+++ b/app/AlbumSources/BackblazeB2Source.php
@@ -2,10 +2,12 @@
namespace App\AlbumSources;
+use App\BackblazeB2FileIdCache;
use App\Photo;
use App\Services\BackblazeB2Service;
use App\Storage;
use Guzzle\Http\EntityBody;
+use Illuminate\Support\Facades\Log;
class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
{
@@ -36,7 +38,7 @@ class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
*/
public function deleteAlbumContents()
{
- // TODO: Implement deleteAlbumContents() method.
+ // No need to do anything for the album container - once the files are gone, the virtual folder is also gone
}
/**
@@ -47,7 +49,21 @@ class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
*/
public function deleteThumbnail(Photo $photo, $thumbnail = null)
{
- // TODO: Implement deleteThumbnail() method.
+ $pathOnStorage = $this->getPathToPhoto($photo, $thumbnail);
+
+ // Create or update our cache record
+
+ /** @var BackblazeB2FileIdCache $b2Cache */
+ $b2Cache = BackblazeB2FileIdCache::where('storage_path', $pathOnStorage)->first();
+ if (is_null($b2Cache))
+ {
+ // TODO: lookup the file on B2 to get the file ID
+ Log::warning(sprintf('B2 file ID not found in cache: %s', $pathOnStorage));
+ return;
+ }
+
+ $this->getClient()->deleteFile($b2Cache->b2_file_id, $pathOnStorage);
+ $b2Cache->delete();
}
/**
@@ -58,7 +74,14 @@ class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
*/
public function fetchPhotoContent(Photo $photo, $thumbnail = null)
{
- // TODO: Implement fetchPhotoContent() method.
+ // Use the same URLs that the public would use to fetch the file
+ $urlToPhoto = $this->getUrlToPhoto($photo, $thumbnail);
+
+ $ch = curl_init($urlToPhoto);
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
+ $fileContent = curl_exec($ch);
+
+ return EntityBody::fromString($fileContent);
}
/**
@@ -93,6 +116,7 @@ class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
switch ($this->bucketType)
{
case self::BUCKET_TYPE_PRIVATE:
+ // TODO: use the B2 b2_download_file_by_id method so filenames are harder to guess
if (is_null($this->downloadToken))
{
$this->downloadToken = $client->getDownloadAuthToken();
@@ -116,7 +140,25 @@ class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
{
$pathOnStorage = $this->getPathToPhoto($photo, $thumbnail);
- $this->getClient()->uploadFile($tempFilename, $pathOnStorage);
+ // Upload the file to B2
+ $b2FileID = $this->getClient()->uploadFile($tempFilename, $pathOnStorage);
+
+ // Create or update our cache record
+ $b2Cache = BackblazeB2FileIdCache::where('storage_path', $pathOnStorage)->first();
+ if (is_null($b2Cache))
+ {
+ $b2Cache = new BackblazeB2FileIdCache([
+ 'photo_id' => $photo->id,
+ 'storage_path' => $pathOnStorage,
+ 'b2_file_id' => $b2FileID
+ ]);
+ }
+ else
+ {
+ $b2Cache->b2_file_id = $b2FileID;
+ }
+
+ $b2Cache->save();
}
public function setConfiguration(Storage $configuration)
diff --git a/app/BackblazeB2FileIdCache.php b/app/BackblazeB2FileIdCache.php
new file mode 100644
index 0000000..b1765cd
--- /dev/null
+++ b/app/BackblazeB2FileIdCache.php
@@ -0,0 +1,19 @@
+sendRequest(
+ sprintf('%s/b2api/v2/b2_delete_file_version', $this->accountApiUrl),
+ 'POST',
+ [
+ 'fileId' => $fileID,
+ 'fileName' => $fileName
+ ]
+ );
+ }
+
+ public function downloadFile($fileID)
+ {
+ $downloadToken = $this->getDownloadAuthToken();
+
+ return $this->sendRequest(
+ sprintf('%s/b2api/v2/b2_download_file_by_id?fileId=%s', $this->accountApiUrl, urlencode($fileID), urlencode($downloadToken)),
+ 'GET',
+ null,
+ [
+ 'http_headers' => [
+ sprintf('Authorization: %s', $downloadToken)
+ ],
+ 'response_body_is_json' => false
+ ]
+ );
+ }
+
public function getBucketType()
{
return $this->bucketType;
@@ -129,24 +160,24 @@ class BackblazeB2Service
fclose($handle);
$fileContentsSha1 = sha1_file($pathToFileToUpload);
- $ch = $this->getBasicHttpClient($uploadUrl, 'POST', [
+ $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))
- ]);
+ ];
- curl_setopt($ch, CURLOPT_POSTFIELDS, $fileContents);
+ $result = $this->sendRequest(
+ $uploadUrl,
+ 'POST',
+ $fileContents,
+ [
+ 'http_headers' => $httpHeaders,
+ 'post_body_is_json' => false
+ ]
+ );
- $result = curl_exec($ch);
- $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
-
- if ($httpCode != 200 && $httpCode != 304)
- {
- throw new \Exception(sprintf('Exception from Backblaze B2: %s', $result));
- }
-
- curl_close($ch);
+ return $result->fileId;
}
private function getBucketDetailsFromName($bucketName)
@@ -206,31 +237,67 @@ class BackblazeB2Service
return $ch;
}
- private function sendRequest($url, $method = 'GET', $postData = null)
+ private function sendRequest($url, $method = 'GET', $postData = null, array $postOptions = [])
{
- $httpHeaders = [];
+ $postOptions = array_merge(
+ [
+ 'authorization_token' => null,
+ 'http_headers' => [],
+ 'post_body_is_json' => true,
+ 'response_body_is_json' => true
+ ],
+ $postOptions
+ );
+ $httpHeaders = $postOptions['http_headers'];
- if (is_null($this->authToken))
+ // Some methods may need to override the authorization token used
+ if (empty($postOptions['authorization_token']))
{
- // No auth token yet, use username/password
- $httpHeaders[] = sprintf('Authorization: Basic %s', base64_encode($this->authHeader));
+ // 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
{
- // Use the auth token we have
- $httpHeaders[] = sprintf('Authorization: %s', $this->authToken);
+ // Override - use the auth token specified
+ $httpHeaders[] = sprintf('Authorization: %s', $postOptions['authorization_token']);
}
$ch = $this->getBasicHttpClient($url, $method, $httpHeaders);
if (!is_null($postData))
{
- curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($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));
+ Log::debug($result);
+
if ($httpCode != 200 && $httpCode != 304)
{
throw new \Exception(sprintf('Exception from Backblaze B2: %s', $result));
@@ -238,6 +305,8 @@ class BackblazeB2Service
curl_close($ch);
- return json_decode($result);
+ return $postOptions['response_body_is_json']
+ ? json_decode($result)
+ : $result;
}
}
\ No newline at end of file
diff --git a/database/migrations/2019_09_10_085020_create_backblaze_b2_file_id_caches_table.php b/database/migrations/2019_09_10_085020_create_backblaze_b2_file_id_caches_table.php
new file mode 100644
index 0000000..1b7ac4d
--- /dev/null
+++ b/database/migrations/2019_09_10_085020_create_backblaze_b2_file_id_caches_table.php
@@ -0,0 +1,38 @@
+bigIncrements('id');
+ $table->unsignedBigInteger('photo_id');
+ $table->string('storage_path');
+ $table->string('b2_file_id');
+ $table->timestamps();
+
+ $table->foreign('photo_id')
+ ->references('id')->on('photos')
+ ->onDelete('cascade');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * @return void
+ */
+ public function down()
+ {
+ Schema::dropIfExists('backblaze_b2_file_id_caches');
+ }
+}
--
2.47.0
From fb6754b8e917bc1e33b359ac6eeb4f81a960820e Mon Sep 17 00:00:00 2001
From: Andy Heathershaw
Date: Tue, 10 Sep 2019 16:24:26 +0100
Subject: [PATCH 5/8] Backblaze #135 - tried to implement
b2_download_file_by_id for private buckets, but this doesn't work correctly,
logged with Backblaze
---
app/AlbumSources/BackblazeB2Source.php | 35 ++++++++++++++++----------
1 file changed, 22 insertions(+), 13 deletions(-)
diff --git a/app/AlbumSources/BackblazeB2Source.php b/app/AlbumSources/BackblazeB2Source.php
index b7e3986..c9a4c9e 100644
--- a/app/AlbumSources/BackblazeB2Source.php
+++ b/app/AlbumSources/BackblazeB2Source.php
@@ -102,30 +102,39 @@ class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
public function getUrlToPhoto(Photo $photo, $thumbnail = null)
{
$client = $this->getClient();
- $storagePathToFile = $this->getPathToPhoto($photo, $thumbnail);
-
- /*
- * From https://www.backblaze.com/b2/docs/b2_download_file_by_name.html:
- * The base URL to use comes from the b2_authorize_account call, and looks something like
- * https://f345.backblazeb2.com. The "f" in the URL stands for "file", and the number is the cluster
- * number containing your account. To this base, you add "file/", your bucket name, a "/", and then the
- * name of the file. The file name may itself include more "/" characters.
- */
- $fileDownloadUrl = sprintf('%s/file/%s/%s', $client->getDownloadUrl(), $this->configuration->container_name, $storagePathToFile);
+ $pathOnStorage = $this->getPathToPhoto($photo, $thumbnail);
switch ($this->bucketType)
{
case self::BUCKET_TYPE_PRIVATE:
- // TODO: use the B2 b2_download_file_by_id method so filenames are harder to guess
if (is_null($this->downloadToken))
{
$this->downloadToken = $client->getDownloadAuthToken();
}
- return sprintf('%s?Authorization=%s', $fileDownloadUrl, $this->downloadToken);
+ // Once I sort out the issue with b2_download_file_by_id, this line can be removed
+ return sprintf('%s/file/%s/%s?Authorization=%s', $client->getDownloadUrl(), $this->configuration->container_name, $pathOnStorage, $this->downloadToken);
+
+ /** @var BackblazeB2FileIdCache $b2Cache */
+ $b2Cache = BackblazeB2FileIdCache::where('storage_path', $pathOnStorage)->first();
+ if (is_null($b2Cache))
+ {
+ // TODO: lookup the file on B2 to get the file ID
+ Log::warning(sprintf('B2 file ID not found in cache: %s', $pathOnStorage));
+ return '';
+ }
+
+ return sprintf('%s/b2api/v2/b2_download_file_by_id?fileId=%s&Authorization=%s', $client->getDownloadUrl(), urlencode($b2Cache->b2_file_id), urlencode($this->downloadToken));
case self::BUCKET_TYPE_PUBLIC:
- return $fileDownloadUrl;
+ /*
+ * From https://www.backblaze.com/b2/docs/b2_download_file_by_name.html:
+ * The base URL to use comes from the b2_authorize_account call, and looks something like
+ * https://f345.backblazeb2.com. The "f" in the URL stands for "file", and the number is the cluster
+ * number containing your account. To this base, you add "file/", your bucket name, a "/", and then the
+ * name of the file. The file name may itself include more "/" characters.
+ */
+ return sprintf('%s/file/%s/%s', $client->getDownloadUrl(), $this->configuration->container_name, $pathOnStorage);
}
}
--
2.47.0
From 69422ffaa42cda7d2881c5925cab17e224e3f9a3 Mon Sep 17 00:00:00 2001
From: Andy Heathershaw
Date: Wed, 11 Sep 2019 14:59:25 +0100
Subject: [PATCH 6/8] Backblaze #135 - implemented a retry and backoff period
for 500/503 errors
---
app/Exceptions/BackblazeRetryException.php | 25 +++++++++++++++++
app/Services/BackblazeB2Service.php | 32 +++++++++++++++++++++-
2 files changed, 56 insertions(+), 1 deletion(-)
create mode 100644 app/Exceptions/BackblazeRetryException.php
diff --git a/app/Exceptions/BackblazeRetryException.php b/app/Exceptions/BackblazeRetryException.php
new file mode 100644
index 0000000..557ebfd
--- /dev/null
+++ b/app/Exceptions/BackblazeRetryException.php
@@ -0,0 +1,25 @@
+innerException;
+ }
+
+ public function __construct($httpCode, \Exception $innerException)
+ {
+ parent::__construct('Backblaze requested to retry the request');
+
+ $this->innerException = $innerException;
+ }
+}
\ No newline at end of file
diff --git a/app/Services/BackblazeB2Service.php b/app/Services/BackblazeB2Service.php
index 2ffb95c..57149ee 100644
--- a/app/Services/BackblazeB2Service.php
+++ b/app/Services/BackblazeB2Service.php
@@ -2,6 +2,7 @@
namespace App\Services;
+use App\Exceptions\BackblazeRetryException;
use Illuminate\Support\Facades\Log;
class BackblazeB2Service
@@ -238,6 +239,27 @@ class BackblazeB2Service
}
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)
+ {
+ // Keep backing off
+ $exponentialBackoff *= $exponentialBackoff;
+ $numberOfTimesTried++;
+ }
+ }
+ }
+
+ private function sendRequestReal($url, $method = 'GET', $postData = null, array $postOptions = [])
{
$postOptions = array_merge(
[
@@ -298,7 +320,15 @@ class BackblazeB2Service
Log::info(sprintf('Received HTTP code %d', $httpCode));
Log::debug($result);
- if ($httpCode != 200 && $httpCode != 304)
+ // 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));
}
--
2.47.0
From a6825bcef90bdaa7f05759047945540af6cacf20 Mon Sep 17 00:00:00 2001
From: Andy Heathershaw
Date: Sat, 14 Sep 2019 10:04:09 +0100
Subject: [PATCH 7/8] Backblaze #135 - implemented the re-use of the upload
token/URL. Fetching file contents now works by using the
b2_download_file_by_id method with an auth header.
---
app/AlbumSources/BackblazeB2Source.php | 20 ++--
app/Services/BackblazeB2Service.php | 84 ++++++++++++---
public/b2_test.php | 143 +++++++++++++++++++++++++
3 files changed, 228 insertions(+), 19 deletions(-)
create mode 100644 public/b2_test.php
diff --git a/app/AlbumSources/BackblazeB2Source.php b/app/AlbumSources/BackblazeB2Source.php
index c9a4c9e..5223d32 100644
--- a/app/AlbumSources/BackblazeB2Source.php
+++ b/app/AlbumSources/BackblazeB2Source.php
@@ -74,14 +74,22 @@ class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
*/
public function fetchPhotoContent(Photo $photo, $thumbnail = null)
{
- // Use the same URLs that the public would use to fetch the file
- $urlToPhoto = $this->getUrlToPhoto($photo, $thumbnail);
+ $pathOnStorage = $this->getPathToPhoto($photo, $thumbnail);
- $ch = curl_init($urlToPhoto);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- $fileContent = curl_exec($ch);
+ // First we need the file ID
- return EntityBody::fromString($fileContent);
+ /** @var BackblazeB2FileIdCache $b2Cache */
+ $b2Cache = BackblazeB2FileIdCache::where('storage_path', $pathOnStorage)->first();
+ if (is_null($b2Cache))
+ {
+ // TODO: lookup the file on B2 to get the file ID
+ Log::warning(sprintf('B2 file ID not found in cache: %s', $pathOnStorage));
+ return EntityBody::fromString('');
+ }
+
+ return EntityBody::fromString(
+ $this->getClient()->downloadFile($b2Cache->b2_file_id)
+ );
}
/**
diff --git a/app/Services/BackblazeB2Service.php b/app/Services/BackblazeB2Service.php
index 57149ee..ceab613 100644
--- a/app/Services/BackblazeB2Service.php
+++ b/app/Services/BackblazeB2Service.php
@@ -55,6 +55,18 @@ class BackblazeB2Service
*/
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');
@@ -92,15 +104,13 @@ class BackblazeB2Service
public function downloadFile($fileID)
{
- $downloadToken = $this->getDownloadAuthToken();
-
return $this->sendRequest(
- sprintf('%s/b2api/v2/b2_download_file_by_id?fileId=%s', $this->accountApiUrl, urlencode($fileID), urlencode($downloadToken)),
+ sprintf('%s/b2api/v2/b2_download_file_by_id?fileId=%s', $this->accountApiUrl, urlencode($fileID)),
'GET',
null,
[
'http_headers' => [
- sprintf('Authorization: %s', $downloadToken)
+ sprintf('Authorization: %s', $this->authToken)
],
'response_body_is_json' => false
]
@@ -155,6 +165,35 @@ class BackblazeB2Service
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);
@@ -168,7 +207,7 @@ class BackblazeB2Service
sprintf('X-Bz-File-Name: %s', urlencode($pathToStorage))
];
- $result = $this->sendRequest(
+ $result = $this->sendRequestReal(
$uploadUrl,
'POST',
$fileContents,
@@ -200,15 +239,21 @@ class BackblazeB2Service
throw new \Exception(sprintf('The bucket \'%s\' was not found or your API key does not have access.', $bucketName));
}
- private function getUploadUrl()
+ private function getUploadUrl($alwaysGetNewToken = false)
{
- $result = $this->sendRequest(
- sprintf('%s/b2api/v2/b2_get_upload_url', $this->accountApiUrl),
- 'POST',
- ['bucketId' => $this->bucketId]
- );
+ if (is_null($this->uploadAuthToken) || $alwaysGetNewToken)
+ {
+ $result = $this->sendRequest(
+ sprintf('%s/b2api/v2/b2_get_upload_url', $this->accountApiUrl),
+ 'POST',
+ ['bucketId' => $this->bucketId]
+ );
- return [$result->uploadUrl, $result->authorizationToken];
+ $this->uploadAuthToken = $result->authorizationToken;
+ $this->uploadUrl = $result->uploadUrl;
+ }
+
+ return [$this->uploadUrl, $this->uploadAuthToken];
}
private function getBasicHttpClient($url, $method = 'GET', array $httpHeaders = [])
@@ -252,7 +297,15 @@ class BackblazeB2Service
}
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++;
}
@@ -318,7 +371,12 @@ class BackblazeB2Service
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
Log::info(sprintf('Received HTTP code %d', $httpCode));
- Log::debug($result);
+
+ // 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)
diff --git a/public/b2_test.php b/public/b2_test.php
new file mode 100644
index 0000000..a741604
--- /dev/null
+++ b/public/b2_test.php
@@ -0,0 +1,143 @@
+' . $uri . '
';
+
+ $server_output = curl_exec($session); // Let's do this!
+
+ if (curl_getinfo($session, CURLINFO_HTTP_CODE) != 200)
+ {
+ echo '' . $server_output . '
';
+ }
+ else
+ {
+ echo '' . (strlen($server_output) . ' bytes received') . '
'; // Tell me about the rabbits, George!
+ }
+
+ curl_close ($session); // Clean up
+
+ //$download_url = ""; // From b2_authorize_account call
+ $file_id = "4_z731245f41efc196b6dda0018_f116729ca6de74b38_d20190910_m132847_c002_v0001127_t0021"; // The ID of the file you want to download
+ $uri = $download_url . "/b2api/v2/b2_download_file_by_id?fileId=" . $file_id . '&Authorization=' . $auth_token;
+
+ $session = curl_init($uri);
+
+ curl_setopt($session, CURLOPT_HTTPGET, true); // HTTP GET
+ curl_setopt($session, CURLOPT_RETURNTRANSFER, true); // Receive server response
+
+ echo '' . $uri . '
';
+
+ $server_output = curl_exec($session); // Let's do this!
+
+ if (curl_getinfo($session, CURLINFO_HTTP_CODE) != 200)
+ {
+ echo '' . $server_output . '
';
+ }
+ else
+ {
+ echo '' . (strlen($server_output) . ' bytes received') . '
'; // Tell me about the rabbits, George!
+ }
+
+ curl_close ($session); // Clean up
+}
+
+function b2_download_file_by_name($download_url, $auth_token)
+{
+ //$download_url = ""; // From b2_authorize_account call
+ $bucket_name = "andysh-bt-test"; // The NAME of the bucket you want to download from
+ $file_name = "B2-Test-Album/preview/7tgoy55do1vjv180ytlp.jpeg"; // The name of the file you want to download
+ $uri = $download_url . "/file/" . $bucket_name . "/" . $file_name;
+
+ $session = curl_init($uri);
+
+ curl_setopt($session, CURLOPT_HTTPGET, true); // HTTP GET
+ curl_setopt($session, CURLOPT_SSL_VERIFYPEER, false);
+ curl_setopt($session, CURLOPT_RETURNTRANSFER, true); // Receive server response
+
+ echo '' . $uri . '
';
+
+ $server_output = curl_exec($session); // Let's do this!
+
+ if (curl_getinfo($session, CURLINFO_HTTP_CODE) != 200)
+ {
+ echo '' . $server_output . '
';
+ }
+ else
+ {
+ echo '' . (strlen($server_output) . ' bytes received') . '
'; // Tell me about the rabbits, George!
+ }
+
+ curl_close ($session); // Clean up
+
+ // You will need to use the account authorization token if your bucket's type is allPrivate.
+
+ //$download_url = ""; // From b2_authorize_account call
+ $bucket_name = "andysh-bt-test"; // The NAME of the bucket you want to download from
+ $file_name = "B2-Test-Album/preview/7tgoy55do1vjv180ytlp.jpeg"; // The name of the file you want to download
+ //$auth_token = ""; // From b2_authorize_account call
+ $uri = $download_url . "/file/" . $bucket_name . "/" . $file_name . '?Authorization=' . $auth_token;
+
+ $session = curl_init($uri);
+
+ curl_setopt($session, CURLOPT_HTTPGET, true); // HTTP POST
+ curl_setopt($session, CURLOPT_RETURNTRANSFER, true); // Receive server response
+
+ echo '' . $uri . '
';
+
+ $server_output = curl_exec($session); // Let's do this!
+
+ if (curl_getinfo($session, CURLINFO_HTTP_CODE) != 200)
+ {
+ echo '' . $server_output . '
';
+ }
+ else
+ {
+ echo '' . (strlen($server_output) . ' bytes received') . '
'; // Tell me about the rabbits, George!
+ }
+
+ curl_close ($session); // Clean up
+}
+
+?>
+b2_authorize_account
+
+
+b2_download_file_by_name
+downloadUrl, $authorize_account_result->authorizationToken); ?>
+
+b2_download_file_by_id
+downloadUrl, $authorize_account_result->authorizationToken); ?>
--
2.47.0
From 99cafbc9a5afbcb17ba80844aef440204fbdf888 Mon Sep 17 00:00:00 2001
From: Andy Heathershaw
Date: Sat, 14 Sep 2019 15:35:05 +0100
Subject: [PATCH 8/8] Backblaze #135 - B2 storage source now removes the
current file version before uploading a new one
---
app/AlbumSources/BackblazeB2Source.php | 40 +++++++++++++++++---------
1 file changed, 27 insertions(+), 13 deletions(-)
diff --git a/app/AlbumSources/BackblazeB2Source.php b/app/AlbumSources/BackblazeB2Source.php
index 5223d32..b206c1a 100644
--- a/app/AlbumSources/BackblazeB2Source.php
+++ b/app/AlbumSources/BackblazeB2Source.php
@@ -53,12 +53,9 @@ class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
// Create or update our cache record
- /** @var BackblazeB2FileIdCache $b2Cache */
- $b2Cache = BackblazeB2FileIdCache::where('storage_path', $pathOnStorage)->first();
+ $b2Cache = $this->getB2FileFromCache($pathOnStorage);
if (is_null($b2Cache))
{
- // TODO: lookup the file on B2 to get the file ID
- Log::warning(sprintf('B2 file ID not found in cache: %s', $pathOnStorage));
return;
}
@@ -78,12 +75,9 @@ class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
// First we need the file ID
- /** @var BackblazeB2FileIdCache $b2Cache */
- $b2Cache = BackblazeB2FileIdCache::where('storage_path', $pathOnStorage)->first();
+ $b2Cache = $this->getB2FileFromCache($pathOnStorage);
if (is_null($b2Cache))
{
- // TODO: lookup the file on B2 to get the file ID
- Log::warning(sprintf('B2 file ID not found in cache: %s', $pathOnStorage));
return EntityBody::fromString('');
}
@@ -123,12 +117,9 @@ class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
// Once I sort out the issue with b2_download_file_by_id, this line can be removed
return sprintf('%s/file/%s/%s?Authorization=%s', $client->getDownloadUrl(), $this->configuration->container_name, $pathOnStorage, $this->downloadToken);
- /** @var BackblazeB2FileIdCache $b2Cache */
- $b2Cache = BackblazeB2FileIdCache::where('storage_path', $pathOnStorage)->first();
+ $b2Cache = $this->getB2FileFromCache($pathOnStorage);
if (is_null($b2Cache))
{
- // TODO: lookup the file on B2 to get the file ID
- Log::warning(sprintf('B2 file ID not found in cache: %s', $pathOnStorage));
return '';
}
@@ -157,11 +148,17 @@ class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
{
$pathOnStorage = $this->getPathToPhoto($photo, $thumbnail);
+ $b2Cache = $this->getB2FileFromCache($pathOnStorage);
+ if (!is_null($b2Cache))
+ {
+ // Delete the current file version if we're replacing a file that already exists
+ $this->getClient()->deleteFile($b2Cache->b2_file_id, $pathOnStorage);
+ }
+
// Upload the file to B2
$b2FileID = $this->getClient()->uploadFile($tempFilename, $pathOnStorage);
// Create or update our cache record
- $b2Cache = BackblazeB2FileIdCache::where('storage_path', $pathOnStorage)->first();
if (is_null($b2Cache))
{
$b2Cache = new BackblazeB2FileIdCache([
@@ -183,6 +180,23 @@ class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
parent::setConfiguration($configuration);
}
+ /**
+ * @param $pathOnStorage
+ * @return BackblazeB2FileIdCache|null
+ */
+ private function getB2FileFromCache($pathOnStorage)
+ {
+ $b2Cache = BackblazeB2FileIdCache::where('storage_path', $pathOnStorage)->first();
+ if (is_null($b2Cache))
+ {
+ // TODO: lookup the file on B2 to get the file ID
+ Log::warning(sprintf('B2 file ID not found in cache: %s', $pathOnStorage));
+ return null;
+ }
+
+ return $b2Cache;
+ }
+
private function getClient()
{
if (is_null($this->backblaze))
--
2.47.0