Dropbox #106 - files can be uploaded to a Dropbox account using a generated access token, and downloaded using the Blue Twilight download endpoint.

This commit is contained in:
Andy Heathershaw 2019-09-15 21:37:41 +01:00
parent da99b0b05a
commit 582e5fffaa
13 changed files with 400 additions and 6 deletions

View File

@ -0,0 +1,118 @@
<?php
namespace App\AlbumSources;
use App\Photo;
use App\Services\DropboxService;
use Guzzle\Http\EntityBody;
class DropboxSource extends AlbumSourceBase implements IAlbumSource
{
/**
* @var DropboxService
*/
private $dropboxClient;
/**
* 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)
{
$pathOnStorage = $this->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
);
}
}

View File

@ -4,6 +4,7 @@ namespace App\Helpers;
use App\AlbumSources\AmazonS3Source; use App\AlbumSources\AmazonS3Source;
use App\AlbumSources\BackblazeB2Source; use App\AlbumSources\BackblazeB2Source;
use App\AlbumSources\DropboxSource;
use App\AlbumSources\IAlbumSource; use App\AlbumSources\IAlbumSource;
use App\AlbumSources\LocalFilesystemSource; use App\AlbumSources\LocalFilesystemSource;
use App\AlbumSources\OpenStackSource; use App\AlbumSources\OpenStackSource;
@ -48,6 +49,7 @@ class ConfigHelper
LocalFilesystemSource::class, LocalFilesystemSource::class,
AmazonS3Source::class, AmazonS3Source::class,
BackblazeB2Source::class, BackblazeB2Source::class,
DropboxSource::class,
OpenStackSource::class, OpenStackSource::class,
RackspaceSource::class RackspaceSource::class
]; ];

View File

@ -23,7 +23,7 @@ class StorageController extends Controller
$this->middleware('auth'); $this->middleware('auth');
View::share('is_admin', true); 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', 'cdn_url',
'access_key', 'access_key',
'secret_key', 'secret_key',
'b2_bucket_type' 'b2_bucket_type',
'access_token'
])); ]));
$storage->is_active = true; $storage->is_active = true;
$storage->is_default = (strtolower($request->get('is_default')) == 'on'); $storage->is_default = (strtolower($request->get('is_default')) == 'on');
@ -219,7 +220,8 @@ class StorageController extends Controller
'cdn_url', 'cdn_url',
'access_key', 'access_key',
'secret_key', 'secret_key',
'b2_bucket_type' 'b2_bucket_type',
'access_token'
])); ]));
$storage->is_active = (strtolower($request->get('is_active')) == 'on'); $storage->is_active = (strtolower($request->get('is_active')) == 'on');
$storage->is_default = (strtolower($request->get('is_default')) == 'on'); $storage->is_default = (strtolower($request->get('is_default')) == 'on');

View File

@ -65,6 +65,18 @@ class StoreStorageRequest extends FormRequest
$result['service_region'] = 'sometimes|required'; $result['service_region'] = 'sometimes|required';
$result['container_name'] = 'sometimes|required'; $result['container_name'] = 'sometimes|required';
break; 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; break;
@ -103,6 +115,18 @@ class StoreStorageRequest extends FormRequest
$result['service_region'] = 'sometimes|required'; $result['service_region'] = 'sometimes|required';
$result['container_name'] = 'sometimes|required'; $result['container_name'] = 'sometimes|required';
break; 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; break;
} }

View File

@ -0,0 +1,157 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
class DropboxService
{
/**
* @var string
*/
private $accessToken;
/**
* Configuration related to the Backblaze B2 service.
* @var \Illuminate\Config\Repository|mixed
*/
private $config;
public function __construct()
{
$this->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;
}
}

View File

@ -31,7 +31,8 @@ class Storage extends Model
'cdn_url', 'cdn_url',
'access_key', 'access_key',
'secret_key', 'secret_key',
'b2_bucket_type' 'b2_bucket_type',
'access_token'
]; ];
public function albums() public function albums()

View File

