Added the ability to create, edit and remove external services. Implemented an OAuth2 flow for authentication to Dropbox. #106

This commit is contained in:
Andy Heathershaw 2020-04-20 22:33:42 +01:00
parent 09b4bc60dd
commit d97b790264
27 changed files with 838 additions and 64 deletions

View File

@ -6,18 +6,36 @@ use Illuminate\Database\Eloquent\Model;
class ExternalService extends Model class ExternalService extends Model
{ {
public const DROPBOX = 'Dropbox'; public const DROPBOX = 'dropbox';
public const FACEBOOK = 'Facebook'; public const FACEBOOK = 'facebook';
public const GOOGLE = 'Google'; public const GOOGLE = 'google';
public const TWITTER = 'Twitter'; public const TWITTER = 'twitter';
/** /**
* Gets the details for the given service type. * The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = ['name', 'service_type'];
/**
* Gets all possible service configurations for the given service type.
* @param $serviceType * @param $serviceType
* @return ExternalService * @return ExternalService[]
*/ */
public static function getForService($serviceType) public static function getForService($serviceType)
{ {
return ExternalService::where('service_type', $serviceType)->first(); return ExternalService::where('service_type', $serviceType)->get();
}
public function hasOAuthStandardOptions()
{
// This list must be mirrored in external_services.js
return in_array($this->service_type, [
self::DROPBOX,
self::FACEBOOK,
self::GOOGLE,
self::TWITTER
]);
} }
} }

View File

@ -2,9 +2,239 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\ExternalService;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\StoreServiceRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\View;
class ServiceController extends Controller class ServiceController extends Controller
{ {
/**
* List of fields that must be encrypted before being saved.
*
* @var string[]
*/
private $fieldsToEncrypt;
/**
* List of fields that depend on the service_type being configured.
*
* @var string[]
*/
private $serviceTypeDependentFields;
public function __construct()
{
$this->middleware('auth');
View::share('is_admin', true);
$this->serviceTypeDependentFields = ['app_id', 'app_secret'];
$this->fieldsToEncrypt = ['app_id', 'app_secret'];
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
$this->authorizeAccessToAdminPanel('admin:manage-services');
return Theme::render('admin.create_service', [
'service' => new ExternalService(),
'serviceTypes' => $this->serviceTypeList()
]);
}
public function delete(Request $request, $id)
{
$this->authorizeAccessToAdminPanel('admin:manage-users');
$service = ExternalService::where('id', intval($id))->first();
if (is_null($service))
{
App::abort(404);
}
if ($this->isServiceInUse($service))
{
$request->session()->flash('warning', trans('admin.cannot_delete_service_in_use'));
return redirect(route('services.index'));
}
return Theme::render('admin.delete_service', ['service' => $service]);
}
/**
* Remove the specified resource from storage.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy(Request $request, $id)
{
$this->authorizeAccessToAdminPanel('admin:manage-services');
$service = ExternalService::where('id', intval($id))->first();
if (is_null($service))
{
App::abort(404);
}
if ($this->isServiceInUse($service))
{
$request->session()->flash('warning', trans('admin.cannot_delete_service_in_use'));
return redirect(route('services.index'));
}
try
{
$service->delete();
$request->session()->flash('success', trans('admin.service_deletion_successful', [
'name' => $service->name
]));
}
catch (\Exception $ex)
{
$request->session()->flash('error', trans('admin.service_deletion_failed', [
'error_message' => $ex->getMessage(),
'name' => $service->name
]));
}
return redirect(route('services.index'));
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function edit(Request $request, $id)
{
$this->authorizeAccessToAdminPanel('admin:manage-services');
$service = ExternalService::where('id', intval($id))->first();
if (is_null($service))
{
App::abort(404);
}
// Decrypt the fields that are stored as encrypted in the DB
foreach ($this->fieldsToEncrypt as $field)
{
if (!empty($service->$field))
{
$service->$field = decrypt($service->$field);
}
}
return Theme::render('admin.edit_service', [
'service' => $service,
'serviceTypes' => $this->serviceTypeList()
]);
}
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index(Request $request)
{
$this->authorizeAccessToAdminPanel('admin:manage-services');
$services = ExternalService::orderBy('name')
->paginate(UserConfig::get('items_per_page'));
return Theme::render('admin.list_services', [
'error' => $request->session()->get('error'),
'services' => $services,
'success' => $request->session()->get('success'),
'warning' => $request->session()->get('warning')
]);
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(StoreServiceRequest $request)
{
$this->authorizeAccessToAdminPanel('admin:manage-services');
$service = new ExternalService($request->only(['name', 'service_type']));
foreach ($this->serviceTypeDependentFields as $field)
{
if ($request->has($field))
{
$service->$field = in_array($field, $this->fieldsToEncrypt)
? encrypt($request->get($field))
: $request->get($field);
}
}
$service->save();
return redirect(route('services.index'));
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\Response
*/
public function update(StoreServiceRequest $request, $id)
{
$this->authorizeAccessToAdminPanel('admin:manage-services');
$service = ExternalService::where('id', intval($id))->first();
if (is_null($service))
{
App::abort(404);
}
$service->fill($request->only(['name', 'service_type']));
foreach ($this->serviceTypeDependentFields as $field)
{
if ($request->has($field))
{
$service->$field = in_array($field, $this->fieldsToEncrypt)
? encrypt($request->get($field))
: $request->get($field);
}
}
$service->save();
return redirect(route('services.index'));
}
private function isServiceInUse(ExternalService $service)
{
// TODO check if the service is in use anywhere else and prevent it being deleted if so
return false;
}
private function serviceTypeList()
{
return [
ExternalService::DROPBOX => trans(sprintf('services.%s', ExternalService::DROPBOX)),
ExternalService::FACEBOOK => trans(sprintf('services.%s', ExternalService::FACEBOOK)),
ExternalService::GOOGLE => trans(sprintf('services.%s', ExternalService::GOOGLE)),
ExternalService::TWITTER => trans(sprintf('services.%s', ExternalService::TWITTER))
];
}
} }

