From 582e5fffaac01190db6f4bd029a5d95f339a0d92 Mon Sep 17 00:00:00 2001 From: Andy Heathershaw Date: Sun, 15 Sep 2019 21:37:41 +0100 Subject: [PATCH] Dropbox #106 - files can be uploaded to a Dropbox account using a generated access token, and downloaded using the Blue Twilight download endpoint. --- app/AlbumSources/DropboxSource.php | 118 +++++++++++++ app/Helpers/ConfigHelper.php | 2 + .../Controllers/Admin/StorageController.php | 8 +- app/Http/Requests/StoreStorageRequest.php | 24 +++ app/Services/DropboxService.php | 157 ++++++++++++++++++ app/Storage.php | 3 +- config/services.php | 5 + ...205456_add_storage_access_token_column.php | 32 ++++ 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 | 9 +- .../admin_storages_dropbox_options.blade.php | 41 +++++ 13 files changed, 400 insertions(+), 6 deletions(-) create mode 100644 app/AlbumSources/DropboxSource.php create mode 100644 app/Services/DropboxService.php create mode 100644 database/migrations/2019_09_15_205456_add_storage_access_token_column.php create mode 100644 resources/views/themes/base/partials/admin_storages_dropbox_options.blade.php diff --git a/app/AlbumSources/DropboxSource.php b/app/AlbumSources/DropboxSource.php new file mode 100644 index 0000000..2b67ea9 --- /dev/null +++ b/app/AlbumSources/DropboxSource.php @@ -0,0 +1,118 @@ +getPathToPhoto($photo, $thumbnail); + + return EntityBody::fromString($this->getClient()->downloadFile($pathOnStorage)); + } + + /** + * Gets the name of this album source. + * @return string + */ + public function getName() + { + return 'global.album_sources.dropbox'; + } + + /** + * 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) + { + $photoUrl = route('downloadPhoto', [ + 'albumUrlAlias' => $this->album->url_path, + 'photoFilename' => $photo->storage_file_name + ]); + + if (!is_null($thumbnail)) + { + $photoUrl .= sprintf('?t=%s', urlencode($thumbnail)); + } + + return $photoUrl; + } + + /** + * 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); + } + + private function getClient() + { + if (is_null($this->dropboxClient)) + { + $this->dropboxClient = new DropboxService(); + $this->dropboxClient->setAccessToken(decrypt($this->configuration->access_token)); + } + + return $this->dropboxClient; + } + + 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 c432d46..27c2f20 100644 --- a/app/Helpers/ConfigHelper.php +++ b/app/Helpers/ConfigHelper.php @@ -4,6 +4,7 @@ namespace App\Helpers; use App\AlbumSources\AmazonS3Source; use App\AlbumSources\BackblazeB2Source; +use App\AlbumSources\DropboxSource; use App\AlbumSources\IAlbumSource; use App\AlbumSources\LocalFilesystemSource; use App\AlbumSources\OpenStackSource; @@ -48,6 +49,7 @@ class ConfigHelper LocalFilesystemSource::class, AmazonS3Source::class, BackblazeB2Source::class, + DropboxSource::class, OpenStackSource::class, RackspaceSource::class ]; diff --git a/app/Http/Controllers/Admin/StorageController.php b/app/Http/Controllers/Admin/StorageController.php index 6a1c775..181431c 100644 --- a/app/Http/Controllers/Admin/StorageController.php +++ b/app/Http/Controllers/Admin/StorageController.php @@ -23,7 +23,7 @@ class StorageController extends Controller $this->middleware('auth'); View::share('is_admin', true); - $this->encryptedFields = ['password', 'access_key', 'secret_key']; + $this->encryptedFields = ['password', 'access_key', 'secret_key', 'access_token']; } /** @@ -89,7 +89,8 @@ class StorageController extends Controller 'cdn_url', 'access_key', 'secret_key', - 'b2_bucket_type' + 'b2_bucket_type', + 'access_token' ])); $storage->is_active = true; $storage->is_default = (strtolower($request->get('is_default')) == 'on'); @@ -219,7 +220,8 @@ class StorageController extends Controller 'cdn_url', 'access_key', 'secret_key', - 'b2_bucket_type' + 'b2_bucket_type', + 'access_token' ])); $storage->is_active = (strtolower($request->get('is_active')) == 'on'); $storage->is_default = (strtolower($request->get('is_default')) == 'on'); diff --git a/app/Http/Requests/StoreStorageRequest.php b/app/Http/Requests/StoreStorageRequest.php index 8c445b9..5d3e4d8 100644 --- a/app/Http/Requests/StoreStorageRequest.php +++ b/app/Http/Requests/StoreStorageRequest.php @@ -65,6 +65,18 @@ class StoreStorageRequest extends FormRequest $result['service_region'] = 'sometimes|required'; $result['container_name'] = 'sometimes|required'; break; + + case 'BackblazeB2Source': + $result['access_key'] = 'sometimes|required'; + $result['secret_key'] = 'sometimes|required'; + $result['container_name'] = 'sometimes|required'; + break; + + case 'DropboxSource': + $result['access_key'] = 'sometimes|required'; + $result['secret_key'] = 'sometimes|required'; + $result['access_token'] = 'sometimes|required'; + break; } break; @@ -103,6 +115,18 @@ class StoreStorageRequest extends FormRequest $result['service_region'] = 'sometimes|required'; $result['container_name'] = 'sometimes|required'; break; + + case 'BackblazeB2Source': + $result['access_key'] = 'sometimes|required'; + $result['secret_key'] = 'sometimes|required'; + $result['container_name'] = 'sometimes|required'; + break; + + case 'DropboxSource': + $result['access_key'] = 'sometimes|required'; + $result['secret_key'] = 'sometimes|required'; + $result['access_token'] = 'sometimes|required'; + break; } break; } diff --git a/app/Services/DropboxService.php b/app/Services/DropboxService.php new file mode 100644 index 0000000..5fca0fc --- /dev/null +++ b/app/Services/DropboxService.php @@ -0,0 +1,157 @@ +config = config('services.dropbox'); + } + + public function downloadFile($pathOnStorage) + { + $dropboxArgs = ['path' => $pathOnStorage]; + + return $this->sendRequest( + $this->config['download_url'], + 'POST', + null, + [ + 'http_headers' => [ + sprintf('Dropbox-API-Arg: %s', json_encode($dropboxArgs)), + 'Content-Type: application/octet-stream' + ], + 'post_body_is_json' => false, + 'response_body_is_json' => false + ] + ); + } + + /** + * @param string $accessToken + */ + public function setAccessToken(string $accessToken) + { + $this->accessToken = $accessToken; + } + + public function uploadFile($pathToFileToUpload, $pathToStorage) + { + $dropboxArgs = [ + 'path' => $pathToStorage, + 'mode' => 'add', + 'mute' => true + ]; + + $uploadResult = $this->sendRequest( + $this->config['upload_url'], + 'POST', + file_get_contents($pathToFileToUpload), + [ + 'http_headers' => [ + sprintf('Dropbox-API-Arg: %s', json_encode($dropboxArgs)), + 'Content-Type: application/octet-stream' + ], + 'post_body_is_json' => false + ] + ); + } + + private function getBasicHttpClient($url, $method = 'GET', array $httpHeaders = []) + { + $httpHeaders = array_merge( + [ + 'Accept: application/json', + sprintf('Authorization: Bearer %s', $this->accessToken) + ], + $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 = []) + { + $postOptions = array_merge( + [ + 'http_headers' => [], + 'post_body_is_json' => true, + 'response_body_is_json' => true + ], + $postOptions + ); + $httpHeaders = $postOptions['http_headers']; + + $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); + } + + if ($httpCode != 200 && $httpCode != 304) + { + throw new \Exception(sprintf('Exception from Dropbox: %s', $result)); + } + + curl_close($ch); + + return $postOptions['response_body_is_json'] + ? json_decode($result) + : $result; + } +} \ No newline at end of file diff --git a/app/Storage.php b/app/Storage.php index 660178c..beecf04 100644 --- a/app/Storage.php +++ b/app/Storage.php @@ -31,7 +31,8 @@ class Storage extends Model 'cdn_url', 'access_key', 'secret_key', - 'b2_bucket_type' + 'b2_bucket_type', + 'access_token' ]; public function albums() diff --git a/config/services.php b/config/services.php index e7ace08..a67c763 100644 --- a/config/services.php +++ b/config/services.php @@ -19,6 +19,11 @@ return [ 'download_token_lifetime' => 300 ], + 'dropbox' => [ + 'download_url' => 'https://content.dropboxapi.com/2/files/download', + 'upload_url' => 'https://content.dropboxapi.com/2/files/upload' + ], + 'gitea' => [ 'api_url' => 'https://apps.andysh.uk/api/v1', 'cache_time_seconds' => 3600, diff --git a/database/migrations/2019_09_15_205456_add_storage_access_token_column.php b/database/migrations/2019_09_15_205456_add_storage_access_token_column.php new file mode 100644 index 0000000..0163f46 --- /dev/null +++ b/database/migrations/2019_09_15_205456_add_storage_access_token_column.php @@ -0,0 +1,32 @@ +text('access_token')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('storages', function (Blueprint $table) { + $table->dropColumn('access_token'); + }); + } +} diff --git a/resources/lang/en/forms.php b/resources/lang/en/forms.php index 0e1cc96..a86ca24 100644 --- a/resources/lang/en/forms.php +++ b/resources/lang/en/forms.php @@ -96,10 +96,12 @@ return [ 'settings_social_user_profiles' => 'Enable public user profiles', 'settings_social_user_profiles_help' => 'Display public pages for users showing their albums, cameras used and activity.', 'storage_access_key_label' => 'Access key:', + 'storage_access_token_label' => 'Access token:', '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_application_secret_label' => 'Application secret:', 'storage_auth_url_label' => 'Authentication URL:', 'storage_b2_bucket_type' => [ 'autodetect' => 'Auto-detect', diff --git a/resources/lang/en/global.php b/resources/lang/en/global.php index c5da0bd..1b47a77 100644 --- a/resources/lang/en/global.php +++ b/resources/lang/en/global.php @@ -3,6 +3,7 @@ return [ 'album_sources' => [ 'amazon_s3' => 'Amazon S3 (or S3-compatible)', 'backblaze_b2' => 'Backblaze B2 Cloud', + 'dropbox' => 'Dropbox', '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 679e144..b0328f6 100644 --- a/resources/views/themes/base/admin/create_storage.blade.php +++ b/resources/views/themes/base/admin/create_storage.blade.php @@ -67,6 +67,10 @@ @include(Theme::viewName('partials.admin_storages_backblaze_b2_options')) +
+ @include(Theme::viewName('partials.admin_storages_dropbox_options')) +
+
is_default)) checked="checked"@endif> diff --git a/resources/views/themes/base/admin/edit_storage.blade.php b/resources/views/themes/base/admin/edit_storage.blade.php index c7d4705..f222cdc 100644 --- a/resources/views/themes/base/admin/edit_storage.blade.php +++ b/resources/views/themes/base/admin/edit_storage.blade.php @@ -61,10 +61,15 @@ @include(Theme::viewName('partials.admin_storages_rackspace_options')) @endif -
+ @if ($storage->source == 'BackblazeB2Source')
@include(Theme::viewName('partials.admin_storages_backblaze_b2_options')) -
+ @endif + + @if ($storage->source == 'DropboxSource') +
+ @include(Theme::viewName('partials.admin_storages_dropbox_options')) + @endif
@lang('forms.cancel_action') diff --git a/resources/views/themes/base/partials/admin_storages_dropbox_options.blade.php b/resources/views/themes/base/partials/admin_storages_dropbox_options.blade.php new file mode 100644 index 0000000..41c2577 --- /dev/null +++ b/resources/views/themes/base/partials/admin_storages_dropbox_options.blade.php @@ -0,0 +1,41 @@ +
+
+
+ + + + @if ($errors->has('access_key')) +
+ {{ $errors->first('access_key') }} +
+ @endif +
+
+
+
+ + + + @if ($errors->has('secret_key')) +
+ {{ $errors->first('secret_key') }} +
+ @endif +
+
+
+ +
+
+
+ + + + @if ($errors->has('access_key')) +
+ {{ $errors->first('access_token') }} +
+ @endif +
+
+
\ No newline at end of file