@ -19,6 +19,11 @@ return [
'download_token_lifetime' => 300 'download_token_lifetime' => 300
], ],
'dropbox' => [
'download_url' => 'https://content.dropboxapi.com/2/files/download',
'upload_url' => 'https://content.dropboxapi.com/2/files/upload'
],
'gitea' => [ 'gitea' => [
'api_url' => 'https://apps.andysh.uk/api/v1', 'api_url' => 'https://apps.andysh.uk/api/v1',
'cache_time_seconds' => 3600, 'cache_time_seconds' => 3600,

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddStorageAccessTokenColumn extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('storages', function (Blueprint $table) {
$table->text('access_token')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('storages', function (Blueprint $table) {
$table->dropColumn('access_token');
});
}
}

View File

@ -96,10 +96,12 @@ return [
'settings_social_user_profiles' => 'Enable public user profiles', '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.', '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_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_active_label' => 'Location is active. Uncheck to prevent creating new albums in this location.',
'storage_api_key_label' => 'API key:', 'storage_api_key_label' => 'API key:',
'storage_application_key_id_label' => 'Application key ID:', 'storage_application_key_id_label' => 'Application key ID:',
'storage_application_key_label' => 'Application key:', 'storage_application_key_label' => 'Application key:',
'storage_application_secret_label' => 'Application secret:',
'storage_auth_url_label' => 'Authentication URL:', 'storage_auth_url_label' => 'Authentication URL:',
'storage_b2_bucket_type' => [ 'storage_b2_bucket_type' => [
'autodetect' => 'Auto-detect', 'autodetect' => 'Auto-detect',

View File

@ -3,6 +3,7 @@ return [
'album_sources' => [ 'album_sources' => [
'amazon_s3' => 'Amazon S3 (or S3-compatible)', 'amazon_s3' => 'Amazon S3 (or S3-compatible)',
'backblaze_b2' => 'Backblaze B2 Cloud', 'backblaze_b2' => 'Backblaze B2 Cloud',
'dropbox' => 'Dropbox',
'filesystem' => 'Local filesystem', 'filesystem' => 'Local filesystem',
'openstack' => 'OpenStack cloud storage', 'openstack' => 'OpenStack cloud storage',
'rackspace' => 'Rackspace cloud storage' 'rackspace' => 'Rackspace cloud storage'

View File

@ -67,6 +67,10 @@
@include(Theme::viewName('partials.admin_storages_backblaze_b2_options')) @include(Theme::viewName('partials.admin_storages_backblaze_b2_options'))
</div> </div>
<div v-if="storage_driver == 'DropboxSource'">
@include(Theme::viewName('partials.admin_storages_dropbox_options'))
</div>
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" id="is-default" name="is_default"@if (old('is_default', $storage->is_default)) checked="checked"@endif> <input type="checkbox" class="form-check-input" id="is-default" name="is_default"@if (old('is_default', $storage->is_default)) checked="checked"@endif>
<label class="form-check-label" for="is-default">@lang('forms.default_storage_label')</label> <label class="form-check-label" for="is-default">@lang('forms.default_storage_label')</label>

View File

@ -61,10 +61,15 @@
@include(Theme::viewName('partials.admin_storages_rackspace_options')) @include(Theme::viewName('partials.admin_storages_rackspace_options'))
@endif @endif
<div v-if="storage_driver == 'BackblazeB2Source'"> @if ($storage->source == 'BackblazeB2Source')
<hr/> <hr/>
@include(Theme::viewName('partials.admin_storages_backblaze_b2_options')) @include(Theme::viewName('partials.admin_storages_backblaze_b2_options'))
</div> @endif
@if ($storage->source == 'DropboxSource')
<hr/>
@include(Theme::viewName('partials.admin_storages_dropbox_options'))
@endif
<div class="text-right"> <div class="text-right">
<a href="{{ route('storage.index') }}" class="btn btn-default">@lang('forms.cancel_action')</a> <a href="{{ route('storage.index') }}" class="btn btn-default">@lang('forms.cancel_action')</a>

View File

@ -0,0 +1,41 @@
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label class="form-control-label" for="access-key">@lang('forms.storage_application_key_label')</label>
<input type="text" class="form-control{{ $errors->has('access_key') ? ' is-invalid' : '' }}" id="access-key" name="access_key" value="{{ old('access_key', $storage->access_key) }}">
@if ($errors->has('access_key'))
<div class="invalid-feedback">
<strong>{{ $errors->first('access_key') }}</strong>
</div>
@endif
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label class="form-control-label" for="secret-key">@lang('forms.storage_application_secret_label')</label>
<input type="text" class="form-control{{ $errors->has('secret_key') ? ' is-invalid' : '' }}" id="secret-key" name="secret_key" value="{{ old('secret_key', $storage->secret_key) }}">
@if ($errors->has('secret_key'))
<div class="invalid-feedback">
<strong>{{ $errors->first('secret_key') }}</strong>
</div>
@endif
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label class="form-control-label" for="access-token">@lang('forms.storage_access_token_label')</label>
<input type="text" class="form-control{{ $errors->has('access_token') ? ' is-invalid' : '' }}" id="access-token" name="access_token" value="{{ old('access_token', $storage->access_token) }}">
@if ($errors->has('access_key'))
<div class="invalid-feedback">
<strong>{{ $errors->first('access_token') }}</strong>
</div>
@endif
</div>
</div>
</div>