View File

@ -2,10 +2,12 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use App\ExternalService;
use App\Facade\Theme; use App\Facade\Theme;
use App\Facade\UserConfig; use App\Facade\UserConfig;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests; use App\Http\Requests;
use App\Services\DropboxService;
use App\Storage; use App\Storage;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
@ -26,6 +28,59 @@ class StorageController extends Controller
$this->encryptedFields = ['password', 'access_key', 'secret_key', 'access_token']; $this->encryptedFields = ['password', 'access_key', 'secret_key', 'access_token'];
} }
public function authoriseService($id)
{
$this->authorizeAccessToAdminPanel('admin:manage-storage');
$storage = Storage::where('id', intval($id))->first();
if (is_null($storage))
{
App::abort(404);
}
if (is_null($storage->externalService))
{
App::abort(400, 'Storage does not support an external service');
}
switch ($storage->externalService->service_type)
{
case ExternalService::DROPBOX:
$dropbox = new DropboxService();
return redirect($dropbox->authoriseUrl($storage));
default:
App::abort(400, 'External service does not support authorisation');
}
}
public function completeServiceAuthorisation(Request $request, $id)
{
$this->authorizeAccessToAdminPanel('admin:manage-storage');
$storage = Storage::where('id', intval($id))->first();
if (is_null($storage))
{
App::abort(404);
}
if (is_null($storage->externalService))
{
App::abort(400, 'Storage does not support an external service');
}
switch ($storage->externalService->service_type)
{
case ExternalService::DROPBOX:
$dropbox = new DropboxService();
$dropbox->handleAuthenticationResponse($request, $storage);
return redirect(route('storage.index'));
default:
App::abort(400, 'External service does not support authorisation');
}
}
/** /**
* Display a listing of the resource. * Display a listing of the resource.
* *
@ -60,6 +115,7 @@ class StorageController extends Controller
return Theme::render('admin.create_storage', [ return Theme::render('admin.create_storage', [
'album_sources' => UserConfig::albumSources(), 'album_sources' => UserConfig::albumSources(),
'dropbox_services' => ExternalService::getForService(ExternalService::DROPBOX),
'filesystem_default_location' => $filesystemDefaultLocation, 'filesystem_default_location' => $filesystemDefaultLocation,
'info' => $request->session()->get('info'), 'info' => $request->session()->get('info'),
'storage' => $storage 'storage' => $storage
@ -92,7 +148,7 @@ class StorageController extends Controller
'access_key', 'access_key',
'secret_key', 'secret_key',
'b2_bucket_type', 'b2_bucket_type',
'access_token' 'external_service_id'
])); ]));
$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');
@ -191,7 +247,10 @@ class StorageController extends Controller
} }
} }
return Theme::render('admin.edit_storage', ['storage' => $storage]); return Theme::render('admin.edit_storage', [
'dropbox_services' => ExternalService::getForService(ExternalService::DROPBOX),
'storage' => $storage
]);
} }
/** /**
@ -224,8 +283,7 @@ class StorageController extends Controller
'access_key', 'access_key',
'secret_key', 'secret_key',
'b2_bucket_type', 'b2_bucket_type',
'access_token', 'external_service_id'
's3_signed_urls'
])); ]));
$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

@ -0,0 +1,74 @@
<?php
namespace App\Http\Requests;
use App\ExternalService;
use Illuminate\Foundation\Http\FormRequest;
class StoreServiceRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
$result = [];
switch ($this->method())
{
case 'POST':
$result = [
'name' => 'required|unique:external_services|max:255',
'service_type' => 'required|max:255',
];
switch ($this->get('service_type'))
{
case ExternalService::DROPBOX:
case ExternalService::FACEBOOK:
case ExternalService::GOOGLE:
case ExternalService::TWITTER:
// Standard OAuth services
$result['app_id'] = 'sometimes|required';
$result['app_secret'] = 'sometimes|required';
break;
}
break;
case 'PATCH':
case 'PUT':
$serviceId = intval($this->segment(3));
$service = ExternalService::find($serviceId);
$result = [
'name' => 'required|max:255|unique:external_services,name,' . $serviceId
];
switch ($service->service_type)
{
case ExternalService::DROPBOX:
case ExternalService::FACEBOOK:
case ExternalService::GOOGLE:
case ExternalService::TWITTER:
// Standard OAuth services
$result['app_id'] = 'sometimes|required';
$result['app_secret'] = 'sometimes|required';
break;
}
break;
}
return $result;
}
}

View File

@ -73,9 +73,7 @@ class StoreStorageRequest extends FormRequest
break; break;
case 'DropboxSource': case 'DropboxSource':
$result['access_key'] = 'sometimes|required'; $result['external_service_id'] = 'sometimes|required';
$result['secret_key'] = 'sometimes|required';
$result['access_token'] = 'sometimes|required';
break; break;
} }
break; break;
@ -123,9 +121,7 @@ class StoreStorageRequest extends FormRequest
break; break;
case 'DropboxSource': case 'DropboxSource':
$result['access_key'] = 'sometimes|required'; $result['external_service_id'] = 'sometimes|required';
$result['secret_key'] = 'sometimes|required';
$result['access_token'] = 'sometimes|required';
break; break;
} }
break; break;

View File

@ -2,6 +2,8 @@
namespace App\Services; namespace App\Services;
use App\Storage;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class DropboxService class DropboxService
@ -22,6 +24,19 @@ class DropboxService
$this->config = config('services.dropbox'); $this->config = config('services.dropbox');
} }
public function authoriseUrl(Storage $storage)
{
$service = $storage->externalService;
$redirectUrl = route('storage.completeServiceAuthorisation', ['storage' => $storage->id]);
return sprintf(
'%s?client_id=%s&response_type=code&redirect_uri=%s',
$this->config['authorise_url'],
urlencode(decrypt($service->app_id)),
urlencode($redirectUrl)
);
}
public function downloadFile($pathOnStorage) public function downloadFile($pathOnStorage)
{ {
$dropboxArgs = ['path' => $pathOnStorage]; $dropboxArgs = ['path' => $pathOnStorage];
@ -41,6 +56,16 @@ class DropboxService
); );
} }
public function handleAuthenticationResponse(Request $request, Storage $storage)
{
$authorisationCode = $request->query('code');
$storage->access_token = encrypt($this->convertAuthorisationCodeToToken($authorisationCode, $storage));
$storage->save();
return true;
}
/** /**
* @param string $accessToken * @param string $accessToken
*/ */
@ -71,6 +96,40 @@ class DropboxService
); );
} }
private function convertAuthorisationCodeToToken($authorisationCode, Storage $storage)
{
$service = $storage->externalService;
$credentials = sprintf('%s:%s', decrypt($service->app_id), decrypt($service->app_secret));
$redirectUrl = route('storage.completeServiceAuthorisation', ['storage' => $storage->id]);
$httpHeaders = [
'Accept: application/json',
sprintf('Authorization: Basic %s', base64_encode($credentials))
];
$ch = curl_init($this->config['token_url']);
curl_setopt($ch, CURLOPT_HTTPHEADER, $httpHeaders);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, [
'code' => $authorisationCode,
'grant_type' => 'authorization_code',
'redirect_uri' => $redirectUrl
]);
$response = json_decode(curl_exec($ch));
if (is_null($response) || $response === false)
{
throw new \Exception('Unable to read the response from Dropbox');
}
else if (isset($response->error_description))
{
throw new \Exception(sprintf('Error from Dropbox: %s', $response->error_description));
}
return $response->access_token;
}
private function getBasicHttpClient($url, $method = 'GET', array $httpHeaders = []) private function getBasicHttpClient($url, $method = 'GET', array $httpHeaders = [])
{ {
$httpHeaders = array_merge( $httpHeaders = array_merge(

View File

@ -32,11 +32,16 @@ class Storage extends Model
'access_key', 'access_key',
'secret_key', 'secret_key',
'b2_bucket_type', 'b2_bucket_type',
'access_token' 'external_service_id'
]; ];
public function albums() public function albums()
{ {
return $this->hasMany(Album::class); return $this->hasMany(Album::class);
} }
public function externalService()
{
return $this->belongsTo(ExternalService::class);
}
} }

View File

@ -1,5 +1,5 @@
{ {
"name": "pandy06269/blue-twilight", "name": "aheathershaw/blue-twilight",
"description": "Blue Twilight - self-hosted photo gallery software.", "description": "Blue Twilight - self-hosted photo gallery software.",
"keywords": ["blue", "twilight", "photo", "photograph", "portfolio", "gallery", "self-hosted"], "keywords": ["blue", "twilight", "photo", "photograph", "portfolio", "gallery", "self-hosted"],
"license": "MIT", "license": "MIT",

View File

@ -20,7 +20,9 @@ return [
], ],
'dropbox' => [ 'dropbox' => [
'authorise_url' => 'https://www.dropbox.com/oauth2/authorize',
'download_url' => 'https://content.dropboxapi.com/2/files/download', 'download_url' => 'https://content.dropboxapi.com/2/files/download',
'token_url' => 'https://api.dropbox.com/oauth2/token',
'upload_url' => 'https://content.dropboxapi.com/2/files/upload' 'upload_url' => 'https://content.dropboxapi.com/2/files/upload'
], ],

View File

@ -16,6 +16,7 @@ class CreateExternalServicesTable extends Migration
Schema::create('external_services', function (Blueprint $table) { Schema::create('external_services', function (Blueprint $table) {
$table->increments('id'); $table->increments('id');
$table->string('service_type', 50); $table->string('service_type', 50);
$table->string('name');
$table->text('app_id')->nullable(); $table->text('app_id')->nullable();
$table->text('app_secret')->nullable(); $table->text('app_secret')->nullable();
$table->timestamps(); $table->timestamps();

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddServiceToStoragesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('storages', function (Blueprint $table) {
$table->unsignedInteger('external_service_id')->nullable();
$table->foreign('external_service_id')
->references('id')
->on('external_services');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('storages', function (Blueprint $table) {
$table->dropForeign('storages_external_service_id_foreign');
$table->dropColumn('external_service_id');
});
}
}

View File

@ -0,0 +1,17 @@
function ExternalServiceViewModel()
{
this.el = '#external-service-options';
this.data = {
service_type: ''
};
this.computed = {
hasOAuthStandardOptions()
{
// This list must be mirrored in App\ExternalService
return this.service_type === 'dropbox' ||
this.service_type === 'facebook' ||
this.service_type === 'google' ||
this.service_type === 'twitter';
}
}
}

View File

@ -68,6 +68,7 @@ return [
'bulk_photos_changed' => ':number photo was updated successfully.|:number photos were updated successfully.', 'bulk_photos_changed' => ':number photo was updated successfully.|:number photos were updated successfully.',
'bulk_photos_changed_queued' => 'Your requested change has been queued. :number photo will be updated shortly.|Your requested change has been queued. :number photos will be updated shortly.', 'bulk_photos_changed_queued' => 'Your requested change has been queued. :number photo will be updated shortly.|Your requested change has been queued. :number photos will be updated shortly.',
'cannot_delete_own_user_account' => 'It is not possible to delete your own user account. Please ask another administrator to delete it for you.', 'cannot_delete_own_user_account' => 'It is not possible to delete your own user account. Please ask another administrator to delete it for you.',
'cannot_delete_service_in_use' => 'This service is still in use and cannot be deleted. Please remove any references to it in your configuration and try again.',
'cannot_remove_own_admin' => 'You cannot remove your own administrator permissions. Please ask another administrator to remove the administrator permissions for you.', 'cannot_remove_own_admin' => 'You cannot remove your own administrator permissions. Please ask another administrator to remove the administrator permissions for you.',
'change_album_message' => 'Please select the album to move the photo(s) to:', 'change_album_message' => 'Please select the album to move the photo(s) to:',
'change_album_title' => 'Move photo(s) to another album', 'change_album_title' => 'Move photo(s) to another album',
@ -91,6 +92,9 @@ return [
'create_redirect_heading' => 'Add a Redirect', 'create_redirect_heading' => 'Add a Redirect',
'create_redirect_success_message' => 'The redirect was added successfully.', 'create_redirect_success_message' => 'The redirect was added successfully.',
'create_redirect_text' => 'Enter the source address you would like to redirect and click the Create button to add a new redirect to this album.', 'create_redirect_text' => 'Enter the source address you would like to redirect and click the Create button to add a new redirect to this album.',
'create_service' => 'Create service',
'create_service_intro' => 'You can use the form below to create a service. Your service provider (e.g. Facebook, Twitter, Dropbox) will provide you with the details required.',
'create_service_title' => 'Create a service',
'create_storage' => 'Create storage location', 'create_storage' => 'Create storage location',
'create_storage_intro' => 'Complete the form below to create a new storage location to hold your photos. You can then select this storage location when you create an album.', 'create_storage_intro' => 'Complete the form below to create a new storage location to hold your photos. You can then select this storage location when you create an album.',
'create_user' => 'Create user', 'create_user' => 'Create user',
@ -129,6 +133,9 @@ return [
'delete_redirect_confirm_message' => 'Are you sure you want to remove this redirect?', 'delete_redirect_confirm_message' => 'Are you sure you want to remove this redirect?',
'delete_redirect_confirm_title' => 'Delete Album Redirect', 'delete_redirect_confirm_title' => 'Delete Album Redirect',
'delete_redirect_success_message' => 'The redirect was deleted successfully.', 'delete_redirect_success_message' => 'The redirect was deleted successfully.',
'delete_service' => 'Delete service: :name',
'delete_service_confirm' => 'Are you sure you want to permanently remove the :name service?',
'delete_service_warning' => 'This is a permanent action that cannot be reversed!',
'delete_storage' => 'Delete storage location: :name', 'delete_storage' => 'Delete storage location: :name',
'delete_storage_confirm' => 'Are you sure you want to permanently remove this storage location?', 'delete_storage_confirm' => 'Are you sure you want to permanently remove this storage location?',
'delete_storage_existing_albums' => 'At least one album is still using the storage location. Please delete all albums before removing the storage location.', 'delete_storage_existing_albums' => 'At least one album is still using the storage location. Please delete all albums before removing the storage location.',
@ -145,6 +152,8 @@ return [
'edit_album_intro2' => 'Complete the form below to edit the properties of the album: :album_name.', 'edit_album_intro2' => 'Complete the form below to edit the properties of the album: :album_name.',
'edit_group_intro' => 'You can use the form below to edit the above group. Changes take effect immediately.', 'edit_group_intro' => 'You can use the form below to edit the above group. Changes take effect immediately.',
'edit_group_title' => 'Edit group: :group_name', 'edit_group_title' => 'Edit group: :group_name',
'edit_service_intro' => 'You can use the form below to edit the above services. Changes take effect the next time Blue Twilight accesses the service.',
'edit_service_title' => 'Edit service: :name',
'edit_storage' => 'Edit storage location: :storage_name', 'edit_storage' => 'Edit storage location: :storage_name',
'edit_storage_intro' => 'Use the form below to update the details of the :storage_name storage location.', 'edit_storage_intro' => 'Use the form below to update the details of the :storage_name storage location.',
'edit_user_intro' => 'You can use the form below to edit the above user account. Changes take effect immediately.', 'edit_user_intro' => 'You can use the form below to edit the above user account. Changes take effect immediately.',
@ -177,6 +186,8 @@ return [
'list_groups_title' => 'Groups', 'list_groups_title' => 'Groups',
'list_labels_intro' => 'Organise your photos differently using labels. Assign one or more labels to your photos and your visitors can view all photos with a specific tag in a single view.', 'list_labels_intro' => 'Organise your photos differently using labels. Assign one or more labels to your photos and your visitors can view all photos with a specific tag in a single view.',
'list_labels_title' => 'Labels', 'list_labels_title' => 'Labels',
'list_services_intro' => 'External services link your Blue Twilight application to other providers such as Facebook, Twitter and Dropbox. Configure your application\'s credentials for these services here.',
'list_services_title' => 'Services',
'list_storages_intro' => 'Storage locations specify the physical location where your photograph files are held. This may be on your local server\'s filesystem, or on a cloud storage provider such as Rackspace or Amazon S3.', 'list_storages_intro' => 'Storage locations specify the physical location where your photograph files are held. This may be on your local server\'s filesystem, or on a cloud storage provider such as Rackspace or Amazon S3.',
'list_storages_title' => 'Storage Locations', 'list_storages_title' => 'Storage Locations',
'list_users_intro' => 'User accounts allow people to login to your gallery to manage your albums. If you have disabled self-registration, you can create user accounts here to allow people to login.', 'list_users_intro' => 'User accounts allow people to login to your gallery to manage your albums. If you have disabled self-registration, you can create user accounts here to allow people to login.',
@ -212,6 +223,8 @@ return [
'no_labels_text' => 'You have no labels yet. Use the form below to create one.', 'no_labels_text' => 'You have no labels yet. Use the form below to create one.',
'no_labels_title' => 'No Labels', 'no_labels_title' => 'No Labels',
'no_photo_selected_message' => 'Please select at least one photo.', 'no_photo_selected_message' => 'Please select at least one photo.',
'no_services_text' => 'You have no services yet. Click the Create button to add connection details to an external service such as Facebook or Dropbox.',
'no_services_title' => 'No External Services',
'no_storages_text' => 'You need a storage location to store your uploaded photographs.', 'no_storages_text' => 'You need a storage location to store your uploaded photographs.',
'no_storages_text2' => 'This can be on your server\'s local filesystem or a cloud location such as Amazon S3 or Rackspace.', 'no_storages_text2' => 'This can be on your server\'s local filesystem or a cloud location such as Amazon S3 or Rackspace.',
'no_storages_title' => 'No storage locations defined', 'no_storages_title' => 'No storage locations defined',
@ -256,6 +269,9 @@ return [
'visible_action' => 'Only those visible' 'visible_action' => 'Only those visible'
], ],
'select_none_action' => 'Clear selection', 'select_none_action' => 'Clear selection',
'service_deletion_failed' => 'An error occurred while removing the :name service: :error_message',
'service_deletion_successful' => 'The :name service was removed successfully.',
'services_title' => 'External services',
'settings' => [ 'settings' => [
'albums_menu_heading' => 'Albums Navigation Menu', 'albums_menu_heading' => 'Albums Navigation Menu',
'albums_menu_number_items' => 'Number of albums to display:', 'albums_menu_number_items' => 'Number of albums to display:',
@ -324,6 +340,9 @@ return [
'users' => 'user|users', 'users' => 'user|users',
], ],
'storage_auth_url_label_help' => 'Leave blank to authenticate with Amazon S3. For an S3-compatible provider, enter your provider\'s authentication URL here.', 'storage_auth_url_label_help' => 'Leave blank to authenticate with Amazon S3. For an S3-compatible provider, enter your provider\'s authentication URL here.',
'storage_authorise_external_service_authorised' => 'Authorised',
'storage_authorise_external_service_refresh_authentication' => 'Refresh authentication',
'storage_authorise_external_service_required' => 'Authorisation required',
'storage_backblaze_access_key_id_help' => 'To use your account\'s master key, enter your account ID here.', 'storage_backblaze_access_key_id_help' => 'To use your account\'s master key, enter your account ID here.',
'storage_s3_signed_urls_help' => 'When enabled, Blue Twilight will upload your photos with a private ACL and will use signed URLs to display the photos to your visitors.', 'storage_s3_signed_urls_help' => 'When enabled, Blue Twilight will upload your photos with a private ACL and will use signed URLs to display the photos to your visitors.',
'storage_s3_signed_urls_tooltip' => 'This location is set to use private images with signed URLs.', 'storage_s3_signed_urls_tooltip' => 'This location is set to use private images with signed URLs.',

View File

@ -57,8 +57,11 @@ return [
'remove_action' => 'Remove', 'remove_action' => 'Remove',
'review_photo_comment_action' => 'Approve/reject comment', 'review_photo_comment_action' => 'Approve/reject comment',
'save_action' => 'Save Changes', 'save_action' => 'Save Changes',
'service_type_label' => 'Type of service:',
'select' => 'Select', 'select' => 'Select',
'select_current_text' => '(current)', 'select_current_text' => '(current)',
'service_app_id_label' => 'Application ID / client ID:',
'service_app_secret_label' => 'Application ID / client secret:',
'settings_allow_photo_comments' => 'Allow comments on photos', 'settings_allow_photo_comments' => 'Allow comments on photos',
'settings_allow_photo_comments_anonymous' => 'Allow anonymous users to comment on photos', 'settings_allow_photo_comments_anonymous' => 'Allow anonymous users to comment on photos',
'settings_allow_photo_comments_anonymous_help' => 'With this option enabled, users can post comments without being logged in.', 'settings_allow_photo_comments_anonymous_help' => 'With this option enabled, users can post comments without being logged in.',
@ -114,6 +117,7 @@ return [
'storage_container_name_label' => 'Container name:', 'storage_container_name_label' => 'Container name:',
'storage_driver_label' => 'Storage driver:', 'storage_driver_label' => 'Storage driver:',
'storage_endpoint_url_label' => 'Endpoint URL (leave blank if using Amazon):', 'storage_endpoint_url_label' => 'Endpoint URL (leave blank if using Amazon):',
'storage_external_service_label' => 'Service:',
'storage_location_label' => 'Physical location:', 'storage_location_label' => 'Physical location:',
'storage_s3_signed_urls' => 'Upload files privately and use signed URLs', 'storage_s3_signed_urls' => 'Upload files privately and use signed URLs',
'storage_secret_key_label' => 'Secret key:', 'storage_secret_key_label' => 'Secret key:',

View File

@ -9,6 +9,7 @@ return [
'comments' => 'Comments', 'comments' => 'Comments',
'create_album' => 'Create album', 'create_album' => 'Create album',
'create_group' => 'Create group', 'create_group' => 'Create group',
'create_service' => 'Create service',
'create_storage' => 'Create storage', 'create_storage' => 'Create storage',
'create_user' => 'Create user', 'create_user' => 'Create user',
'default_album_permissions' => 'Default album permissions', 'default_album_permissions' => 'Default album permissions',
@ -16,10 +17,12 @@ return [
'delete_comment' => 'Delete comment', 'delete_comment' => 'Delete comment',
'delete_group' => 'Delete group', 'delete_group' => 'Delete group',
'delete_label' => 'Delete label', 'delete_label' => 'Delete label',
'delete_service' => 'Delete service',
'delete_storage' => 'Delete storage location', 'delete_storage' => 'Delete storage location',
'delete_user' => 'Delete user', 'delete_user' => 'Delete user',
'edit_album' => 'Edit album', 'edit_album' => 'Edit album',
'edit_group' => 'Edit group', 'edit_group' => 'Edit group',
'edit_service' => 'Edit service',
'edit_storage' => 'Edit storage location', 'edit_storage' => 'Edit storage location',
'edit_user' => 'Edit user', 'edit_user' => 'Edit user',
'exif_data' => 'Exif Data', 'exif_data' => 'Exif Data',

View File

@ -0,0 +1,7 @@
<?php
return [
'dropbox' => 'Dropbox',
'facebook' => 'Facebook',
'google' => 'Google',
'twitter' => 'Twitter'
];

View File

@ -0,0 +1,76 @@
@extends(Theme::viewName('layout'))
@section('title', trans('admin.create_service'))
@section('breadcrumb')
<li class="breadcrumb-item"><a href="{{ route('home') }}"><i class="fa fa-fw fa-home"></i></a></li>
<li class="breadcrumb-item"><a href="{{ route('admin') }}">@lang('navigation.breadcrumb.admin')</a></li>
<li class="breadcrumb-item"><a href="{{ route('services.index') }}">@lang('navigation.breadcrumb.services')</a></li>
<li class="breadcrumb-item active">@lang('navigation.breadcrumb.create_service')</li>
@endsection
@section('content')
<div class="container">
<div class="row">
<div class="col">
<h1>@lang('admin.create_service_title')</h1>
<p>@lang('admin.create_service_intro')</p>
<hr/>
<form action="{{ route('services.store') }}" method="post" id="external-service-options">
{{ csrf_field() }}
<div class="form-group">
<label class="form-control-label" for="service-type">@lang('forms.service_type_label')</label>
<select class="form-control{{ $errors->has('service_type') ? ' is-invalid' : '' }}" id="service-type" name="service_type" value="{{ old('service_type') }}" v-model="service_type">
<option value="">@lang('forms.please_select')</option>
@foreach ($serviceTypes as $serviceTypeKey => $serviceTypeDescription)
<option value="{{ $serviceTypeKey }}">{{ $serviceTypeDescription }}</option>
@endforeach
</select>
@if ($errors->has('service_type'))
<div class="invalid-feedback">
<strong>{{ $errors->first('service_type') }}</strong>
</div>
@endif
</div>
<div class="form-group">
<label class="form-control-label" for="service-name">@lang('forms.name_label')</label>
<input type="text" class="form-control{{ $errors->has('name') ? ' is-invalid' : '' }}" id="service-name" name="name" value="{{ old('name') }}" />
@if ($errors->has('name'))
<div class="invalid-feedback">
<strong>{{ $errors->first('name') }}</strong>
</div>
@endif
</div>
<div v-if="hasOAuthStandardOptions">
@include(Theme::viewName('partials.admin_services_oauth_options'))
</div>
<div class="text-right">
<a href="{{ route('services.index') }}" class="btn btn-link">@lang('forms.cancel_action')</a>
<button type="submit" class="btn btn-success"><i class="fa fa-fw fa-check"></i> @lang('forms.create_action')</button>
</div>
</form>
</div>
</div>
</div>
@endsection
@push('scripts')
<script type="text/javascript">
$(document).ready(function()
{
var viewModel = new ExternalServiceViewModel();
var app = new Vue(viewModel);
@if (strlen(old('service_type')) > 0)
app.service_type = '{{ old('service_type') }}';
@endif
});
</script>
@endpush

View File

@ -0,0 +1,34 @@
@extends(Theme::viewName('layout'))
@section('title', trans('admin.delete_service', ['name' => $service->name]))
@section('breadcrumb')
<li class="breadcrumb-item"><a href="{{ route('home') }}"><i class="fa fa-fw fa-home"></i></a></li>
<li class="breadcrumb-item"><a href="{{ route('admin') }}">@lang('navigation.breadcrumb.admin')</a></li>
<li class="breadcrumb-item"><a href="{{ route('services.index') }}">@lang('navigation.breadcrumb.services')</a></li>
<li class="breadcrumb-item active">@lang('navigation.breadcrumb.delete_service')</li>
@endsection
@section('content')
<div class="container">
<div class="row">
<div class="col-md-8 ml-md-auto mr-md-auto">
<div class="card bg-danger">
<div class="card-header text-white">@yield('title')</div>
<div class="card-body bg-light">
<p>@lang('admin.delete_service_confirm', ['name' => $service->name])</p>
<p class="text-danger"><b>@lang('admin.delete_service_warning')</b></p>
<div class="text-right">
<form action="{{ route('services.destroy', [$service->id]) }}" method="post">
{{ csrf_field() }}
{{ method_field('DELETE') }}
<a href="{{ route('services.index') }}" class="btn btn-link">@lang('forms.cancel_action')</a>
<button type="submit" class="btn btn-danger"><i class="fa fa-fw fa-trash"></i> @lang('forms.delete_action')</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@ -0,0 +1,63 @@
@extends(Theme::viewName('layout'))
@section('title', trans('admin.edit_service_title', ['name' => $service->name]))
@section('breadcrumb')
<li class="breadcrumb-item"><a href="{{ route('home') }}"><i class="fa fa-fw fa-home"></i></a></li>
<li class="breadcrumb-item"><a href="{{ route('admin') }}">@lang('navigation.breadcrumb.admin')</a></li>
<li class="breadcrumb-item"><a href="{{ route('services.index') }}">@lang('navigation.breadcrumb.services')</a></li>
<li class="breadcrumb-item active">@lang('navigation.breadcrumb.edit_service')</li>
@endsection
@section('content')
<div class="container">
<div class="row">
<div class="col">
<h1>@yield('title')</h1>
<p>@lang('admin.edit_service_intro')</p>
<hr/>
<form action="{{ route('services.update', [$service->id]) }}" method="post">
{{ csrf_field() }}
{{ method_field('PUT') }}
<div class="form-group">
<label class="form-control-label" for="service-type">@lang('forms.service_type_label')</label>
<select class="form-control{{ $errors->has('service_type') ? ' is-invalid' : '' }}" id="service-type" name="service_type" value="{{ old('service_type', $service->service_type) }}">
<option value="">@lang('forms.please_select')</option>
@foreach ($serviceTypes as $serviceTypeKey => $serviceTypeDescription)
<option value="{{ $serviceTypeKey }}"@if ($service->service_type == $serviceTypeKey) selected="selected"@endif>{{ $serviceTypeDescription }}</option>
@endforeach
</select>
@if ($errors->has('service_type'))
<div class="invalid-feedback">
<strong>{{ $errors->first('service_type') }}</strong>
</div>
@endif
</div>
<div class="form-group">
<label class="form-control-label" for="service-name">@lang('forms.name_label')</label>
<input type="text" class="form-control{{ $errors->has('name') ? ' is-invalid' : '' }}" id="service-name" name="name" value="{{ old('name', $service->name) }}" />
@if ($errors->has('name'))
<div class="invalid-feedback">
<strong>{{ $errors->first('name') }}</strong>
</div>
@endif
</div>
@if ($service->hasOAuthStandardOptions())
@include(Theme::viewName('partials.admin_services_oauth_options'))
@endif
<div class="text-right" style="margin-top: 20px;">
<a href="{{ route('services.index') }}" class="btn btn-link">@lang('forms.cancel_action')</a>
<button type="submit" class="btn btn-success"><i class="fa fa-fw fa-check"></i> @lang('forms.save_action')</button>
</div>
</form>
</div>
</div>
</div>
@endsection

View File

@ -66,8 +66,13 @@
@include(Theme::viewName('partials.admin_storages_backblaze_b2_options')) @include(Theme::viewName('partials.admin_storages_backblaze_b2_options'))
@endif @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-link">@lang('forms.cancel_action')</a>
<button type="submit" class="btn btn-success"><i class="fa fa-fw fa-check"></i> @lang('forms.save_action')</button> <button type="submit" class="btn btn-success"><i class="fa fa-fw fa-check"></i> @lang('forms.save_action')</button>
</div> </div>
</form> </form>

View File

@ -0,0 +1,55 @@
@extends(Theme::viewName('layout'))
@section('title', trans('admin.services_title'))
@section('breadcrumb')
<li class="breadcrumb-item"><a href="{{ route('home') }}"><i class="fa fa-fw fa-home"></i></a></li>
<li class="breadcrumb-item"><a href="{{ route('admin') }}">@lang('navigation.breadcrumb.admin')</a></li>
<li class="breadcrumb-item active">@lang('navigation.breadcrumb.services')</li>
@endsection
@section('content')
<div class="container">
<div class="row">
<div class="col">
<h1>@lang('admin.list_services_title')</h1>
<div class="alert alert-info" style="margin-bottom: 30px;">
<i class="fa fa-fw fa-info"></i> @lang('admin.list_services_intro')
</div>
@if (count($services) == 0)
<div class="text-center mb-4">
<h4 class="text-danger"><b>@lang('admin.no_services_title')</b></h4>
<p>@lang('admin.no_services_text')</p>
</div>
@else
<table class="table table-hover table-striped">
<tbody>
@foreach ($services as $service)
<tr>
<td>
<span style="font-size: 1.3em;"><a href="{{ route('services.edit', ['service' => $service->id]) }}">{{ $service->name }}</a></span><br/>
{{ trans(sprintf('services.%s', $service->service_type)) }}
@if ($service->service_type == \App\ExternalService::DROPBOX)
&middot; {{ decrypt($service->app_id) }}
@endif
</td>
<td class="text-right">
<a href="{{ route('services.delete', ['service' => $service->id]) }}" class="btn btn-danger">@lang('forms.delete_action')</a>
</td>
</tr>
@endforeach
</tbody>
</table>
<div class="text-center">
{{ $services->links() }}
</div>
@endif
<div class="text-right" style="margin-top: 10px;">
<a href="{{ route('services.create') }}" class="btn btn-success"><i class="fa fa-fw fa-plus"></i> @lang('admin.create_service')</a>
</div>
</div>
</div>
</div>
@endsection

View File

@ -38,16 +38,24 @@
</span><br/> </span><br/>
<span style="color: #888; font-style: italic;"> <span style="color: #888; font-style: italic;">
@if ($storage->source == 'LocalFilesystemSource'){{ $storage->location }}@endif @if ($storage->source == 'LocalFilesystemSource'){{ $storage->location }}@endif
@if ($storage->source == 'OpenStackSource'){{ $storage->container_name }} - {{ $storage->service_name }}, {{ $storage->service_region }}@endif @if ($storage->source == 'OpenStackSource'){{ $storage->container_name }} &middot; {{ $storage->service_name }}, {{ $storage->service_region }}@endif
@if ($storage->source == 'AmazonS3Source') @if ($storage->source == 'AmazonS3Source')
{{ $storage->container_name }} - {{ $storage->service_region }} {{ $storage->container_name }} &middot; {{ $storage->service_region }}
@if ($storage->s3_signed_urls) @if ($storage->s3_signed_urls)
<i class="fa fa-key ml-2" data-toggle="tooltip" title="@lang('admin.storage_s3_signed_urls_tooltip')"></i> <i class="fa fa-key ml-2" data-toggle="tooltip" title="@lang('admin.storage_s3_signed_urls_tooltip')"></i>
@endif @endif
@endif @endif
@if ($storage->source == 'RackspaceSource'){{ $storage->container_name }} - {{ $storage->service_region }}@endif @if ($storage->source == 'RackspaceSource'){{ $storage->container_name }} &middot; {{ $storage->service_region }}@endif
@if ($storage->source == 'DropboxSource')
@if (empty($storage->access_token))
<a href="{{ route('storage.authoriseService', ['storage' => $storage->id]) }}" class="text-danger"><b>@lang('admin.storage_authorise_external_service_required')</b></a>
@else
<span class="text-success">@lang('admin.storage_authorise_external_service_authorised')</span> &middot; <a href="{{ route('storage.authoriseService', ['storage' => $storage->id]) }}">@lang('admin.storage_authorise_external_service_refresh_authentication')</a>
@endif
@endif
</span> </span>
</td> </td>
<p></p>
<td class="text-right"> <td class="text-right">
@if (!$storage->is_internal) @if (!$storage->is_internal)
<a href="{{ route('storage.delete', ['storage' => $storage->id]) }}" class="btn btn-danger">@lang('forms.delete_action')</a> <a href="{{ route('storage.delete', ['storage' => $storage->id]) }}" class="btn btn-danger">@lang('forms.delete_action')</a>

View File

@ -0,0 +1,26 @@
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label class="form-control-label" for="access-key">@lang('forms.service_app_id_label')</label>
<input type="text" class="form-control{{ $errors->has('app_id') ? ' is-invalid' : '' }}" id="app-id" name="app_id" value="{{ old('app_id', $service->app_id) }}">
@if ($errors->has('app_id'))
<div class="invalid-feedback">
<strong>{{ $errors->first('app_id') }}</strong>
</div>
@endif
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label class="form-control-label" for="secret-key">@lang('forms.service_app_secret_label')</label>
<input type="text" class="form-control{{ $errors->has('app_secret') ? ' is-invalid' : '' }}" id="app-secret" name="app_secret" value="{{ old('app_secret', $service->app_secret) }}">
@if ($errors->has('app_secret'))
<div class="invalid-feedback">
<strong>{{ $errors->first('app_secret') }}</strong>
</div>
@endif
</div>
</div>
</div>

View File

@ -1,41 +1,16 @@
<div class="row"> <div class="form-group">
<div class="col-md-6"> <label for="external-service">@lang('forms.storage_external_service_label')</label>
<div class="form-group"> <select class="form-control{{ $errors->has('external_service_id') ? ' is-invalid' : '' }}" id="external-service" name="external_service_id">
<label class="form-control-label" for="access-key">@lang('forms.storage_application_key_label')</label> <option value="">@lang('forms.please_select')</option>
<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')) @foreach ($dropbox_services as $service)
<div class="invalid-feedback"> <option value="{{ $service->id }}"@if (old('external_service_id', $storage->external_service_id == $service->id)) selected="selected"@endif>{{ $service->name }}</option>
<strong>{{ $errors->first('access_key') }}</strong> @endforeach
</div> </select>
@endif
@if ($errors->has('external_service_id'))
<div class="invalid-feedback">
<strong>{{ $errors->first('external_service_id') }}</strong>
</div> </div>
</div> @endif
<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> </div>

View File

@ -5,7 +5,7 @@
<p style="font-size: smaller;"> <p style="font-size: smaller;">
<b> <b>
@lang('global.powered_by', [ @lang('global.powered_by', [
'link_start' => '<a href="https://andysh.uk/software/blue-twilight-php-photo-gallery/" target="_blank">', 'link_start' => '<a href="https://showmy.photos/" target="_blank">',
'link_end' => '</a>', 'link_end' => '</a>',
]) ])
</b><br/> </b><br/>

View File

@ -5,7 +5,7 @@
<p style="font-size: smaller;"> <p style="font-size: smaller;">
<b> <b>
@lang('global.powered_by', [ @lang('global.powered_by', [
'link_start' => '<a href="https://andysh.uk/software/blue-twilight-php-photo-gallery/" target="_blank">', 'link_start' => '<a href="https://showmy.photos/" target="_blank">',
'link_end' => '</a>', 'link_end' => '</a>',
]) ])
</b><br/> </b><br/>

View File

@ -55,6 +55,8 @@ Route::group(['prefix' => 'admin'], function () {
Route::resource('labels', 'Admin\LabelController'); Route::resource('labels', 'Admin\LabelController');
// Storage management // Storage management
Route::get('storage/{storage}/authorise-service', 'Admin\StorageController@authoriseService')->name('storage.authoriseService');
Route::get('storage/{storage}/complete-service-authorisation', 'Admin\StorageController@completeServiceAuthorisation')->name('storage.completeServiceAuthorisation');
Route::get('storage/{storage}/delete', 'Admin\StorageController@delete')->name('storage.delete'); Route::get('storage/{storage}/delete', 'Admin\StorageController@delete')->name('storage.delete');
Route::resource('storage', 'Admin\StorageController'); Route::resource('storage', 'Admin\StorageController');
@ -81,7 +83,7 @@ Route::group(['prefix' => 'admin'], function () {
Route::resource('comments', 'Admin\PhotoCommentController'); Route::resource('comments', 'Admin\PhotoCommentController');
// Services management // Services management
Route::get('services/{services}/delete', 'Admin\ServiceController@delete')->name('services.delete'); Route::get('services/{service}/delete', 'Admin\ServiceController@delete')->name('services.delete');
Route::resource('services', 'Admin\ServiceController'); Route::resource('services', 'Admin\ServiceController');
}); });