Compare commits

...

29 Commits
v2.2 ... main

Author SHA1 Message Date
Andy Heathershaw 5ad23328f7 Update 'readme.md' 2022-06-05 16:19:14 +01:00
Andy Heathershaw e95967b3b0 Merge pull request 'Improved Bootstrap experience and services improvements' (#154) from feature/146-bootstrap-experience into master 2020-04-30 08:48:54 +01:00
Andy Heathershaw 8f9a386494 Prevent deleting service definitions when they are in use throughout the system. Closes #153 2020-04-30 08:38:37 +01:00
Andy Heathershaw 3655c28c73 Facebook, Google and Twitter SSO app credentials are now migrated to the new services section when running under v2.2.0-beta.2. Providers no longer appear on the login/register page unless they are enabled AND a service has been selected. Added a link to amend services in the settings section. closes #152 2020-04-30 08:28:19 +01:00
Andy Heathershaw 4dc4ce1517 Switched the socialite login providers to the new external services configuration #152 2020-04-29 22:19:21 +01:00
Andy Heathershaw cb849c7928 Revamped the new installer and moved the system configuration check to that part of the installer and out of the Laravel app. Corrected an issue with the s3_signed_urls storage column. #146 2020-04-27 17:35:26 +01:00
Andy Heathershaw e2f195f5be Refactored the installer so it all comes under the /install URL, and the AppInstaller namespace and source is outside of the public/ folder. 2020-04-27 08:57:13 +01:00
Andy Heathershaw 6ad1cdda8f Corrected the detection of the Blue Twilight URL to allow redirection to the bootstrapper. Added standard BT to the install page. #146 2020-04-27 08:32:45 +01:00
Andy Heathershaw e06b227147 Completed the first interation of the new Bootstrapper experience. Downloads and extracts vendors from Gitea and configures the encryption key. Still need to get upgrades implemented. #146 2020-04-26 21:53:24 +01:00
Andy Heathershaw 365034d611 Added a new Bootstrapper experience to download the vendors directly from Gitea instead of having to download Composer. #146 2020-04-26 15:08:26 +01:00
Andy Heathershaw 7b2ea74a19 Got the new Openstack SDK working with Rackspace, and added my own support for the Rackspace-specific extensions for API key and CDN. #144 2020-04-22 17:11:50 +01:00
Andy Heathershaw 61c51fcd37 Updated the OpenStack driver to use the new PHP Open Cloud repo instead of the previous Rackspace one. This also completes the last provider's change to GuzzleHttp instead of Guzzle. #144 #145 2020-04-22 08:19:28 +01:00
Andy Heathershaw 132bfcdb83 Fixed a missing JS variable when deleting a photo from the album's admin page. This also now just removes the photo element from the DOM instead of reloading the whole page. #150 2020-04-22 08:18:30 +01:00
Andy Heathershaw c1a11eee17 Merge pull request 'Pull #106 and #148 Dropbox and external services' (#149) from feature/106-dropbox-storage into master 2020-04-22 06:58:14 +01:00
Andy Heathershaw f80b80540f Files are now removed from Dropbox when a photo/album is deleted. Added handling for Dropbox's 429 (retry) error. Added a new admin permission for restricing access to the new services area. Corrected a logic issue with failing images during the analysis process. #106 2020-04-22 06:56:15 +01:00
Andy Heathershaw f17a84f746 Dropbox authorisation now uses a dedicated endpoint on the services controller, and uses OAuth2 state to transfer the storage ID. Added an intermediary screen before authorising. #106 2020-04-21 08:40:56 +01:00
Andy Heathershaw d97b790264 Added the ability to create, edit and remove external services. Implemented an OAuth2 flow for authentication to Dropbox. #106 2020-04-20 22:33:42 +01:00
Andy Heathershaw 09b4bc60dd Images are now refreshed correctly after resizing/rotating when using a private S3 album. Corrected some more icons to Font Awesome v5. #147 #141 2020-04-19 15:46:35 +01:00
Andy Heathershaw b8b21cc06b More updated icons to Font Awesome v5 and removal of assets within the project following the switch of the build system. #147 2020-04-19 15:31:48 +01:00
Andy Heathershaw db585586a4 Switched the build system from Gulp to Grunt. Updated Bootstrap, Font Awesome and other dependencies to pull from my CDN on build. Started working on adding a 'services' section to hold external credentials, such as app ID/secret. 2020-04-19 10:54:07 +01:00
Andy Heathershaw e3892a037f Started work on replacing guzzle/guzzle (v3) with guzzlehttp/guzzle (v6.) 2020-04-18 22:41:30 +01:00
Andy Heathershaw fdf4d72236 Merge branch 'master' into feature/106-dropbox-storage
# Conflicts:
#	app/Http/Controllers/Admin/StorageController.php
#	resources/views/themes/base/admin/edit_storage.blade.php
2020-04-18 21:53:36 +01:00
Andy Heathershaw f773b10244 Implemented a new option for S3 sources to allow signed URLs and private buckets to be used. #141 2020-04-18 21:51:28 +01:00
Andy Heathershaw 93c6f5da10 Updated all resource routes to follow Laravel's standard of the "id" parameter on edit/update/destroy routes being the singular of the resource #142 2020-04-18 18:25:43 +01:00
Andy Heathershaw 15cb2f40b0 Replaced Rackspace's PHP Open Cloud with PHP OpenCloud OpenStack package. 2020-04-18 18:02:38 +01:00
Andy Heathershaw 90cf38d9aa Upgraded Laravel from 5.5 to 6.0 LTS, as well as latest Composer dependencies #142 2020-04-18 17:45:40 +01:00
Andy Heathershaw 9668352129 Updated Composer dependencies to the latest versions. Resolves an issue with PHP 7.4 and AWS 2020-04-18 16:39:20 +01:00
Andy Heathershaw feb38c47b0 Fixes #134 - user profile link is made available if not logged in and the user's profile is public 2019-10-19 21:03:06 +01:00
Andy Heathershaw 582e5fffaa Dropbox #106 - files can be uploaded to a Dropbox account using a generated access token, and downloaded using the Blue Twilight download endpoint. 2019-09-15 21:37:41 +01:00
246 changed files with 33693 additions and 82944 deletions

View File

@ -1,7 +1,7 @@
APP_ENV=local
APP_ENV=production
APP_KEY=
APP_DEBUG=true
APP_LOG_LEVEL=debug
APP_DEBUG=false
APP_LOG_LEVEL=warning
APP_URL=http://localhost
DB_CONNECTION=mysql

157
Gruntfile.js Normal file
View File

@ -0,0 +1,157 @@
/*
This Gruntfile downloads the necessary CSS and JS includes (Bootstrap, etc.) and creates a combined file ready for
deployment with the application.
Available tasks:
- build-debug: Builds the minified CSS and JS files
*/
module.exports = function(grunt)
{
var download_url = 'https://cdn.andysh.uk/';
const sass = require('node-sass');
grunt.loadNpmTasks('grunt-contrib-clean');
grunt.loadNpmTasks('grunt-contrib-cssmin');
grunt.loadNpmTasks('grunt-contrib-concat');
grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-curl');
grunt.loadNpmTasks('grunt-dart-sass');
grunt.loadNpmTasks('grunt-exec');
grunt.initConfig({
pkg: grunt.file.readJSON('package.json'),
clean: {
build_css: [
// Clean the build folder - downloaded files
'build/css',
// Clean the resources folder - compiled SASS files
'resources/css'
],
build_js: [
'build/js',
],
output: [
'public/css',
'public/js'
]
},
concat: {
// Concatenate all third-party stylesheets into blue-twilight.css
bt_css: {
src: [
'build/css/*.css',
'resources/css/*.css'
],
dest: 'public/css/blue-twilight.css'
},
// Concatenate all third-party and application Javascripts into blue-twilight.js
bt_js: {
src: [
'build/js/*.js',
'resources/js/*.js',
],
dest: 'public/js/blue-twilight.js'
},
},
curl: {
/* These elements are sorted according to the order we desire them to be loaded when concatenated */
/** CSS **/
bootstrap_css: {
src: download_url + 'bootstrap/4.4.1/css/bootstrap.css',
dest: 'build/css/01-bootstrap.css'
},
/** JS **/
jquery: {
src: download_url + 'jquery/3.4.1/jquery.js',
dest: 'build/js/01-jquery.js'
},
bootstrap_js: {
src: download_url + 'bootstrap/4.4.1/js/bootstrap.bundle.js',
dest: 'build/js/02-bootstrap.js'
},
bootbox_js: {
src: download_url + 'bootbox/5.3.3/bootbox.all.js',
dest: 'build/js/03-bootbox.js'
},
font_awesome_js: {
src: download_url + 'font-awesome/5.12.0/js/all.js',
dest: 'build/js/04-fontawesome.js'
},
vuejs: {
src: download_url + 'vuejs/2.6.11/vue.js',
dest: 'build/js/05-vuejs.js'
},
},
'dart-sass': {
bt_sass: {
options: {
sourceMap: false
},
files: [{
expand: true,
cwd: 'resources/sass/',
src: ['*.scss'],
dest: 'resources/css/',
ext: '.css'
}]
},
},
cssmin: {
bt_css: {
files: {
'public/css/blue-twilight.min.css': ['public/css/blue-twilight.css']
}
}
},
uglify: {
bt_js: {
options: {
sourceMap: true
},
files: {
'public/js/blue-twilight.min.js': ['public/js/blue-twilight.js']
}
}
}
});
// Register our tasks
grunt.registerTask('build-css-debug', [
// Download third-party stylesheets
'curl:bootstrap_css',
// SASS our own CSS
'dart-sass:bt_sass',
// Create our blue-twilight.css
'concat:bt_css'
]);
grunt.registerTask('build-js-debug', [
// Download third-party Javascripts
'curl:jquery',
'curl:bootstrap_js',
'curl:bootbox_js',
'curl:font_awesome_js',
'curl:vuejs',
// Create our blue-twilight.js
'concat:bt_js'
]);
grunt.registerTask('build-css-release', [
'build-css-debug',
'cssmin:bt_css'
]);
grunt.registerTask('build-js-release', [
'build-js-debug',
'uglify:bt_js'
]);
// Shortcut tasks for the ones above
grunt.registerTask('clean-all', ['clean:build_css', 'clean:build_js', 'clean:output']);
grunt.registerTask('build-debug', ['clean-all', 'build-css-debug', 'build-js-debug']);
grunt.registerTask('build-release', ['clean-all', 'build-css-release', 'build-js-release']);
};

View File

@ -42,6 +42,11 @@ abstract class AlbumSourceBase
return self::$albumSourceCache[$fullClassName];
}
public function getConfiguration()
{
return $this->configuration;
}
public function setAlbum(Album $album)
{
$this->album = $album;

View File

@ -4,9 +4,10 @@ namespace App\AlbumSources;
use App\Helpers\MiscHelper;
use App\Photo;
use Guzzle\Http\EntityBody;
use Guzzle\Http\Exception\ClientErrorResponseException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Stream;
use Illuminate\Support\Facades\Log;
use function GuzzleHttp\Psr7\stream_for;
class AmazonS3Source extends AlbumSourceBase implements IAlbumSource, IAnalysisQueueSource
{
@ -37,7 +38,7 @@ class AmazonS3Source extends AlbumSourceBase implements IAlbumSource, IAnalysisQ
'Key' => $fileToDelete
]);
}
catch (ClientErrorResponseException $ex)
catch (GuzzleException $ex)
{
// Don't worry if the file no longer exists
Log::warning('Failed deleting image from S3.', ['error' => $ex->getMessage(), 'path' => $fileToDelete]);
@ -61,7 +62,7 @@ class AmazonS3Source extends AlbumSourceBase implements IAlbumSource, IAnalysisQ
'Key' => $this->getPathToPhoto($photo, $thumbnail)
]);
}
catch (ClientErrorResponseException $ex)
catch (GuzzleException $ex)
{
// Don't worry if the file no longer exists
Log::warning('Failed deleting image from S3.', ['error' => $ex->getMessage(), 'path' => $photoPath]);
@ -91,7 +92,7 @@ class AmazonS3Source extends AlbumSourceBase implements IAlbumSource, IAnalysisQ
* 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
* @return Stream
*/
public function fetchPhotoContent(Photo $photo, $thumbnail = null)
{
@ -105,7 +106,7 @@ class AmazonS3Source extends AlbumSourceBase implements IAlbumSource, IAnalysisQ
'SaveAs' => $tempFile
]);
return EntityBody::factory(fopen($tempFile, 'r+'));
return stream_for(fopen($tempFile, 'r+'));
}
finally
{
@ -130,7 +131,19 @@ class AmazonS3Source extends AlbumSourceBase implements IAlbumSource, IAnalysisQ
*/
public function getUrlToPhoto(Photo $photo, $thumbnail = null)
{
return $this->getClient()->getObjectUrl($this->configuration->container_name, $this->getPathToPhoto($photo, $thumbnail));
$client = $this->getClient();
if ($this->configuration->s3_signed_urls)
{
$cmd = $client->getCommand('GetObject', [
'Bucket' => $this->configuration->container_name,
'Key' => $this->getPathToPhoto($photo, $thumbnail)
]);
return (string)$client->createPresignedRequest($cmd, '+5 minutes')->getUri();
}
return $client->getObjectUrl($this->configuration->container_name, $this->getPathToPhoto($photo, $thumbnail));
}
/**
@ -144,7 +157,11 @@ class AmazonS3Source extends AlbumSourceBase implements IAlbumSource, IAnalysisQ
{
$photoPath = $this->getPathToPhoto($photo, $thumbnail);
$this->getClient()->upload($this->configuration->container_name, $photoPath, fopen($tempFilename, 'r+'), 'public-read');
$uploadAcl = $this->configuration->s3_signed_urls
? 'private'
: 'public-read';
$this->getClient()->upload($this->configuration->container_name, $photoPath, fopen($tempFilename, 'r+'), $uploadAcl);
}
/**

View File

@ -6,8 +6,9 @@ use App\BackblazeB2FileIdCache;
use App\Photo;
use App\Services\BackblazeB2Service;
use App\Storage;
use Guzzle\Http\EntityBody;
use GuzzleHttp\Psr7\Stream;
use Illuminate\Support\Facades\Log;
use function GuzzleHttp\Psr7\stream_for;
class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
{
@ -67,7 +68,7 @@ class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
* 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
* @return Stream
*/
public function fetchPhotoContent(Photo $photo, $thumbnail = null)
{
@ -78,12 +79,10 @@ class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
$b2Cache = $this->getB2FileFromCache($pathOnStorage);
if (is_null($b2Cache))
{
return EntityBody::fromString('');
return stream_for('');
}
return EntityBody::fromString(
$this->getClient()->downloadFile($b2Cache->b2_file_id)
);
return stream_for($this->getClient()->downloadFile($b2Cache->b2_file_id));
}
/**

View File

@ -0,0 +1,137 @@
<?php
namespace App\AlbumSources;
use App\Photo;
use App\Services\DropboxService;
use GuzzleHttp\Psr7\Stream;
use function GuzzleHttp\Psr7\stream_for;
class DropboxSource extends AlbumSourceBase implements IAlbumSource
{
/**
* @var DropboxService
*/
private $dropboxClient;
/**
* Deletes an entire album's media contents.
* @return void
*/
public function deleteAlbumContents()
{
try
{
$albumPathOnStorage = sprintf('/%s', $this->album->url_alias);
$this->getClient()->deleteFile($albumPathOnStorage);
}
catch (\Exception $ex)
{
// Don't worry too much if the delete fails - the file may have been removed on Dropbox itself
}
}
/**
* 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)
{
try
{
$pathOnStorage = $this->getPathToPhoto($photo, $thumbnail);
$this->getClient()->deleteFile($pathOnStorage);
}
catch (\Exception $ex)
{
// Don't worry too much if the delete fails - the file may have been removed on Dropbox itself
}
}
/**
* 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 Stream
*/
public function fetchPhotoContent(Photo $photo, $thumbnail = null)
{
$pathOnStorage = $this->getPathToPhoto($photo, $thumbnail);
return stream_for($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

@ -5,8 +5,7 @@ namespace App\AlbumSources;
use App\Album;
use App\Photo;
use App\Storage;
use Guzzle\Http\EntityBody;
use Symfony\Component\HttpFoundation\File\File;
use GuzzleHttp\Psr7\Stream;
interface IAlbumSource
{
@ -28,10 +27,16 @@ interface IAlbumSource
* 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
* @return Stream
*/
function fetchPhotoContent(Photo $photo, $thumbnail = null);
/**
* Gets the configuration of the source.
* @return mixed
*/
function getConfiguration();
/**
* Gets the name of this album source.
* @return string

View File

@ -5,8 +5,9 @@ namespace App\AlbumSources;
use App\Helpers\FileHelper;
use App\Helpers\MiscHelper;
use App\Photo;
use Guzzle\Http\EntityBody;
use GuzzleHttp\Psr7\Stream;
use Symfony\Component\HttpFoundation\File\File;
use function GuzzleHttp\Psr7\stream_for;
/**
* Driver for managing files on the local filesystem.
@ -37,7 +38,7 @@ class LocalFilesystemSource extends AlbumSourceBase implements IAlbumSource, IAn
* 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
* @return Stream
*/
public function fetchPhotoContent(Photo $photo, $thumbnail = null)
{
@ -50,7 +51,7 @@ class LocalFilesystemSource extends AlbumSourceBase implements IAlbumSource, IAn
'r+'
);
return EntityBody::factory($fh);
return stream_for($fh);
}
public function getName()

View File

@ -3,12 +3,16 @@
namespace App\AlbumSources;
use App\Photo;
use Guzzle\Http\EntityBody;
use Guzzle\Http\Exception\ClientErrorResponseException;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Stream;
use Illuminate\Support\Facades\Log;
use OpenCloud\ObjectStore\Exception\ObjectNotFoundException;
use OpenCloud\OpenStack;
use Symfony\Component\HttpFoundation\File\File;
use OpenStack\Common\Error\BadResponseError;
use OpenStack\Common\Transport\Utils as TransportUtils;
use OpenStack\Identity\v2\Service as IdentityV2Service;
use OpenStack\OpenStack;
use function GuzzleHttp\Psr7\stream_for;
/**
* Driver for managing files on an OpenStack Keystone+Swift compatible platform.
@ -38,9 +42,14 @@ class OpenStackSource extends AlbumSourceBase implements IAlbumSource
try
{
$this->getContainer()->deleteObject($photoPath);
$this->getContainer()->getObject($photoPath)->delete();
}
catch (ClientErrorResponseException $ex)
catch (GuzzleException $ex)
{
// Don't worry if the file no longer exists
Log::warning('Failed deleting image from OpenStack.', ['error' => $ex->getMessage(), 'path' => $photoPath]);
}
catch (BadResponseError $ex)
{
// Don't worry if the file no longer exists
Log::warning('Failed deleting image from OpenStack.', ['error' => $ex->getMessage(), 'path' => $photoPath]);
@ -51,13 +60,13 @@ class OpenStackSource extends AlbumSourceBase implements IAlbumSource
* 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
* @return Stream
*/
public function fetchPhotoContent(Photo $photo, $thumbnail = null)
{
$object = $this->getContainer()->getObject($this->getPathToPhoto($photo, $thumbnail));
return $object->getContent();
return stream_for($object->download());
}
/**
@ -113,17 +122,33 @@ class OpenStackSource extends AlbumSourceBase implements IAlbumSource
{
$photoPath = $this->getPathToPhoto($photo, $thumbnail);
$container = $this->getContainer();
$container->uploadObject($photoPath, fopen($tempFilename, 'r+'));
$createOptions = [
'name' => $photoPath,
'content' => file_get_contents($tempFilename)
];
$this->getContainer()->createObject($createOptions);
}
protected function getClient()
{
return new OpenStack($this->configuration->auth_url, [
$authURL = $this->configuration->auth_url;
$options = [
'authUrl' => $authURL,
'username' => $this->configuration->username,
'password' => decrypt($this->configuration->password),
'tenantName' => $this->configuration->tenant_name,
]);
'region' => $this->configuration->service_region,
'identityService' => IdentityV2Service::factory(
new Client([
'base_uri' => TransportUtils::normalizeUrl($authURL),
'handler' => HandlerStack::create(),
])
)
];
return new OpenStack($options);
}
protected function getContainer()
@ -133,11 +158,14 @@ class OpenStackSource extends AlbumSourceBase implements IAlbumSource
protected function getStorageService()
{
return $this->getClient()->objectStoreService(
$this->configuration->service_name,
$this->configuration->service_region,
'publicURL'
);
return $this->getClient()->objectStoreV1([
'catalogName' => $this->getStorageServiceCatalogName()
]);
}
protected function getStorageServiceCatalogName()
{
return $this->configuration->service_name;
}
protected function getOriginalsFolder()

View File

@ -3,26 +3,15 @@
namespace App\AlbumSources;
use App\Photo;
use App\Storage;
use OpenCloud\Rackspace;
use App\Services\Rackspace\Identity\v2\Service as RackspaceIdentityV2Service;
use App\Services\Rackspace\ObjectStoreCdn\v1\Models\Container;
use App\Services\Rackspace\Rackspace;
use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use OpenStack\Common\Transport\Utils as TransportUtils;
class RackspaceSource extends OpenStackSource
{
protected function getClient()
{
$endpoint = Rackspace::US_IDENTITY_ENDPOINT;
if ($this->configuration->service_region == 'LON')
{
$endpoint = Rackspace::UK_IDENTITY_ENDPOINT;
}
return new Rackspace($endpoint, [
'username' => $this->configuration->username,
'apiKey' => decrypt($this->configuration->password)
]);
}
/**
* Gets the name of this album source.
* @return string
@ -41,12 +30,14 @@ class RackspaceSource extends OpenStackSource
public function getUrlToPhoto(Photo $photo, $thumbnail = null)
{
$isCdnEnabled = false;
$cdnService = $this->getStorageService()->getCdnService();
$cdnService = $this->getCdnService();
$thisCdnContainer = null;
/** @var Container $cdnContainer */
foreach ($cdnService->listContainers() as $cdnContainer)
{
if ($cdnContainer->name == $this->configuration->container_name)
if ($cdnContainer->cdn_enabled && strtolower($cdnContainer->name) == strtolower($this->configuration->container_name))
{
$isCdnEnabled = true;
$thisCdnContainer = $cdnContainer;
@ -55,9 +46,47 @@ class RackspaceSource extends OpenStackSource
if ($isCdnEnabled)
{
return sprintf('%s/%s', $thisCdnContainer->getCdnSslUri(), $this->getPathToPhoto($photo, $thumbnail));
return sprintf('%s/%s', $thisCdnContainer->cdn_ssl_uri, $this->getPathToPhoto($photo, $thumbnail));
}
return parent::getPathToPhoto($photo, $thumbnail);
}
protected function getCdnService()
{
return $this->getClient()->objectStoreCdnV1();
}
protected function getClient()
{
$authURL = config('services.rackspace.authentication_url');
// Uncomment the commented out lines below and in the $options array to get a 'storage/logs/openstack.log' file
// with passed HTTP traffic
//$logger = new Logger('MyLog');
//$logger->pushHandler(new StreamHandler(__DIR__ . '/../../storage/logs/openstack.log'), Logger::DEBUG);
$options = [
'authUrl' => $authURL,
'username' => $this->configuration->username,
'apiKey' => decrypt($this->configuration->password),
'region' => $this->configuration->service_region,
'identityService' => RackspaceIdentityV2Service::factory(
new Client([
'base_uri' => TransportUtils::normalizeUrl($authURL),
'handler' => HandlerStack::create(),
])
),
//'debugLog' => true,
//'logger' => $logger,
//'messageFormatter' => new MessageFormatter('{req_body} - {res_body}')
];
return new Rackspace($options);
}
protected function getStorageServiceCatalogName()
{
return 'cloudFiles';
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Exceptions;
class DropboxRetryException extends \Exception
{
private $innerException;
/**
* @return mixed
*/
public function getInnerException()
{
return $this->innerException;
}
public function __construct($httpCode, \Exception $innerException)
{
parent::__construct('Dropbox requested to retry the request');
$this->innerException = $innerException;
}
}

54
app/ExternalService.php Normal file
View File

@ -0,0 +1,54 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class ExternalService extends Model
{
public const DROPBOX = 'dropbox';
public const FACEBOOK = 'facebook';
public const GOOGLE = 'google';
public const TWITTER = 'twitter';
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = ['name', 'service_type', 'app_id', 'app_secret'];
/**
* Gets all possible service configurations for the given service type.
* @param $serviceType
* @return ExternalService[]
*/
public static function getForService($serviceType)
{
return ExternalService::where('service_type', $serviceType)->get();
}
public function isDropbox()
{
// This logic must be mirrored in external_services.js
return $this->service_type == self::DROPBOX;
}
public function isFacebook()
{
// This logic must be mirrored in external_services.js
return $this->service_type == self::FACEBOOK;
}
public function isGoogle()
{
// This logic must be mirrored in external_services.js
return $this->service_type == self::GOOGLE;
}
public function isTwitter()
{
// This logic must be mirrored in external_services.js
return $this->service_type == self::TWITTER;
}
}

View File

@ -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
];
@ -111,10 +113,8 @@ class ConfigHelper
'date_format' => $this->allowedDateFormats()[0],
'default_album_view' => $this->allowedAlbumViews()[0],
'enable_visitor_hits' => false,
'facebook_app_id' => '',
'facebook_app_secret' => '',
'google_app_id' => '',
'google_app_secret' => '',
'facebook_external_service_id' => 0,
'google_external_service_id' => 0,
'hotlink_protection' => false,
'items_per_page' => 12,
'items_per_page_admin' => 10,
@ -149,8 +149,7 @@ class ConfigHelper
'social_user_feeds' => false,
'social_user_profiles' => false,
'theme' => 'default',
'twitter_app_id' => '',
'twitter_app_secret' => '',
'twitter_external_service_id' => 0
);
}
@ -217,11 +216,29 @@ class ConfigHelper
!empty($this->get('rabbitmq_vhost'));
}
public function isLoginWithFacebookEnabled()
{
return boolval($this->get('social_facebook_login')) &&
intval($this->get('facebook_external_service_id')) > 0;
}
public function isLoginWithGoogleEnabled()
{
return boolval($this->get('social_google_login')) &&
intval($this->get('google_external_service_id')) > 0;
}
public function isLoginWithTwitterEnabled()
{
return boolval($this->get('social_twitter_login')) &&
intval($this->get('twitter_external_service_id')) > 0;
}
public function isSocialMediaLoginEnabled()
{
return $this->get('social_facebook_login') ||
$this->get('social_twitter_login') ||
$this->get('social_google_login');
return $this->isLoginWithFacebookEnabled() ||
$this->isLoginWithGoogleEnabled() ||
$this->isLoginWithTwitterEnabled();
}
private function loadCache()

View File

@ -111,9 +111,9 @@ class MiscHelper
return sprintf('%s/.env', dirname(dirname(__DIR__)));
}
public static function getEnvironmentSetting($settingName)
public static function getEnvironmentSetting($settingName, $envFile = null)
{
$envFile = MiscHelper::getEnvironmentFilePath();
$envFile = $envFile ?? MiscHelper::getEnvironmentFilePath();
if (!file_exists($envFile))
{

View File

@ -11,7 +11,6 @@ use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Group;
use App\Helpers\DbHelper;
use App\Helpers\FileHelper;
use App\Helpers\MiscHelper;
use App\Helpers\PermissionsHelper;
use App\Http\Controllers\Controller;
@ -74,7 +73,7 @@ class AlbumController extends Controller
if (count($photos) == 0)
{
return redirect(route('albums.show', ['id' => $album->id]));
return redirect(route('albums.show', ['album' => $album->id]));
}
return Theme::render('admin.analyse_album', ['album' => $album, 'photos' => $photos, 'queue_token' => $queue_token]);
@ -171,7 +170,7 @@ class AlbumController extends Controller
$redirect->delete();
$request->session()->flash('success', trans('admin.delete_redirect_success_message'));
return redirect(route('albums.show', ['id' => $id, 'tab' => 'redirects']));
return redirect(route('albums.show', ['album' => $id, 'tab' => 'redirects']));
}
/**
@ -706,7 +705,7 @@ class AlbumController extends Controller
$helper = new PermissionsHelper();
$helper->rebuildCache();
return redirect(route('albums.show', ['id' => $album->id]));
return redirect(route('albums.show', ['album' => $album->id]));
}
public function storeRedirect(Requests\StoreAlbumRedirectRequest $request, $id)
@ -721,7 +720,7 @@ class AlbumController extends Controller
$redirect->save();
$request->session()->flash('success', trans('admin.create_redirect_success_message'));
return redirect(route('albums.show', ['id' => $id, 'tab' => 'redirects']));
return redirect(route('albums.show', ['album' => $id, 'tab' => 'redirects']));
}
/**
@ -783,7 +782,7 @@ class AlbumController extends Controller
$request->session()->flash('success', trans('admin.album_saved_successfully', ['name' => $album->name]));
return redirect(route('albums.show', ['id' => $id]));
return redirect(route('albums.show', ['album' => $id]));
}
private function createActivityRecord(Album $album, $type, $activityDateTime = null)

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Admin;
use App\Album;
use App\ExternalService;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Group;
@ -260,10 +261,8 @@ class DefaultController extends Controller
'analysis_queue_storage_location',
'app_name',
'date_format',
'facebook_app_id',
'facebook_app_secret',
'google_app_id',
'google_app_secret',
'facebook_external_service_id',
'google_external_service_id',
'photo_comments_allowed_html',
'photo_comments_thread_depth',
'rabbitmq_server',
@ -279,8 +278,7 @@ class DefaultController extends Controller
'smtp_username',
'smtp_password',
'theme',
'twitter_app_id',
'twitter_app_secret',
'twitter_external_service_id',
'recaptcha_site_key',
'recaptcha_secret_key',
'analytics_code'
@ -374,12 +372,30 @@ class DefaultController extends Controller
// Storage sources for the Image Processing tab
$storageSources = AnalysisQueueHelper::getCompatibleStorages();
// External services
$externalServices = ExternalService::all();
$facebookServices = $externalServices->filter(function (ExternalService $item)
{
return $item->service_type == ExternalService::FACEBOOK;
});
$googleServices = $externalServices->filter(function (ExternalService $item)
{
return $item->service_type == ExternalService::GOOGLE;
});
$twitterServices = $externalServices->filter(function (ExternalService $item)
{
return $item->service_type == ExternalService::TWITTER;
});
return Theme::render('admin.settings', [
'config' => $config,
'date_formats' => $dateFormatsLookup,
'facebookServices' => $facebookServices,
'googleServices' => $googleServices,
'storage_sources' => $storageSources,
'success' => $request->session()->get('success'),
'theme_names' => $themeNamesLookup
'theme_names' => $themeNamesLookup,
'twitterServices' => $twitterServices
]);
}

View File

@ -134,7 +134,7 @@ class PhotoCommentController extends Controller
if (count($commentIDs) == 1)
{
// Single comment selected - redirect to the single delete page
return redirect(route('comments.delete', ['id' => $commentIDs[0]]));
return redirect(route('comments.delete', ['comment' => $commentIDs[0]]));
}
// Show the view to confirm the delete
@ -148,7 +148,7 @@ class PhotoCommentController extends Controller
if (count($commentIDs) == 1)
{
// Single comment selected - redirect to the single approve page
return redirect(route('comments.approve', ['id' => $commentIDs[0]]));
return redirect(route('comments.approve', ['comment' => $commentIDs[0]]));
}
// Show the view to confirm the approval
@ -162,7 +162,7 @@ class PhotoCommentController extends Controller
if (count($commentIDs) == 1)
{
// Single comment selected - redirect to the single reject page
return redirect(route('comments.reject', ['id' => $commentIDs[0]]));
return redirect(route('comments.reject', ['comment' => $commentIDs[0]]));
}
// Show the view to confirm the rejection

View File

@ -162,7 +162,7 @@ class PhotoController extends Controller
$request->session()->flash('success', trans('admin.delete_photo_successful_message', ['name' => $photo->name]));
}
public function flip($photoId, $horizontal, $vertical)
public function flip(Request $request, $photoId, $horizontal, $vertical)
{
$this->authorizeAccessToAdminPanel();
@ -176,6 +176,8 @@ class PhotoController extends Controller
// Log an activity record for the user's feed
$this->createActivityRecord($photo, 'photo.edited');
return $photo->thumbnailUrl($request->get('t', 'admin-preview'));
}
public function move(Request $request, $photoId)
@ -241,13 +243,13 @@ class PhotoController extends Controller
return response()->json($result);
}
public function regenerateThumbnails($photoId)
public function regenerateThumbnails(Request $request, $photoId)
{
$this->authorizeAccessToAdminPanel();
$photo = $this->loadPhoto($photoId, 'change-metadata');
$result = ['is_successful' => false, 'message' => ''];
$result = ['is_successful' => false, 'message' => '', 'thumbnail_url' => ''];
try
{
@ -255,6 +257,7 @@ class PhotoController extends Controller
$photoService->regenerateThumbnails();
$result['is_successful'] = true;
$result['thumbnail_url'] = $photo->thumbnailUrl($request->get('t', 'admin-preview'));
}
catch (\Exception $ex)
{
@ -265,7 +268,7 @@ class PhotoController extends Controller
return response()->json($result);
}
public function rotate($photoId, $angle)
public function rotate(Request $request, $photoId, $angle)
{
$this->authorizeAccessToAdminPanel();
@ -273,7 +276,7 @@ class PhotoController extends Controller
if ($angle != 90 && $angle != 180 && $angle != 270)
{
App::aport(400);
App::abort(400);
return null;
}
@ -282,6 +285,8 @@ class PhotoController extends Controller
// Log an activity record for the user's feed
$this->createActivityRecord($photo, 'photo.edited');
return $photo->thumbnailUrl($request->get('t', 'admin-preview'));
}
/**
@ -392,7 +397,7 @@ class PhotoController extends Controller
else
{
return redirect(route('albums.analyse', [
'id' => $album->id,
'album' => $album->id,
'queue_token' => $queueUid
]));
}
@ -408,7 +413,7 @@ class PhotoController extends Controller
if (is_null($request->files->get('archive')))
{
$request->session()->flash('error', trans('admin.upload_bulk_no_file'));
return redirect(route('albums.show', ['id' => $album->id]));
return redirect(route('albums.show', ['album' => $album->id]));
}
$archiveFile = UploadedFile::createFromBase($request->files->get('archive'));
@ -416,7 +421,7 @@ class PhotoController extends Controller
{
Log::error('Bulk image upload failed.', ['error' => $archiveFile->getError(), 'reason' => $archiveFile->getErrorMessage()]);
$request->session()->flash('error', $archiveFile->getErrorMessage());
return redirect(route('albums.show', ['id' => $album->id]));
return redirect(route('albums.show', ['album' => $album->id]));
}
// Create the folder to hold the analysis results if not already present
@ -446,7 +451,7 @@ class PhotoController extends Controller
default:
$request->session()->flash('error', sprintf('The file type "%s" is not supported for bulk uploads.', $mimeType));
return redirect(route('albums.show', ['id' => $album->id]));
return redirect(route('albums.show', ['album' => $album->id]));
}
$di = new \RecursiveDirectoryIterator($temporaryFolder, \RecursiveDirectoryIterator::SKIP_DOTS);
@ -523,7 +528,7 @@ class PhotoController extends Controller
}
return redirect(route('albums.analyse', [
'id' => $album->id,
'album' => $album->id,
'queue_token' => $queueUid
]));
}
@ -586,7 +591,7 @@ class PhotoController extends Controller
)
);
return redirect(route('albums.show', array('id' => $albumId, 'page' => $request->get('page', 1))));
return redirect(route('albums.show', array('album' => $albumId, 'page' => $request->get('page', 1))));
}
private function applyBulkActions(Request $request, Album $album)

View File

@ -0,0 +1,355 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Configuration;
use App\ExternalService;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreServiceRequest;
use App\Services\DropboxService;
use App\Storage;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\View;
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'];
}
public function authoriseDropbox(Request $request)
{
$this->authorizeAccessToAdminPanel('admin:manage-storage');
if (!$request->has('state') && !$request->has('code'))
{
// TODO flash an error
return redirect('storages.index');
}
try
{
$storageID = decrypt($request->get('state'));
$storage = Storage::where('id', intval($storageID))->first();
if (is_null($storage))
{
// TODO flash an error
return redirect('storages.index');
}
if (is_null($storage->externalService))
{
// TODO flash an error
return redirect('storages.index');
}
switch ($storage->externalService->service_type)
{
case ExternalService::DROPBOX:
$dropbox = new DropboxService();
$dropbox->handleAuthenticationResponse($request, $storage);
// TODO flash a success message
return redirect(route('storage.index'));
default:
// TODO flash an error
return redirect('storages.index');
}
}
catch (\Exception $ex)
{
// TODO flash an error
return redirect('storages.index');
}
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create(Request $request)
{
$this->authorizeAccessToAdminPanel('admin:manage-services');
$serviceTypes = $this->serviceTypeList();
$selectedServiceType = old('service_type', $request->get('service_type'));
if (!array_key_exists($selectedServiceType, $serviceTypes))
{
$selectedServiceType = '';
}
$returnTo = old('return_to', $request->get('return_to'));
if (!array_key_exists($returnTo, $this->validReturnLocations()))
{
$returnTo = '';
}
return Theme::render('admin.create_service', [
'callbackUrls' => $this->callbackList(),
'returnTo' => $returnTo,
'selectedServiceType' => $selectedServiceType,
'service' => new ExternalService(),
'serviceTypes' => $serviceTypes
]);
}
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', [
'callbackUrls' => $this->callbackList(),
'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();
$returnToLocations = $this->validReturnLocations();
$returnTo = $request->get('return_to');
if (array_key_exists($returnTo, $returnToLocations))
{
return redirect($returnToLocations[$returnTo]);
}
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 callbackList()
{
$dropboxService = new DropboxService();
return [
ExternalService::DROPBOX => $dropboxService->callbackUrl(),
ExternalService::FACEBOOK => route('login_callback.facebook'),
ExternalService::GOOGLE => route('login_callback.google'),
ExternalService::TWITTER => route('login_callback.twitter')
];
}
private function isServiceInUse(ExternalService $service)
{
switch ($service->service_type)
{
case ExternalService::FACEBOOK:
// Cannot delete Facebook service if it's set as the login provider
$facebookConfig = Configuration::where('key', 'facebook_external_service_id')->first();
return !is_null($facebookConfig) && intval($facebookConfig->value) == $service->id;
case ExternalService::GOOGLE:
// Cannot delete Google service if it's set as the login provider
$googleConfig = Configuration::where('key', 'google_external_service_id')->first();
return !is_null($googleConfig) && intval($googleConfig->value) == $service->id;
case ExternalService::DROPBOX:
return Storage::where('external_service_id', $service->id)->count() > 0;
case ExternalService::TWITTER:
// Cannot delete Twitter service if it's set as the login provider
$twitterConfig = Configuration::where('key', 'twitter_external_service_id')->first();
return !is_null($twitterConfig) && intval($twitterConfig->value) == $service->id;
}
return true;
}
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))
];
}
private function validReturnLocations()
{
return [
'settings' => route('admin.settings')
];
}
}

View File

@ -2,10 +2,12 @@
namespace App\Http\Controllers\Admin;
use App\ExternalService;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Http\Controllers\Controller;
use App\Http\Requests;
use App\Services\DropboxService;
use App\Storage;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
@ -23,7 +25,39 @@ 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'];
}
public function authoriseService(Request $request, $id)
{
$this->authorizeAccessToAdminPanel('admin:manage-storage');
$storage = Storage::where('id', intval($id))->first();
if (is_null($storage))
{
return redirect(route('storages.index'));
}
$externalServiceType = $this->getExternalServiceType($storage);
if (is_null($externalServiceType))
{
$request->session()->flash('error', trans('admin.storage_no_external_service_support'));
return redirect(route('storages.index'));
}
$serviceTypeName = trans(sprintf('services.%s', $externalServiceType));
switch ($externalServiceType)
{
case ExternalService::DROPBOX:
$dropbox = new DropboxService();
return redirect($dropbox->authoriseUrl($storage));
default:
$request->session()->flash('error', trans('admin.storage_external_service_no_authorisation', ['service_name' => $serviceTypeName]));
return redirect(route('storages.index'));
}
}
/**
@ -55,12 +89,15 @@ class StorageController extends Controller
$this->authorizeAccessToAdminPanel('admin:manage-storage');
$filesystemDefaultLocation = sprintf('%s/storage/app/albums', dirname(dirname(dirname(dirname(__DIR__)))));
$storage = new Storage();
$storage->s3_signed_urls = true;
return Theme::render('admin.create_storage', [
'album_sources' => UserConfig::albumSources(),
'dropbox_services' => ExternalService::getForService(ExternalService::DROPBOX),
'filesystem_default_location' => $filesystemDefaultLocation,
'info' => $request->session()->get('info'),
'storage' => new Storage()
'storage' => $storage
]);
}
@ -89,11 +126,13 @@ class StorageController extends Controller
'cdn_url',
'access_key',
'secret_key',
'b2_bucket_type'
'b2_bucket_type',
'external_service_id'
]));
$storage->is_active = true;
$storage->is_default = (strtolower($request->get('is_default')) == 'on');
$storage->is_internal = false;
$storage->s3_signed_urls = (strtolower($request->get('s3_signed_urls')) == 'on');
if ($storage->source != 'LocalFilesystemSource' && isset($storage->location))
{
@ -115,6 +154,17 @@ class StorageController extends Controller
$this->unsetIsDefaultFromOthers($storage);
}
$externalServiceType = $this->getExternalServiceType($storage);
if (!is_null($externalServiceType))
{
switch ($externalServiceType)
{
case ExternalService::DROPBOX:
return redirect(route('storage.authoriseService', ['storage' => $storage->id]));
}
}
return redirect(route('storage.index'));
}
@ -187,7 +237,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
]);
}
/**
@ -219,10 +272,12 @@ class StorageController extends Controller
'cdn_url',
'access_key',
'secret_key',
'b2_bucket_type'
'b2_bucket_type',
'external_service_id'
]));
$storage->is_active = (strtolower($request->get('is_active')) == 'on');
$storage->is_default = (strtolower($request->get('is_default')) == 'on');
$storage->s3_signed_urls = (strtolower($request->get('s3_signed_urls')) == 'on');
if ($storage->is_default && !$storage->is_active)
{
@ -286,6 +341,16 @@ class StorageController extends Controller
return redirect(route('storage.index'));
}
private function getExternalServiceType(Storage $storage)
{
if (!is_null($storage->externalService))
{
return $storage->externalService->service_type;
}
return null;
}
private function setIsDefaultForFirstStorage()
{
$count = Storage::where('is_default', true)->count();

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Auth;
use App\ExternalService;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Helpers\MiscHelper;
@ -152,7 +153,12 @@ class LoginController extends Controller
*/
public function redirectToFacebook()
{
$socialite = $this->setSocialiteConfigs();
$socialite = $this->setSocialiteConfigForFacebook();
if (is_null($socialite))
{
return redirect(route('login'));
}
return $socialite->driver('facebook')->redirect();
}
@ -163,7 +169,12 @@ class LoginController extends Controller
*/
public function redirectToGoogle()
{
$socialite = $this->setSocialiteConfigs();
$socialite = $this->setSocialiteConfigForGoogle();
if (is_null($socialite))
{
return redirect(route('login'));
}
return $socialite->driver('google')->redirect();
}
@ -174,7 +185,12 @@ class LoginController extends Controller
*/
public function redirectToTwitter()
{
$socialite = $this->setSocialiteConfigs();
$socialite = $this->setSocialiteConfigForTwitter();
if (is_null($socialite))
{
return redirect(route('login'));
}
return $socialite->driver('twitter')->redirect();
}
@ -185,7 +201,12 @@ class LoginController extends Controller
*/
public function handleFacebookCallback(Request $request)
{
$socialite = $this->setSocialiteConfigs();
$socialite = $this->setSocialiteConfigForFacebook();
if (is_null($socialite))
{
return redirect(route('login'));
}
$facebookUser = $socialite->driver('facebook')->user();
return $this->processSocialMediaLogin($request, 'facebook_id', $facebookUser);
@ -198,7 +219,12 @@ class LoginController extends Controller
*/
public function handleGoogleCallback(Request $request)
{
$socialite = $this->setSocialiteConfigs();
$socialite = $this->setSocialiteConfigForGoogle();
if (is_null($socialite))
{
return redirect(route('login'));
}
$googleUser = $socialite->driver('google')->user();
return $this->processSocialMediaLogin($request, 'google_id', $googleUser);
@ -211,12 +237,30 @@ class LoginController extends Controller
*/
public function handleTwitterCallback(Request $request)
{
$socialite = $this->setSocialiteConfigs();
$socialite = $this->setSocialiteConfigForTwitter();
if (is_null($socialite))
{
return redirect(route('login'));
}
$twitterUser = $socialite->driver('twitter')->user();
return $this->processSocialMediaLogin($request, 'twitter_id', $twitterUser);
}
private function getSocialMediaConfig($socialMediaEnabledField, $socialMediaExternalServiceIdField)
{
if (boolval(UserConfig::get($socialMediaEnabledField)))
{
$externalServiceID = intval(UserConfig::get($socialMediaExternalServiceIdField));
$externalService = ExternalService::where('id', $externalServiceID)->first();
return $externalService;
}
return null;
}
private function processSocialMediaLogin(Request $request, $socialMediaIdField, $socialMediaUser)
{
$userBySocialMediaId = User::where($socialMediaIdField, $socialMediaUser->getId())->first();
@ -260,38 +304,81 @@ class LoginController extends Controller
return redirect(route('auth.register_sso'));
}
private function setSocialiteConfigs()
private function setSocialiteConfigForFacebook()
{
// Force Socialite to use our config from the database instead of hard-coded in config/services.php
$facebookConfig = $this->getSocialMediaConfig(
'social_facebook_login',
'facebook_external_service_id'
);
if (is_null($facebookConfig))
{
return null;
}
$socialite = app()->make(\Laravel\Socialite\Contracts\Factory::class);
$socialite->extend(
'facebook',
function ($app) use ($socialite) {
function ($app) use ($socialite, $facebookConfig) {
$config = [
'client_id' => trim(UserConfig::get('facebook_app_id')),
'client_secret' => trim(decrypt(UserConfig::get('facebook_app_secret'))),
'client_id' => trim(decrypt($facebookConfig->app_id)),
'client_secret' => trim(decrypt($facebookConfig->app_secret)),
'redirect' => route('login_callback.facebook')
];
return $socialite->buildProvider(FacebookProvider::class, $config);
}
);
return $socialite;
}
private function setSocialiteConfigForGoogle()
{
$googleConfig = $this->getSocialMediaConfig(
'social_google_login',
'google_external_service_id'
);
if (is_null($googleConfig))
{
return null;
}
$socialite = app()->make(\Laravel\Socialite\Contracts\Factory::class);
$socialite->extend(
'google',
function ($app) use ($socialite) {
function ($app) use ($socialite, $googleConfig) {
$config = [
'client_id' => trim(UserConfig::get('google_app_id')),
'client_secret' => trim(decrypt(UserConfig::get('google_app_secret'))),
'client_id' => trim(decrypt($googleConfig->app_id)),
'client_secret' => trim(decrypt($googleConfig->app_secret)),
'redirect' => route('login_callback.google')
];
return $socialite->buildProvider(GoogleProvider::class, $config);
}
);
return $socialite;
}
private function setSocialiteConfigForTwitter()
{
$twitterConfig = $this->getSocialMediaConfig(
'social_twitter_login',
'twitter_external_service_id'
);
if (is_null($twitterConfig))
{
return null;
}
$socialite = app()->make(\Laravel\Socialite\Contracts\Factory::class);
$socialite->extend(
'twitter',
function ($app) use ($socialite) {
function ($app) use ($socialite, $twitterConfig) {
$config = [
'identifier' => trim(UserConfig::get('twitter_app_id')),
'secret' => trim(decrypt(UserConfig::get('twitter_app_secret'))),
'identifier' => trim(decrypt($twitterConfig->app_id)),
'secret' => trim(decrypt($twitterConfig->app_secret)),
'callback_uri' => route('login_callback.twitter')
];
return new TwitterProvider($app['request'], new TwitterServer($config));

View File

@ -2,14 +2,11 @@
namespace App\Http\Controllers\Gallery;
use App\Album;
use App\AlbumRedirect;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Helpers\ConfigHelper;
use App\Helpers\DbHelper;
use App\Http\Controllers\Controller;
use App\Http\Requests;
use App\VisitorHit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
@ -34,7 +31,7 @@ class AlbumController extends Controller
}
$album = DbHelper::getAlbumById($redirect->album_id);
return redirect($album->url());
return redirect($album->url(), 301);
}
$this->authorizeForUser($this->getUser(), 'view', $album);

View File

@ -6,17 +6,15 @@ use App\Album;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Helpers\DbHelper;
use App\Helpers\MiscHelper;
use app\Http\Controllers\Admin\AlbumController;
use App\Http\Controllers\Controller;
use App\Http\Middleware\VerifyCsrfToken;
use App\Photo;
use App\VisitorHit;
use Guzzle\Http\Mimetypes;
use GuzzleHttp\Psr7\Stream;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\Request;
use function GuzzleHttp\Psr7\mimetype_from_extension;
class PhotoController extends Controller
{
@ -71,8 +69,9 @@ class PhotoController extends Controller
});
}
/** @var Stream $photoStream */
$photoStream = $album->getAlbumSource()->fetchPhotoContent($photo, $thumbnail);
$mimeType = Mimetypes::getInstance()->fromFilename($photo->storage_file_name);
$mimeType = mimetype_from_extension(pathinfo($photo->storage_file_name, PATHINFO_EXTENSION));
return response()->stream(
function() use ($photoStream)
@ -81,7 +80,7 @@ class PhotoController extends Controller
},
200,
[
'Content-Length' => $photoStream->getContentLength(),
'Content-Length' => strlen($photoStream->getContents()),
'Content-Type' => $mimeType
]
);

View File

@ -19,10 +19,10 @@ class InstallController extends Controller
public function administrator(StoreUserRequest $request)
{
// Validate we're at the required stage
$stage = 3;
$stage = 2;
if (intval($request->session()->get('install_stage')) < $stage)
{
return redirect(route('install.check'));
return redirect(route('install.database'));
}
// If we already have an admin account, this step can be skipped
@ -52,70 +52,9 @@ class InstallController extends Controller
]);
}
public function check(Request $request)
{
// This is the first installation step therefore it doesn't need to verify the stage
if ($request->getMethod() == 'POST')
{
$request->session()->put('install_stage', 2);
return redirect(route('install.database'));
}
$canContinue = true;
$runtimeMinimum = '7.0.0'; // this minimum is imposed by Laravel 5.5
$runtimeVersion = phpversion();
$phpIsValid = version_compare($runtimeVersion, $runtimeMinimum) >= 0;
if (!$phpIsValid)
{
$canContinue = false;
}
$requiredModules = [
'curl' => 'installer.php_modules.curl',
'pdo_mysql' => 'installer.php_modules.mysql',
'gd' => 'installer.php_modules.gd'
];
$availableModules = [];
foreach ($requiredModules as $key => $langString)
{
$availableModules[$key] = extension_loaded($key);
if (!$availableModules[$key])
{
$canContinue = false;
}
}
$uploadLimit = MiscHelper::convertToBytes(ini_get('upload_max_filesize'));
$postMaxSize = MiscHelper::convertToBytes(ini_get('post_max_size'));
$recommendedMinimum = 4 * 1024 * 1024;
return view('install.check', [
'available_modules' => $availableModules,
'can_continue' => $canContinue,
'php_is_valid' => $phpIsValid,
'php_version_current' => $runtimeVersion,
'php_version_required' => $runtimeMinimum,
'post_max_size' => ($postMaxSize / 1024 / 1024),
'post_max_size_warning' => $postMaxSize < $recommendedMinimum,
'recommended_minimum_upload' => ($recommendedMinimum / 1024 / 1024),
'upload_limit' => ($uploadLimit / 1024 / 1024),
'upload_limit_warning' => $uploadLimit < $recommendedMinimum,
'required_modules' => $requiredModules
]);
}
public function database(Request $request)
{
// Validate we're at the required stage
$stage = 2;
if (intval($request->session()->get('install_stage')) < $stage)
{
return redirect(route('install.check'));
}
// This is the first installation step therefore it doesn't need to verify the stage
if ($request->method() == 'POST')
{
@ -162,7 +101,7 @@ class InstallController extends Controller
// Default settings
$this->setConfigurationForNewSystems();
$request->session()->put('install_stage', 3);
$request->session()->put('install_stage', 2);
return redirect(route('install.administrator'));
}
catch (\Exception $ex)

View File

@ -14,9 +14,6 @@ use Illuminate\Support\Facades\Log;
class AppInstallation
{
private $baseDirectory;
private $environmentFilePath;
/**
* The application instance.
*
@ -33,8 +30,6 @@ class AppInstallation
public function __construct(Application $app)
{
$this->app = $app;
$this->baseDirectory = dirname(dirname(dirname(__DIR__)));
$this->environmentFilePath = sprintf('%s/.env', $this->baseDirectory);
}
public function handle(Request $request, Closure $next)
@ -51,6 +46,14 @@ class AppInstallation
// See if the successful flag has been written to the .env file
$isAppInstalled = MiscHelper::getEnvironmentSetting('APP_INSTALLED');
// See if the vendors are out-of-date
if ($this->isVendorUpdateRequired())
{
return $isAppInstalled
? redirect('/update')
: redirect('/install');
}
if ($request->is('install/*'))
{
// Already in the installer
@ -66,26 +69,40 @@ class AppInstallation
if ($isAppInstalled)
{
// See if an update is necessary
$this->updateDatabaseIfRequired();
if ($this->updateDatabaseIfRequired())
{
return redirect($request->fullUrl());
}
// App is configured, continue on
return $next($request);
}
return redirect(route('install.check'));
return redirect(route('install.database'));
}
private function generateAppKey()
{
// Generate an application key and store to the .env file
if (!file_exists($this->environmentFilePath))
if (!file_exists(MiscHelper::getEnvironmentFilePath()))
{
$key = MiscHelper::randomString(32);
file_put_contents($this->environmentFilePath, sprintf('APP_KEY=%s', $key) . PHP_EOL);
file_put_contents(MiscHelper::getEnvironmentFilePath(), sprintf('APP_KEY=%s', $key) . PHP_EOL);
app('config')->set(['app' => ['key' => $key]]);
}
}
private function isVendorUpdateRequired()
{
$vendorsVersionFilename = $this->app->basePath('vendor/version.txt');
if (!file_exists($vendorsVersionFilename))
{
return true;
}
return trim(file_get_contents($vendorsVersionFilename)) != trim(config('app.version'));
}
private function updateDatabaseIfRequired()
{
$versionNumber = UserConfig::getOrCreateModel('app_version');
@ -138,6 +155,10 @@ class AppInstallation
// Rebuild the permissions cache
$helper = new PermissionsHelper();
$helper->rebuildCache();
return true;
}
return false;
}
}

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

@ -65,6 +65,16 @@ 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['external_service_id'] = 'sometimes|required';
break;
}
break;
@ -103,6 +113,16 @@ 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['external_service_id'] = 'sometimes|required';
break;
}
break;
}

View File

@ -2,6 +2,7 @@
namespace App;
use App\AlbumSources\IAlbumSource;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notifiable;
@ -110,9 +111,14 @@ class Photo extends Model
public function thumbnailUrl($thumbnailName = null, $cacheBust = true)
{
$url = $this->album->getAlbumSource()->getUrlToPhoto($this, $thumbnailName);
/** @var IAlbumSource $source */
$source = $this->album->getAlbumSource();
$sourceConfiguration = $source->getConfiguration();
if ($cacheBust)
$url = $source->getUrlToPhoto($this, $thumbnailName);
// Cache busting doesn't work with S3 signed URLs
if ($cacheBust && !$sourceConfiguration->s3_signed_urls)
{
// Append the timestamp of the last update to avoid browser caching
$theDate = is_null($this->updated_at) ? $this->created_at : $this->updated_at;

View File

@ -11,9 +11,8 @@ use App\Policies\AlbumPolicy;
use App\Policies\PhotoPolicy;
use App\Policies\UserPolicy;
use App\User;
use function GuzzleHttp\Psr7\mimetype_from_extension;
use Illuminate\Support\Facades\Gate;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
class AuthServiceProvider extends ServiceProvider
{
@ -66,6 +65,10 @@ class AuthServiceProvider extends ServiceProvider
{
return $this->userHasAdminPermission($user, 'manage-labels');
});
Gate::define('admin:manage-services', function ($user)
{
return $this->userHasAdminPermission($user, 'manage-services');
});
Gate::define('admin:manage-storage', function ($user)
{
return $this->userHasAdminPermission($user, 'manage-storage');

View File

@ -0,0 +1,269 @@
<?php
namespace App\Services;
use App\Exceptions\DropboxRetryException;
use App\Storage;
use Illuminate\Http\Request;
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 authoriseUrl(Storage $storage)
{
$service = $storage->externalService;
$redirectUrl = $this->callbackUrl();
return sprintf(
'%s?client_id=%s&response_type=code&redirect_uri=%s&state=%s',
$this->config['authorise_url'],
urlencode(decrypt($service->app_id)),
urlencode($redirectUrl),
urlencode(encrypt($storage->id))
);
}
public function callbackUrl()
{
return route('services.authoriseDropbox');
}
public function deleteFile($pathOnStorage)
{
$dropboxData = ['path' => $pathOnStorage];
$deleteResult = $this->sendRequest(
$this->config['delete_url'],
'POST',
$dropboxData,
[
'http_headers' => [
'Content-Type: application/json'
],
'post_body_is_json' => true
]
);
Log::debug('DropboxService - response to deleteFile.', ['response' => $deleteResult, 'path' => $pathOnStorage]);
}
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
]
);
}
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
*/
public function setAccessToken(string $accessToken)
{
$this->accessToken = $accessToken;
}
public function uploadFile($pathToFileToUpload, $pathOnStorage)
{
$dropboxArgs = [
'path' => $pathOnStorage,
'mode' => 'overwrite',
'mute' => true
];
$shouldRetry = true;
while ($shouldRetry)
{
try
{
$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
]
);
$shouldRetry = false;
Log::debug('DropboxService - response to uploadFile.', ['response' => $uploadResult, 'path' => $pathOnStorage]);
}
catch (DropboxRetryException $dre)
{
// Retry - leave shouldRetry as true
Log::debug('DropboxService - Dropbox reported a lock/rate limit and requested to retry');
sleep(2);
}
catch (\Exception $ex)
{
$shouldRetry = false;
Log::debug('DropboxService - exception in uploadFile.', ['exception' => $ex->getMessage()]);
}
}
}
private function convertAuthorisationCodeToToken($authorisationCode, Storage $storage)
{
$service = $storage->externalService;
$credentials = sprintf('%s:%s', decrypt($service->app_id), decrypt($service->app_secret));
$redirectUrl = $this->callbackUrl();
$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 = [])
{
$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);
Log::info(sprintf('DropboxService - %s: %s', strtoupper($method), $url));
Log::debug('DropboxService - HTTP headers:', $httpHeaders);
if (!is_null($postData))
{
if ($postOptions['post_body_is_json'])
{
// Only log a post body if we have one and it's in JSON format (i.e. not a file upload)
Log::debug('DropboxService - Body: ', $postData);
$postData = json_encode($postData);
}
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
}
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
Log::info(sprintf('DropboxService - 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);
}
try
{
if ($httpCode != 200 && $httpCode != 304)
{
if ($httpCode == 429)
{
throw new DropboxRetryException($httpCode, new \Exception(sprintf('Exception from Dropbox: %s', $result)));
}
throw new \Exception(sprintf('Exception from Dropbox: %s', $result));
}
return $postOptions['response_body_is_json']
? json_decode($result)
: $result;
}
finally
{
curl_close($ch);
}
}
}

View File

@ -6,11 +6,34 @@ class GiteaService
{
private $cacheFile = null;
private $config = [];
private $currentVersionNumber;
public function __construct()
public function __construct(array $config = null, $currentVersionNumber = null)
{
$this->config = config('services.gitea');
$this->cacheFile = storage_path('app/gitea_cache.txt');
// This class is used in the Bootstrapper to fetch release information, therefore
// we need to check if the Laravel helper functions are loaded before we use them
if (is_null($config) && function_exists('config'))
{
$this->config = config('services.gitea');
}
else
{
$this->config = $config;
}
if (is_null($currentVersionNumber) && function_exists('config'))
{
$this->currentVersionNumber = config('app.version');
}
else
{
$this->currentVersionNumber = $currentVersionNumber;
}
if (function_exists('storage_path'))
{
$this->cacheFile = storage_path('app/gitea_cache.txt');
}
}
public function checkForLatestRelease()
@ -26,7 +49,7 @@ class GiteaService
{
// Lookup and store the version information
$statusCode = -1;
$result = $this->getLatestReleaseFromGitea($statusCode);
$result = $this->getReleasesFromGitea($statusCode);
if ($statusCode == 200)
{
@ -51,6 +74,31 @@ class GiteaService
return $cacheData;
}
public function getSpecificRelease($versionNumber)
{
$cacheData = null;
// Lookup and store the version information
$statusCode = -1;
$result = $this->getReleasesFromGitea($statusCode);
if ($statusCode == 200)
{
$releases = json_decode($result[1]);
$foundRelease = null;
foreach ($releases as $release)
{
if (version_compare($release->tag_name, $versionNumber) === 0)
{
return $release;
}
}
}
return null;
}
private function doesCacheExist()
{
$exists = file_exists($this->cacheFile);
@ -75,10 +123,10 @@ class GiteaService
return json_decode(file_get_contents($this->cacheFile));
}
private function getLatestReleaseFromGitea(&$statusCode)
private function getReleasesFromGitea(&$statusCode)
{
$httpHeaders = [
sprintf('User-Agent: aheathershaw/blue-twilight (v%s)', config('app.version'))
sprintf('User-Agent: aheathershaw/blue-twilight (v%s)', $this->currentVersionNumber)
];
if (isset($this->config['api_key']) && !empty($this->config['api_key']))
@ -106,7 +154,11 @@ class GiteaService
private function setCacheData($data)
{
file_put_contents($this->cacheFile, json_encode(get_object_vars($data)));
if (!is_null($this->cacheFile))
{
file_put_contents($this->cacheFile, json_encode(get_object_vars($data)));
}
return $data;
}
}

View File

@ -6,12 +6,10 @@ use App\Album;
use App\AlbumSources\IAlbumSource;
use App\AlbumSources\IAnalysisQueueSource;
use App\Helpers\AnalysisQueueHelper;
use App\Helpers\FileHelper;
use App\Helpers\ImageHelper;
use App\Helpers\MiscHelper;
use App\Helpers\ThemeHelper;
use App\Photo;
use Symfony\Component\HttpFoundation\File\File;
class PhotoService
{
@ -207,7 +205,7 @@ class PhotoService
$stream = $this->albumSource->fetchPhotoContent($this->photo);
$stream->rewind();
while (!$stream->feof())
while (!$stream->eof())
{
fwrite($photoHandle, $stream->read(4096));
}
@ -326,7 +324,7 @@ class PhotoService
$stream = $this->albumSource->fetchPhotoContent($this->photo);
$stream->rewind();
while (!$stream->feof())
while (!$stream->eof())
{
fwrite($photoHandle, $stream->read(4096));
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Services\Rackspace\Identity\v2;
use OpenStack\Common\Api\ApiInterface;
class Api implements ApiInterface
{
public function postTokenWithApiKey(): array
{
return [
'method' => 'POST',
'path' => 'tokens',
'params' => [
'username' => [
'type' => 'string',
'required' => true,
'path' => 'auth.RAX-KSKEY:apiKeyCredentials',
],
'apiKey' => [
'type' => 'string',
'required' => true,
'path' => 'auth.RAX-KSKEY:apiKeyCredentials',
],
'tenantId' => [
'type' => 'string',
'path' => 'auth',
],
'tenantName' => [
'type' => 'string',
'path' => 'auth',
],
],
];
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Services\Rackspace\Identity\v2;
use GuzzleHttp\ClientInterface;
use OpenStack\Common\Auth\IdentityService;
use OpenStack\Common\Service\AbstractService;
use OpenStack\Identity\v2\Models\Catalog;
use OpenStack\Identity\v2\Models\Token;
/**
* Represents the Rackspace Identity v2 service.
*
* @property Api $api
*/
class Service extends AbstractService implements IdentityService
{
public static function factory(ClientInterface $client): self
{
return new static($client, new Api());
}
public function authenticate(array $options = []): array
{
$definition = $this->api->postTokenWithApiKey();
$response = $this->execute($definition, array_intersect_key($options, $definition['params']));
$token = $this->model(Token::class, $response);
$serviceUrl = $this->model(Catalog::class, $response)->getServiceUrl(
$options['catalogName'],
$options['catalogType'],
$options['region'],
$options['urlType']
);
return [$token, $serviceUrl];
}
/**
* Generates a new authentication token.
*
* @param array $options {@see \OpenStack\Identity\v2\Api::postToken}
*
* @return Token
*/
public function generateToken(array $options = []): Token
{
$response = $this->execute($this->api->postTokenWithApiKey(), $options);
return $this->model(Token::class, $response);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Services\Rackspace\ObjectStoreCdn\v1;
use OpenStack\Common\Api\AbstractApi;
class Api extends AbstractApi
{
public function __construct()
{
$this->params = new Params();
}
public function getAccount(): array
{
return [
'method' => 'GET',
'path' => '',
'params' => [
'format' => $this->params->format()
]
];
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Services\Rackspace\ObjectStoreCdn\v1\Models;
use OpenStack\Common\Resource\OperatorResource;
class Container extends OperatorResource
{
/** @var bool */
public $cdn_enabled;
/** @var string */
public $cdn_ssl_uri;
/** @var string */
public $name;
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Services\Rackspace\ObjectStoreCdn\v1;
use OpenStack\Common\Api\AbstractParams;
class Params extends AbstractParams
{
public function format(): array
{
return [
'location' => self::QUERY,
'type' => self::STRING_TYPE,
'description' => 'Defines the format of the collection. Will always default to `json`.',
];
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Services\Rackspace\ObjectStoreCdn\v1;
use App\Services\Rackspace\ObjectStoreCdn\v1\Models\Container;
use OpenStack\Common\Service\AbstractService;
/**
* Represents the Rackspace Cloud Files CDN v1 service.
*
* @property Api $api
*/
class Service extends AbstractService
{
/**
* Retrieves a collection of CDN-enabled container resources in a generator format.
*
* @param array $options {@see Api::getAccount()}
* @param callable|null $mapFn allows a function to be mapped over each element in the collection
*/
public function listContainers(array $options = [], callable $mapFn = null): \Generator
{
$options = array_merge($options, ['format' => 'json']);
return $this->model(Container::class)->enumerate($this->api->getAccount(), $options, $mapFn);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Services\Rackspace;
use App\Services\Rackspace\ObjectStoreCdn\v1\Service as ObjectStoreCdnService;
use OpenStack\Common\Service\Builder;
use OpenStack\OpenStack;
class Rackspace extends OpenStack
{
/** @var Builder */
private $rsBuilder;
public function __construct(array $options = [], Builder $builder = null)
{
parent::__construct($options, $builder);
$this->rsBuilder = $builder ?: new Builder($options, 'App\Services\Rackspace');
}
/**
* Creates a new Object Store (Rackspace CDN) v1 service.
*
* @param array $options options that will be used in configuring the service
*/
public function objectStoreCdnV1(array $options = []): ObjectStoreCdnService
{
$defaults = ['catalogName' => 'cloudFilesCDN', 'catalogType' => 'rax:object-cdn'];
return $this->rsBuilder->createService('ObjectStoreCdn\\v1', array_merge($defaults, $options));
}
}

View File

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

View File

@ -1,62 +1,60 @@
{
"name": "pandy06269/blue-twilight",
"name": "aheathershaw/blue-twilight",
"description": "Blue Twilight - self-hosted photo gallery software.",
"keywords": ["blue", "twilight", "photo", "photograph", "portfolio", "gallery", "self-hosted"],
"license": "MIT",
"type": "project",
"require": {
"php": ">=7.0.0",
"php": ">=7.2.0",
"ext-curl": "*",
"ext-json": "*",
"laravel/framework": "5.5.*",
"rackspace/php-opencloud": "^1.16",
"aws/aws-sdk-php": "^3.134",
"doctrine/dbal": "^2.5",
"aws/aws-sdk-php": "^3.19",
"laravel/socialite": "^3.0",
"php-amqplib/php-amqplib": "^2.9"
"guzzlehttp/guzzle": "^6.5",
"laravel/framework": "^6.0",
"laravel/socialite": "^4.3",
"php-amqplib/php-amqplib": "^2.9",
"php-opencloud/openstack": "^3.0"
},
"require-dev": {
"filp/whoops": "~2.0",
"fzaninotto/faker": "~1.4",
"mockery/mockery": "0.9.*",
"phpunit/phpunit": "~6.0",
"symfony/css-selector": "3.1.*",
"symfony/dom-crawler": "3.1.*"
"facade/ignition": "^1.4",
"fzaninotto/faker": "^1.9.1",
"mockery/mockery": "^1.0",
"phpunit/phpunit": "^8.0"
},
"autoload": {
"classmap": [
"database"
"database/data_migrations",
"database/seeds",
"database/factories"
],
"psr-4": {
"App\\": "app/"
}
},
"autoload-dev": {
"classmap": [
]
"psr-4": {
"Tests\\": "tests/"
}
},
"minimum-stability": "dev",
"prefer-stable": true,
"scripts": {
"post-root-package-install": [
"php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"php artisan key:generate"
],
"post-install-cmd": [
"Illuminate\\Foundation\\ComposerScripts::postInstall",
"php artisan optimize"
],
"post-update-cmd": [
"Illuminate\\Foundation\\ComposerScripts::postUpdate",
"php artisan optimize"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover"
"@php artisan package:discover --ansi"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi"
]
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"discard-changes": true
"discard-changes": true,
"sort-packages": true
}
}

3278
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
return [
// Version number of Blue Twilight
'version' => '2.2.0-beta.1',
'version' => '2.2.0-beta.2',
/*
|--------------------------------------------------------------------------

52
config/hashing.php Normal file
View File

@ -0,0 +1,52 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Hash Driver
|--------------------------------------------------------------------------
|
| This option controls the default hash driver that will be used to hash
| passwords for your application. By default, the bcrypt algorithm is
| used; however, you remain free to modify this option if you wish.
|
| Supported: "bcrypt", "argon"
|
*/
'driver' => 'bcrypt',
/*
|--------------------------------------------------------------------------
| Bcrypt Options
|--------------------------------------------------------------------------
|
| Here you may specify the configuration options that should be used when
| passwords are hashed using the Bcrypt algorithm. This will allow you
| to control the amount of time it takes to hash the given password.
|
*/
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 10),
],
/*
|--------------------------------------------------------------------------
| Argon Options
|--------------------------------------------------------------------------
|
| Here you may specify the configuration options that should be used when
| passwords are hashed using the Argon algorithm. These will allow you
| to control the amount of time it takes to hash the given password.
|
*/
'argon' => [
'memory' => 1024,
'threads' => 2,
'time' => 2,
],
];

81
config/logging.php Normal file
View File

@ -0,0 +1,81 @@
<?php
use Monolog\Handler\StreamHandler;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that gets used when writing
| messages to the logs. The name specified in this option should match
| one of the channels defined in the "channels" configuration array.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Out of
| the box, Laravel uses the Monolog PHP logging library. This gives
| you a variety of powerful log handlers / formatters to utilize.
|
| Available Drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog",
| "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['single'],
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 7,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => 'critical',
],
'stderr' => [
'driver' => 'monolog',
'handler' => StreamHandler::class,
'with' => [
'stream' => 'php://stderr',
],
],
'syslog' => [
'driver' => 'syslog',
'level' => 'debug',
],
'errorlog' => [
'driver' => 'errorlog',
'level' => 'debug',
],
],
];

View File

@ -19,12 +19,24 @@ return [
'download_token_lifetime' => 300
],
'dropbox' => [
'authorise_url' => 'https://www.dropbox.com/oauth2/authorize',
'delete_url' => 'https://api.dropboxapi.com/2/files/delete_v2',
'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'
],
'gitea' => [
'api_url' => 'https://apps.andysh.uk/api/v1',
'api_url' => env('GITEA_API_URL', 'https://apps.andysh.uk/api/v1'),
'cache_time_seconds' => 3600,
'releases_url' => 'https://apps.andysh.uk/%s/%s/releases',
'repo_name' => 'blue-twilight',
'repo_owner' => 'aheathershaw'
'releases_url' => env('GITEA_RELEASES_URL', 'https://apps.andysh.uk/%s/%s/releases'),
'repo_name' => env('GITEA_REPO_NAME', 'blue-twilight'),
'repo_owner' => env('GITEA_REPO_OWNER', 'aheathershaw')
],
'rackspace' => [
'authentication_url' => 'https://identity.api.rackspacecloud.com/v2.0'
],
'recaptcha' => [

View File

@ -0,0 +1,112 @@
<?php
use App\Configuration;
use App\DataMigration;
use App\ExternalService;
use App\Facade\UserConfig;
use Illuminate\Support\Facades\DB;
class DataMigrationV2_2_0_beta_2 extends DataMigration
{
public function getVersion()
{
return '2.2.0-beta.2';
}
public function run($currentVersion)
{
DB::transaction(function()
{
$this->moveFacebookSettingsToService();
$this->moveGoogleSettingsToService();
$this->moveTwitterSettingsToService();
});
}
private function moveFacebookSettingsToService()
{
/** @var Configuration $facebookAppID */
$facebookAppID = Configuration::where(['key' => 'facebook_app_id'])->first();
/** @var Configuration $facebookAppID */
$facebookAppSecret = Configuration::where(['key' => 'facebook_app_secret'])->first();
if (is_null($facebookAppID) || is_null($facebookAppSecret))
{
return;
}
$externalService = ExternalService::create([
'service_type' => ExternalService::FACEBOOK,
'name' => 'Facebook (migrated from settings)',
'app_id' => encrypt($facebookAppID->value), // app ID needs to be encrypted now
'app_secret' => $facebookAppSecret->value // secret is already encrypted
]);
/** @var ExternalService $facebookExternalServiceConfig */
$facebookExternalServiceConfig = UserConfig::getOrCreateModel('facebook_external_service_id');
$facebookExternalServiceConfig->value = $externalService->id;
$facebookExternalServiceConfig->save();
$facebookAppID->delete();
$facebookAppSecret->delete();
}
private function moveGoogleSettingsToService()
{
/** @var Configuration $googleAppID */
$googleAppID = Configuration::where(['key' => 'google_app_id'])->first();
/** @var Configuration $facebookAppID */
$googleAppSecret = Configuration::where(['key' => 'google_app_secret'])->first();
if (is_null($googleAppID) || is_null($googleAppSecret))
{
return;
}
$externalService = ExternalService::create([
'service_type' => ExternalService::GOOGLE,
'name' => 'Google (migrated from settings)',
'app_id' => encrypt($googleAppID->value), // app ID needs to be encrypted now
'app_secret' => $googleAppSecret->value // secret is already encrypted
]);
/** @var ExternalService $googleExternalServiceConfig */
$googleExternalServiceConfig = UserConfig::getOrCreateModel('google_external_service_id');
$googleExternalServiceConfig->value = $externalService->id;
$googleExternalServiceConfig->save();
$googleAppID->delete();
$googleAppSecret->delete();
}
private function moveTwitterSettingsToService()
{
/** @var Configuration $twitterAppID */
$twitterAppID = Configuration::where(['key' => 'twitter_app_id'])->first();
/** @var Configuration $facebookAppID */
$twitterAppSecret = Configuration::where(['key' => 'twitter_app_secret'])->first();
if (is_null($twitterAppID) || is_null($twitterAppSecret))
{
return;
}
$externalService = ExternalService::create([
'service_type' => ExternalService::TWITTER,
'name' => 'Twitter (migrated from settings)',
'app_id' => encrypt($twitterAppID->value), // app ID needs to be encrypted now
'app_secret' => $twitterAppSecret->value // secret is already encrypted
]);
/** @var ExternalService $twitterExternalServiceConfig */
$twitterExternalServiceConfig = UserConfig::getOrCreateModel('twitter_external_service_id');
$twitterExternalServiceConfig->value = $externalService->id;
$twitterExternalServiceConfig->save();
$twitterAppID->delete();
$twitterAppSecret->delete();
}
}

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

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

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateExternalServicesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('external_services', function (Blueprint $table) {
$table->increments('id');
$table->string('service_type', 50);
$table->string('name');
$table->text('app_id')->nullable();
$table->text('app_secret')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('external_services');
}
}

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

@ -80,6 +80,14 @@ class PermissionsSeeder extends Seeder
'is_default' => false,
'sort_order' => 0
]);
// admin:manage-services = controls if external servies can be managed
DatabaseSeeder::createOrUpdate('permissions', [
'section' => 'admin',
'description' => 'manage-services',
'is_default' => false,
'sort_order' => 0
]);
}
private function seedAlbumPermissions()

View File

@ -0,0 +1,71 @@
<?php
namespace AppInstaller;
use App\Helpers\MiscHelper;
class AppRequirements
{
const STATUS_OK = 0;
const STATUS_WARNING = 1;
const STATUS_NOT_MET = 2;
public static function hasCurlLibrary()
{
return self::isModuleLoaded('curl') ? self::STATUS_OK : self::STATUS_NOT_MET;
}
public static function hasGdLibrary()
{
return self::isModuleLoaded('gd') ? self::STATUS_OK : self::STATUS_NOT_MET;
}
public static function hasMySqlClientLibrary()
{
return self::isModuleLoaded('pdo_mysql') ? self::STATUS_OK : self::STATUS_NOT_MET;
}
public static function maxPostRequestSize(&$status)
{
$bytes = self::getPhpIniValueAsBytes('post_max_size');
$recommendedMinimum = 4 * 1024 * 1024;
if ($bytes < $recommendedMinimum)
{
return self::STATUS_WARNING;
}
$status = sprintf('%0.2f MB', $bytes / 1024 / 1024);
return self::STATUS_OK;
}
public static function maxUploadSize(&$status)
{
$bytes = self::getPhpIniValueAsBytes('upload_max_filesize');
$recommendedMinimum = 4 * 1024 * 1024;
if ($bytes < $recommendedMinimum)
{
return self::STATUS_WARNING;
}
$status = sprintf('%0.2f MB', $bytes / 1024 / 1024);
return self::STATUS_OK;
}
public static function php72OrLater(&$status)
{
$status = phpversion();
return version_compare(phpversion(), '7.4.5', '>=') ? self::STATUS_OK : self::STATUS_NOT_MET;
}
private static function getPhpIniValueAsBytes($settingName)
{
return MiscHelper::convertToBytes(ini_get($settingName));
}
private static function isModuleLoaded($name)
{
return extension_loaded($name);
}
}

394
installer/Installer.php Normal file
View File

@ -0,0 +1,394 @@
<?php
namespace AppInstaller;
use App\Services\GiteaService;
use Illuminate\Support\Facades\Artisan;
/**
* This class handles the downloading and extracting of the vendors directory.
* Because Laravel and other vendors are not yet available, it uses "raw" PHP and the odd few classes within Blue
* Twilight, such as GiteaService.
*
* @package AppInstaller
*/
class Installer
{
/**
* Path to /app/config
* @var string
*/
private $configDir;
/**
* Path to /installer
* @var string
*/
private $installerDir;
/**
* True if we're upgrading Blue Twilight, false if it's a new install
* @var bool
*/
private $isUpgrade;
/**
* Path to / - the app's root
* @var string
*/
private $rootDir;
/**
* Path to /installer/temp
* @var string
*/
private $tempDir;
/**
* Path to /vendor
* @var string
*/
private $vendorDir;
/**
* @var string
*/
private $versionNumber;
/**
* Path to /installer/views
* @var string
*/
private $viewsDir;
public function __construct()
{
$this->installerDir = __DIR__;
$this->rootDir = dirname($this->installerDir);
$this->configDir = sprintf('%s/config', $this->rootDir);
$this->tempDir = sprintf('%s/temp', $this->installerDir);
$this->vendorDir = sprintf('%s/vendor', $this->rootDir);
$this->viewsDir = sprintf('%s/views', $this->installerDir);
$appConfig = require_once sprintf('%s/app.php', $this->configDir);
$this->versionNumber = $appConfig['version'];
}
public function handleRequest()
{
if (!isset($_GET['act']))
{
$this->checkInstallation();
}
else
{
switch (trim($_GET['act']))
{
case 'download':
$this->download();
return;
case 'extract':
$this->extract();
return;
case 'finalise':
$this->finalise();
return;
default:
throw new \Exception(sprintf('Action \'%s\' was not recognised.', $_GET['act']));
}
}
}
/**
* @param bool $isUpgrade
*/
public function setIsUpgrade(bool $isUpgrade): void
{
$this->isUpgrade = $isUpgrade;
}
protected function checkInstallation()
{
$requirements = [
'core:php72OrLater',
'php:hasCurlLibrary',
'php:hasMySqlClientLibrary',
'php:hasGdLibrary',
'config:maxUploadSize',
'config:maxPostRequestSize'
];
$requirementsGrouped = [];
$canInstall = true;
foreach ($requirements as $requirement)
{
$requirementSplit = explode(':', $requirement);
$groupName = $requirementSplit[0];
$functionName = $requirementSplit[1];
$status = null;
$result = call_user_func_array([AppRequirements::class, $functionName], [&$status]);
if ($result == AppRequirements::STATUS_NOT_MET)
{
$canInstall = false;
}
if (!array_key_exists($groupName, $requirementsGrouped))
{
$requirementsGrouped[$groupName] = [];
}
if (!array_key_exists($functionName, $requirementsGrouped[$groupName]))
{
$requirementsGrouped[$groupName][$functionName] = [
'result' => $result,
'status' => $status
];
}
}
$requirementGroupNames = [
'config' => 'PHP configuration',
'core' => 'Core requirements',
'php' => 'Required PHP modules'
];
$requirementNames = [
'hasCurlLibrary' => 'cURL Web Requests Library',
'hasGdLibrary' => 'GD Graphics Procesisng Library',
'hasMySqlClientLibrary' => 'MySQL PDO Data Access Library',
'maxPostRequestSize' => 'Maximum POST request size (recommended: 4 MB)',
'maxUploadSize' => 'Maximum file size allowed to upload (recommended: 4 MB)',
'php72OrLater' => 'Requires PHP 7.2.0 minimum',
];
$this->view('index', [
'appName' => 'Blue Twilight Installer',
'canInstall' => $canInstall,
'isUpgrade' => $this->isUpgrade,
'requirementGroupNames' => $requirementGroupNames,
'requirementNames' => $requirementNames,
'statusNotMet' => AppRequirements::STATUS_NOT_MET,
'statusOK' => AppRequirements::STATUS_OK,
'statusWarning' => AppRequirements::STATUS_WARNING,
'systemRequirements' => $requirementsGrouped
]);
}
protected function download()
{
$servicesConfig = require_once sprintf('%s/services.php', $this->configDir);
$versionNumber = sprintf('v%s', $this->versionNumber);
$gitea = new GiteaService($servicesConfig['gitea'], $versionNumber);
$releaseInfo = $gitea->getSpecificRelease($versionNumber);
if (is_null($releaseInfo))
{
throw new \Exception(sprintf('No release info found in Gitea for Blue Twilight version \'%s\'', $versionNumber));
}
else if (!isset($releaseInfo->assets))
{
throw new \Exception(sprintf('No assets found in Gitea for Blue Twilight version \'%s\'', $versionNumber));
}
$vendorsPrefix = 'vendors';
$vendorsSuffix = '.tar.gz';
$selectedAsset = null;
foreach ($releaseInfo->assets as $asset)
{
/*
Ignore anything that is not "vendors<something>.tar.gz" were the <something> is also optional - e.g.
vendors_2.1.2.tar.gz
vendors.tar.gz
but NOT 2.1.2_vendors.zip
*/
if (!starts_with($asset->name, $vendorsPrefix) || !ends_with($asset->name, $vendorsSuffix))
{
continue;
}
$selectedAsset = $asset;
break;
}
if (is_null($selectedAsset))
{
throw new \Exception(sprintf('No vendors.tar.gz found in Gitea for Blue Twilight version \'%s\'', $versionNumber));
}
$targetFileName = $this->getVendorsTempFileName();
$this->downloadFile($selectedAsset->browser_download_url, $targetFileName);
$this->json([
'result' => true,
'fileName' => $targetFileName
]);
}
protected function extract()
{
$targetFileName = $this->getVendorsTempFileName();
if (!file_exists($targetFileName) || !is_readable($targetFileName))
{
throw new \Exception(sprintf('The file \'%s\' does not exist or is not readable', $targetFileName));
}
$phar = new \PharData($targetFileName);
$phar->extractTo($this->rootDir, null, true);
// We should always have a vendor/autoload.php
$vendorsTestFile = $this->getVendorsAutoloadFileName();
if (file_exists($vendorsTestFile))
{
$this->writeVersionFile();
$this->json([
'result' => true,
'testFile' => $vendorsTestFile
]);
}
else
{
throw new \Exception('The extraction failed');
}
}
protected function finalise()
{
$result = [
'cacheFilesRemoved' => 0,
'envFileCreated' => false
];
$result['cacheFilesRemoved'] = $this->clearCacheIfExists();
$result['envFileCreated'] = !$this->isUpgrade && $this->createEnvFileIfNotExist();
require sprintf('%s/bootstrap/autoload.php', $this->rootDir);
$app = require_once sprintf('%s/bootstrap/app.php', $this->rootDir);
$kernel = $app->make(\Illuminate\Contracts\Console\Kernel::class);
$kernel->bootstrap();
if ($result['envFileCreated'])
{
Artisan::call('key:generate');
}
$kernel->terminate(null, null);
$this->json($result);
}
private function downloadFile($sourceURL, $targetFilename)
{
$urlHandle = @fopen($sourceURL, 'r');
$tempFilename = @fopen($targetFilename, 'w');
if ($urlHandle === false)
{
throw new \Exception(sprintf('Failed downloading the file from %s', $sourceURL));
}
else if ($tempFilename === false)
{
throw new \Exception(sprintf('Failed opening the file \'%s\' for writing', $targetFilename));
}
while (!feof($urlHandle))
{
$buffer = fread($urlHandle, 8192);
fwrite($tempFilename, $buffer);
}
@fclose($urlHandle);
@fclose($tempFilename);
}
private function clearCacheIfExists()
{
$filesDeleted = 0;
$cacheDir = sprintf('%s/bootstrap/cache', $this->rootDir);
$di = new \DirectoryIterator($cacheDir);
foreach ($di as $fileInfo)
{
if (@unlink($fileInfo->getRealPath()))
{
$filesDeleted++;
}
}
return $filesDeleted;
}
private function createEnvFileIfNotExist()
{
$envFile = $this->getEnvFileName();
if (!file_exists($envFile))
{
copy($this->getEnvExampleFileName(), $envFile);
return true;
}
return false;
}
private function getEnvExampleFileName()
{
return sprintf('%s/.env.example', $this->rootDir);
}
private function getEnvFileName()
{
return sprintf('%s/.env', $this->rootDir);
}
private function getVendorsAutoloadFileName()
{
return sprintf('%s/autoload.php', $this->vendorDir);
}
private function getVendorsTempFileName()
{
return sprintf('%s/vendors.tar.gz', $this->tempDir);
}
private function getVendorsVersionFileName()
{
return sprintf('%s/version.txt', $this->vendorDir);
}
private function json($data)
{
echo json_encode($data);
}
private function view($name, array $viewData = [])
{
$viewFile = sprintf('%s/%s.php', $this->viewsDir, $name);
if (!file_exists($viewFile) || !is_readable($viewFile))
{
throw new \Exception(sprintf('ERROR: View file \'%s\' does not exist.', $viewFile));
}
// Provide keys as variables - e.g. $viewData['something'] becomes accessible via $something
extract($viewData);
require_once $viewFile;
}
private function writeVersionFile()
{
file_put_contents($this->getVendorsVersionFileName(), $this->versionNumber . PHP_EOL);
}
}

26
installer/composer.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "aheathershaw/blue-twilight-installer",
"description": "Installer for Blue Twilight - self-hosted photo gallery software.",
"keywords": [
"blue",
"twilight",
"photo",
"photograph",
"portfolio",
"gallery",
"self-hosted"
],
"license": "MIT",
"type": "project",
"require": {
"php": ">=7.2.0",
"ext-curl": "*",
"ext-json": "*"
},
"autoload": {
"psr-4": {
"App\\": "../app/",
"AppInstaller\\": "./"
}
}
}

29
installer/helpers.php Normal file
View File

@ -0,0 +1,29 @@
<?php
function ends_with($stringToCheck, $stringToFind)
{
return strlen($stringToCheck) >= strlen($stringToFind) &&
substr(strtolower($stringToCheck), strlen($stringToCheck) - strlen($stringToFind), strlen($stringToFind)) == strtolower($stringToFind);
}
/**
* A crude implementation of a .env reader to allow the installer to have overriden values from .env.install.
* @param $key
* @param null $default
*/
function env($key, $default = null)
{
$envFilePath = sprintf('%s/.env.install', dirname(__DIR__));
if (!file_exists($envFilePath))
{
return $default;
}
return \App\Helpers\MiscHelper::getEnvironmentSetting($key, $envFilePath) ?? $default;
}
function starts_with($stringToCheck, $stringToFind)
{
return strlen($stringToCheck) >= strlen($stringToFind) &&
substr(strtolower($stringToCheck), 0, strlen($stringToFind)) == strtolower($stringToFind);
}

0
installer/temp/.gitignore vendored Normal file
View File

7
installer/vendor/autoload.php vendored Normal file
View File

@ -0,0 +1,7 @@
<?php
// autoload.php @generated by Composer
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInitae1de26c658d13c195b98449ea1bf6a1::getLoader();

View File

@ -0,0 +1,445 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Autoload;
/**
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
*
* $loader = new \Composer\Autoload\ClassLoader();
*
* // register classes with namespaces
* $loader->add('Symfony\Component', __DIR__.'/component');
* $loader->add('Symfony', __DIR__.'/framework');
*
* // activate the autoloader
* $loader->register();
*
* // to enable searching the include path (eg. for PEAR packages)
* $loader->setUseIncludePath(true);
*
* In this example, if you try to use a class in the Symfony\Component
* namespace or one of its children (Symfony\Component\Console for instance),
* the autoloader will first look for the class under the component/
* directory, and it will then fallback to the framework/ directory if not
* found before giving up.
*
* This class is loosely based on the Symfony UniversalClassLoader.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Jordi Boggiano <j.boggiano@seld.be>
* @see http://www.php-fig.org/psr/psr-0/
* @see http://www.php-fig.org/psr/psr-4/
*/
class ClassLoader
{
// PSR-4
private $prefixLengthsPsr4 = array();
private $prefixDirsPsr4 = array();
private $fallbackDirsPsr4 = array();
// PSR-0
private $prefixesPsr0 = array();
private $fallbackDirsPsr0 = array();
private $useIncludePath = false;
private $classMap = array();
private $classMapAuthoritative = false;
private $missingClasses = array();
private $apcuPrefix;
public function getPrefixes()
{
if (!empty($this->prefixesPsr0)) {
return call_user_func_array('array_merge', $this->prefixesPsr0);
}
return array();
}
public function getPrefixesPsr4()
{
return $this->prefixDirsPsr4;
}
public function getFallbackDirs()
{
return $this->fallbackDirsPsr0;
}
public function getFallbackDirsPsr4()
{
return $this->fallbackDirsPsr4;
}
public function getClassMap()
{
return $this->classMap;
}
/**
* @param array $classMap Class to filename map
*/
public function addClassMap(array $classMap)
{
if ($this->classMap) {
$this->classMap = array_merge($this->classMap, $classMap);
} else {
$this->classMap = $classMap;
}
}
/**
* Registers a set of PSR-0 directories for a given prefix, either
* appending or prepending to the ones previously set for this prefix.
*
* @param string $prefix The prefix
* @param array|string $paths The PSR-0 root directories
* @param bool $prepend Whether to prepend the directories
*/
public function add($prefix, $paths, $prepend = false)
{
if (!$prefix) {
if ($prepend) {
$this->fallbackDirsPsr0 = array_merge(
(array) $paths,
$this->fallbackDirsPsr0
);
} else {
$this->fallbackDirsPsr0 = array_merge(
$this->fallbackDirsPsr0,
(array) $paths
);
}
return;
}
$first = $prefix[0];
if (!isset($this->prefixesPsr0[$first][$prefix])) {
$this->prefixesPsr0[$first][$prefix] = (array) $paths;
return;
}
if ($prepend) {
$this->prefixesPsr0[$first][$prefix] = array_merge(
(array) $paths,
$this->prefixesPsr0[$first][$prefix]
);
} else {
$this->prefixesPsr0[$first][$prefix] = array_merge(
$this->prefixesPsr0[$first][$prefix],
(array) $paths
);
}
}
/**
* Registers a set of PSR-4 directories for a given namespace, either
* appending or prepending to the ones previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param array|string $paths The PSR-4 base directories
* @param bool $prepend Whether to prepend the directories
*
* @throws \InvalidArgumentException
*/
public function addPsr4($prefix, $paths, $prepend = false)
{
if (!$prefix) {
// Register directories for the root namespace.
if ($prepend) {
$this->fallbackDirsPsr4 = array_merge(
(array) $paths,
$this->fallbackDirsPsr4
);
} else {
$this->fallbackDirsPsr4 = array_merge(
$this->fallbackDirsPsr4,
(array) $paths
);
}
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
// Register directories for a new namespace.
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = (array) $paths;
} elseif ($prepend) {
// Prepend directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
(array) $paths,
$this->prefixDirsPsr4[$prefix]
);
} else {
// Append directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
$this->prefixDirsPsr4[$prefix],
(array) $paths
);
}
}
/**
* Registers a set of PSR-0 directories for a given prefix,
* replacing any others previously set for this prefix.
*
* @param string $prefix The prefix
* @param array|string $paths The PSR-0 base directories
*/
public function set($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr0 = (array) $paths;
} else {
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
}
}
/**
* Registers a set of PSR-4 directories for a given namespace,
* replacing any others previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param array|string $paths The PSR-4 base directories
*
* @throws \InvalidArgumentException
*/
public function setPsr4($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr4 = (array) $paths;
} else {
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = (array) $paths;
}
}
/**
* Turns on searching the include path for class files.
*
* @param bool $useIncludePath
*/
public function setUseIncludePath($useIncludePath)
{
$this->useIncludePath = $useIncludePath;
}
/**
* Can be used to check if the autoloader uses the include path to check
* for classes.
*
* @return bool
*/
public function getUseIncludePath()
{
return $this->useIncludePath;
}
/**
* Turns off searching the prefix and fallback directories for classes
* that have not been registered with the class map.
*
* @param bool $classMapAuthoritative
*/
public function setClassMapAuthoritative($classMapAuthoritative)
{
$this->classMapAuthoritative = $classMapAuthoritative;
}
/**
* Should class lookup fail if not found in the current class map?
*
* @return bool
*/
public function isClassMapAuthoritative()
{
return $this->classMapAuthoritative;
}
/**
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
*
* @param string|null $apcuPrefix
*/
public function setApcuPrefix($apcuPrefix)
{
$this->apcuPrefix = function_exists('apcu_fetch') && ini_get('apc.enabled') ? $apcuPrefix : null;
}
/**
* The APCu prefix in use, or null if APCu caching is not enabled.
*
* @return string|null
*/
public function getApcuPrefix()
{
return $this->apcuPrefix;
}
/**
* Registers this instance as an autoloader.
*
* @param bool $prepend Whether to prepend the autoloader or not
*/
public function register($prepend = false)
{
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
}
/**
* Unregisters this instance as an autoloader.
*/
public function unregister()
{
spl_autoload_unregister(array($this, 'loadClass'));
}
/**
* Loads the given class or interface.
*
* @param string $class The name of the class
* @return bool|null True if loaded, null otherwise
*/
public function loadClass($class)
{
if ($file = $this->findFile($class)) {
includeFile($file);
return true;
}
}
/**
* Finds the path to the file where the class is defined.
*
* @param string $class The name of the class
*
* @return string|false The path if found, false otherwise
*/
public function findFile($class)
{
// class map lookup
if (isset($this->classMap[$class])) {
return $this->classMap[$class];
}
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
return false;
}
if (null !== $this->apcuPrefix) {
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
if ($hit) {
return $file;
}
}
$file = $this->findFileWithExtension($class, '.php');
// Search for Hack files if we are running on HHVM
if (false === $file && defined('HHVM_VERSION')) {
$file = $this->findFileWithExtension($class, '.hh');
}
if (null !== $this->apcuPrefix) {
apcu_add($this->apcuPrefix.$class, $file);
}
if (false === $file) {
// Remember that this class does not exist.
$this->missingClasses[$class] = true;
}
return $file;
}
private function findFileWithExtension($class, $ext)
{
// PSR-4 lookup
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
$first = $class[0];
if (isset($this->prefixLengthsPsr4[$first])) {
$subPath = $class;
while (false !== $lastPos = strrpos($subPath, '\\')) {
$subPath = substr($subPath, 0, $lastPos);
$search = $subPath.'\\';
if (isset($this->prefixDirsPsr4[$search])) {
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
foreach ($this->prefixDirsPsr4[$search] as $dir) {
if (file_exists($file = $dir . $pathEnd)) {
return $file;
}
}
}
}
}
// PSR-4 fallback dirs
foreach ($this->fallbackDirsPsr4 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
return $file;
}
}
// PSR-0 lookup
if (false !== $pos = strrpos($class, '\\')) {
// namespaced class name
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
} else {
// PEAR-like class name
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
}
if (isset($this->prefixesPsr0[$first])) {
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
if (0 === strpos($class, $prefix)) {
foreach ($dirs as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
}
}
}
// PSR-0 fallback dirs
foreach ($this->fallbackDirsPsr0 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
// PSR-0 include paths.
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
return $file;
}
return false;
}
}
/**
* Scope isolated include.
*
* Prevents access to $this/self from included files.
*/
function includeFile($file)
{
include $file;
}

21
installer/vendor/composer/LICENSE vendored Normal file
View File

@ -0,0 +1,21 @@
Copyright (c) Nils Adermann, Jordi Boggiano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -0,0 +1,9 @@
<?php
// autoload_classmap.php @generated by Composer
$vendorDir = dirname(dirname(__FILE__));
$baseDir = dirname($vendorDir);
return array(
);

View File

@ -0,0 +1,9 @@
<?php
// autoload_namespaces.php @generated by Composer
$vendorDir = dirname(dirname(__FILE__));
$baseDir = dirname($vendorDir);
return array(
);

View File

@ -0,0 +1,11 @@
<?php
// autoload_psr4.php @generated by Composer
$vendorDir = dirname(dirname(__FILE__));
$baseDir = dirname($vendorDir);
return array(
'App\\' => array($baseDir . '/../app'),
'AppInstaller\\' => array($baseDir . '/'),
);

View File

@ -0,0 +1,52 @@
<?php
// autoload_real.php @generated by Composer
class ComposerAutoloaderInitae1de26c658d13c195b98449ea1bf6a1
{
private static $loader;
public static function loadClassLoader($class)
{
if ('Composer\Autoload\ClassLoader' === $class) {
require __DIR__ . '/ClassLoader.php';
}
}
public static function getLoader()
{
if (null !== self::$loader) {
return self::$loader;
}
spl_autoload_register(array('ComposerAutoloaderInitae1de26c658d13c195b98449ea1bf6a1', 'loadClassLoader'), true, true);
self::$loader = $loader = new \Composer\Autoload\ClassLoader();
spl_autoload_unregister(array('ComposerAutoloaderInitae1de26c658d13c195b98449ea1bf6a1', 'loadClassLoader'));
$useStaticLoader = PHP_VERSION_ID >= 50600 && !defined('HHVM_VERSION') && (!function_exists('zend_loader_file_encoded') || !zend_loader_file_encoded());
if ($useStaticLoader) {
require_once __DIR__ . '/autoload_static.php';
call_user_func(\Composer\Autoload\ComposerStaticInitae1de26c658d13c195b98449ea1bf6a1::getInitializer($loader));
} else {
$map = require __DIR__ . '/autoload_namespaces.php';
foreach ($map as $namespace => $path) {
$loader->set($namespace, $path);
}
$map = require __DIR__ . '/autoload_psr4.php';
foreach ($map as $namespace => $path) {
$loader->setPsr4($namespace, $path);
}
$classMap = require __DIR__ . '/autoload_classmap.php';
if ($classMap) {
$loader->addClassMap($classMap);
}
}
$loader->register(true);
return $loader;
}
}

View File

@ -0,0 +1,36 @@
<?php
// autoload_static.php @generated by Composer
namespace Composer\Autoload;
class ComposerStaticInitae1de26c658d13c195b98449ea1bf6a1
{
public static $prefixLengthsPsr4 = array (
'A' =>
array (
'App\\' => 4,
'AppInstaller\\' => 13,
),
);
public static $prefixDirsPsr4 = array (
'App\\' =>
array (
0 => __DIR__ . '/../..' . '/../app',
),
'AppInstaller\\' =>
array (
0 => __DIR__ . '/../..' . '/',
),
);
public static function getInitializer(ClassLoader $loader)
{
return \Closure::bind(function () use ($loader) {
$loader->prefixLengthsPsr4 = ComposerStaticInitae1de26c658d13c195b98449ea1bf6a1::$prefixLengthsPsr4;
$loader->prefixDirsPsr4 = ComposerStaticInitae1de26c658d13c195b98449ea1bf6a1::$prefixDirsPsr4;
}, null, ClassLoader::class);
}
}

96
installer/views/index.php Normal file
View File

@ -0,0 +1,96 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="stylesheet" href="../css/blue-twilight.min.css">
<link rel="stylesheet" href="../themes/default/theme.css">
<title><?php echo $appName; ?></title>
<style type="text/css">
* {
font-family: "Raleway", sans-serif;
}
</style>
</head>
<body>
<nav class="navbar bg-primary navbar-dark">
<a class="navbar-brand" href="" style="color: #fff;"><i class="fa fa-fw fa-image"></i> Blue Twilight - Install</a>
<div class="collapse navbar-collapse" id="navbar-content">
<ul class="navbar-nav mr-auto">
</ul>
</div>
</nav>
<div class="container" id="bootstrapper">
<h3>Welcome to Blue Twilight - the self-hosted PHP photo gallery.</h3>
<p>Your application/PHP environment have been checked and the results are below. Please correct any failed items before continuing.</p>
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="mt-4" v-if="!isRunning">
<div class="alert alert-info">
Blue Twilight will automatically download the required third-party libraries when you
click Continue.
</div>
<?php foreach ($systemRequirements as $groupName => $items): ?>
<div class="card mb-4">
<div class="card-header">
<p class="m-0"><b><?php echo $requirementGroupNames[$groupName]; ?></b></p>
</div>
<div class="card-body p-0">
<table class="table mb-0">
<tbody>
<?php foreach ($items as $itemName => $result): ?>
<tr>
<td style="width: 75%;"><?php echo $requirementNames[$itemName]; ?></td>
<td style="width: 25%;">
<?php if ($result['result'] == $statusOK): ?>
<i class="fas fa-check text-success mr-2"></i>
<?php elseif ($result['result'] == $statusNotMet): ?>
<i class="fas fa-times text-danger mr-2"></i>
<?php endif; ?>
<?php echo $result['status'] ?? ''; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endforeach; ?>
<?php if ($canInstall): ?>
<p class="mb-0 mt-4 text-right"><button class="btn btn-success" @click.prevent="bootstrap"><i class="fas fa-check"></i> Continue</button></p>
<?php else: ?>
<div class="alert alert-danger">
Blue Twilight cannot be installed until the issues identified above are rectified.
</div>
<?php endif; ?>
</div>
<div class="mt-5" v-else>
<ul v-cloak>
<li class="operation mb-3" v-for="operation in operations">
<div class="status mr-1">
<img src="images/waiting.svg" v-if="!operation.isRunning && !operation.isCompleted"></img>
<img src="images/loading.svg" v-if="operation.isRunning && !operation.isCompleted"/>
<img src="images/completed.svg" v-if="!operation.isRunning && operation.isCompleted"></img>
</div>
<span v-text="operation.name"></span>
</li>
</ul>
</div>
</div>
</div>
</div>
<script src="../js/blue-twilight.min.js"></script>
<script type="text/javascript">
$(function()
{
var vm = new BootstrapperViewModel();
var app = new Vue(vm);
});
</script>
</body>
</html>

View File

@ -1,25 +1,15 @@
{
"private": true,
"scripts": {
"prod": "gulp --production",
"dev": "gulp watch"
},
"name": "blue-twilight",
"version": "2.2.0-beta.2",
"devDependencies": {
"bootstrap-sass": "^3.3.7",
"gulp": "^3.9.1",
"gulp-concat": "^2.6.1",
"gulp-copy": "^1.0.0",
"gulp-help": "^1.6.1",
"gulp-rename": "^1.2.2",
"gulp-uglify-es": "^0.1.3",
"uglify-js": "^3.0.28",
"gulp-uglifycss": "^1.0.8",
"jquery": "^3.1.0",
"laravel-elixir": "^6.0.0-14",
"laravel-elixir-vue-2": "^0.3.0",
"laravel-elixir-webpack-official": "^1.0.2",
"lodash": "^4.16.2",
"vue": "^2.0.1",
"vue-resource": "^1.0.3"
"grunt": "^1.0.4",
"grunt-contrib-clean": "^2.0.0",
"grunt-contrib-concat": "^1.0.1",
"grunt-contrib-cssmin": "^3.0.0",
"grunt-contrib-uglify": "^4.0.1",
"grunt-curl": "^2.5.1",
"grunt-dart-sass": "^1.1.3",
"grunt-exec": "^3.0.0",
"node-sass": "^4.13.0"
}
}

View File

@ -1,143 +0,0 @@
<?php
function b2_authorize_account()
{
$application_key_id = "0023254ec9bda08000000000a"; // Obtained from your B2 account page
$application_key = "K002eARNPUlxdj1XaVJbwEYPMz0c7e8"; // Obtained from your B2 account page
$credentials = base64_encode($application_key_id . ":" . $application_key);
$url = "https://api.backblazeb2.com/b2api/v2/b2_authorize_account";
$session = curl_init($url);
// Add headers
$headers = array();
$headers[] = "Accept: application/json";
$headers[] = "Authorization: Basic " . $credentials;
curl_setopt($session, CURLOPT_HTTPHEADER, $headers); // Add headers
curl_setopt($session, CURLOPT_HTTPGET, true); // HTTP GET
curl_setopt($session, CURLOPT_RETURNTRANSFER, true); // Receive server response
$server_output = curl_exec($session);
curl_close ($session);
echo ($server_output);
return json_decode($server_output);
}
function b2_download_file_by_id($download_url, $auth_token)
{
//$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;
$session = curl_init($uri);
curl_setopt($session, CURLOPT_HTTPGET, true); // HTTP GET
curl_setopt($session, CURLOPT_RETURNTRANSFER, true); // Receive server response
echo '<p>' . $uri . '</p>';
$server_output = curl_exec($session); // Let's do this!
if (curl_getinfo($session, CURLINFO_HTTP_CODE) != 200)
{
echo '<p>' . $server_output . '</p>';
}
else
{
echo '<p>' . (strlen($server_output) . ' bytes received') . '</p>'; // 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 '<p>' . $uri . '</p>';
$server_output = curl_exec($session); // Let's do this!
if (curl_getinfo($session, CURLINFO_HTTP_CODE) != 200)
{
echo '<p>' . $server_output . '</p>';
}
else
{
echo '<p>' . (strlen($server_output) . ' bytes received') . '</p>'; // 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 '<p>' . $uri . '</p>';
$server_output = curl_exec($session); // Let's do this!
if (curl_getinfo($session, CURLINFO_HTTP_CODE) != 200)
{
echo '<p>' . $server_output . '</p>';
}
else
{
echo '<p>' . (strlen($server_output) . ' bytes received') . '</p>'; // 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 '<p>' . $uri . '</p>';
$server_output = curl_exec($session); // Let's do this!
if (curl_getinfo($session, CURLINFO_HTTP_CODE) != 200)
{
echo '<p>' . $server_output . '</p>';
}
else
{
echo '<p>' . (strlen($server_output) . ' bytes received') . '</p>'; // Tell me about the rabbits, George!
}
curl_close ($session); // Clean up
}
?>
<h2>b2_authorize_account</h2>
<?php $authorize_account_result = b2_authorize_account(); ?>
<h2>b2_download_file_by_name</h2>
<?php b2_download_file_by_name($authorize_account_result->downloadUrl, $authorize_account_result->authorizationToken); ?>
<h2>b2_download_file_by_id</h2>
<?php b2_download_file_by_id($authorize_account_result->downloadUrl, $authorize_account_result->authorizationToken); ?>

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -10,7 +10,9 @@
/* Added by Andy - check to see if Composer/vendors are installed */
if (!file_exists(__DIR__.'/../vendor/autoload.php'))
{
header('Location: install.php');
$currentUrl = $_SERVER['PHP_SELF']; // e.g. /some/directory/index.php
$bootstrapUrl = sprintf('%sinstall', dirname($currentUrl));
header(sprintf('Location: %s', $bootstrapUrl));
exit();
}
/* End Added by Andy */

View File

@ -1,256 +0,0 @@
<?php
namespace BtwInstaller;
class BlueTwilightInstaller
{
private $baseDirectory;
private $composerSignature;
public function __construct()
{
$this->baseDirectory = dirname(__DIR__);
chdir($this->baseDirectory);
putenv('HOME=' . $this->baseDirectory);
// Display errors so installer never gets a WSOD!
ini_set('display_errors', true);
}
public function run()
{
if (strtoupper($_SERVER['REQUEST_METHOD']) == 'POST')
{
// Handle post
$this->runInstall();
exit();
}
?>
<html>
<head>
<title>Blue Twilight Setup</title>
</head>
<body>
<h1>Blue Twilight Setup</h1>
<p>We need to download a few things - namely <a href="http://getcomposer.org" target="_blank">Composer</a> and related packages - before we can kick off the Blue Twilight installer.</p>
<p>We can do this for you - simply click the button below.</p>
<p style="font-weight: bold; color: #ff0000;">This can take a few minutes so please be patient, and only click the button once!</p>
<form method="post">
<button type="submit">Install Composer and dependencies for me</button>
</form>
<hr/>
<h2>Got Composer?</h2>
<p>If you already have Composer installed, however, you may want to use that instead. Just run the below commands on your server, changing the path to Composer as appropriate:</p>
<p><em>Please note: &quot;composer.phar&quot; may actually be &quot;composer&quot; on your system.</em></p>
<pre>cd <?php echo $this->baseDirectory; ?><br/>/path/to/composer.phar install</pre>
</body>
</html>
<?php
}
private function runInstall()
{
?>
<h1>Installing Blue Twilight Setup Files</h1>
<ul>
<?php
$steps = [
['Checking PHP modules', 'checkPhpModules'],
['Fetching Composer signature', 'fetchComposerSignature'],
['Installing Composer', 'installComposer'],
['Installing dependencies using Composer', 'runComposer'],
['Generating application key', 'generateAppKey']
];
$successful = true;
foreach ($steps as $step)
{
echo sprintf("<li>%s...</li>%s", $step[0], PHP_EOL);
$result = call_user_func([$this, $step[1]]);
if (!$result)
{
$successful = false;
break;
}
}
if ($successful)
{
header('Location: install/check');
exit();
}
?>
</ul>
<?php
}
private function checkPhpModules()
{
$requiredModules = [
'simplexml',
'curl',
'mbstring',
'dom'
];
$invalidModules = [];
foreach ($requiredModules as $module)
{
if (!extension_loaded($module))
{
$invalidModules[] = $module;
}
}
if (count($invalidModules) > 0)
{
$this->echoError(sprintf('The following PHP modules are missing and need to be installed to continue: %s', join(', ', $invalidModules)));
return false;
}
return true;
}
private function echoError($message)
{
echo sprintf("<span style=\"color: #ff0000;\">%s.</span>%s", $message, PHP_EOL);
}
private function echoOK($message = '')
{
echo "<span style=\"color: #008800;\">OK";
if (strlen($message) > 0)
{
echo sprintf('... %s', $message);
}
echo '</span>' . PHP_EOL;
}
private function fetchComposerSignature()
{
if (!boolval(ini_get('allow_url_fopen')))
{
$this->echoError('allow_url_fopen is disabled so we cannot use Composer');
echo '<br/>';
$this->echoError('You will need to install the vendor libraries manually - <a href="https://github.com/pandy06269/blue-twilight/wiki/Install-Vendor-libraries-manually" target="_blank">see this page for more details</a>');
return false;
}
$signatureUrl = 'https://composer.github.io/installer.sig';
$this->composerSignature = trim(file_get_contents($signatureUrl));
if (strlen($this->composerSignature) == 0)
{
$this->echoError(sprintf("Failed downloading the Composer signature from %s", $signatureUrl));
return false;
}
else
{
$this->echoOK($this->composerSignature);
}
return true;
}
private function generateAppKey()
{
if (!file_exists('.env') && file_exists('.env.example'))
{
copy('.env.example', '.env');
}
ob_start();
system('touch .env', $rc);
$result = ob_get_clean();
echo nl2br($result);
ob_start();
system('php artisan key:generate', $rc);
$result = ob_get_clean();
echo nl2br($result);
if ($rc != 0)
{
$this->echoError('Failed to generate application key');
return false;
}
$this->echoOK();
return true;
}
private function installComposer()
{
$rc = -1;
ob_start();
system('php -r "copy(\'https://getcomposer.org/installer\', \'composer-setup.php\');"', $rc);
$result = ob_get_clean();
echo nl2br($result);
if ($rc != 0)
{
$this->echoError('Failed to fetch Composer');
return false;
}
ob_start();
system(sprintf('php -r "if (hash_file(\'SHA384\', \'composer-setup.php\') === \'%s\') { echo \'Installer verified\'; } else { echo \'Installer corrupt\'; unlink(\'composer-setup.php\'); } echo PHP_EOL;"', $this->composerSignature), $rc);
$result = ob_get_clean();
echo nl2br($result);
if ($rc != 0)
{
$this->echoError('Composer verification failed');
return false;
}
ob_start();
system('php composer-setup.php', $rc);
$result = ob_get_clean();
echo nl2br($result);
if ($rc != 0)
{
$this->echoError('Failed to install Composer');
return false;
}
ob_start();
system('php -r "unlink(\'composer-setup.php\');"', $rc);
$result = ob_get_clean();
echo nl2br($result);
if ($rc != 0)
{
$this->echoError('Failed to remove Composer setup file');
return false;
}
$this->echoOK();
return true;
}
private function runComposer()
{
ob_start();
system('php composer.phar install', $rc);
$result = ob_get_clean();
echo nl2br($result);
if ($rc != 0)
{
$this->echoError('Installing Composer packages failed');
return false;
}
$this->echoOK();
return true;
}
}
$installer = new BlueTwilightInstaller();
$installer->run();

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="100px" height="100px"><path fill="#43A047" d="M40.6 12.1L17 35.7 7.4 26.1 4.6 29 17 41.3 43.4 14.9z"/></svg>

After

Width:  |  Height:  |  Size: 175 B

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" style="margin: auto; background: none; display: block; shape-rendering: auto;"
width="200px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
<path d="M10 50A40 40 0 0 0 90 50A40 42 0 0 1 10 50" fill="#1d3f72" stroke="none" transform="rotate(222.794 50 51)">
<animateTransform attributeName="transform" type="rotate" dur="1s" repeatCount="indefinite" keyTimes="0;1" values="0 50 51;360 50 51"></animateTransform>
</path>
<!-- [ldio] generated by https://loading.io/ --></svg>

After

Width:  |  Height:  |  Size: 581 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="100px" height="100px"><path fill="#00acc1" d="M44,24c0,11.044-8.956,20-20,20S4,35.044,4,24S12.956,4,24,4S44,12.956,44,24z"/><path fill="#eee" d="M40,24c0,8.838-7.162,16-16,16S8,32.838,8,24S15.163,8,24,8S40,15.163,40,24z"/><path d="M23 11H25V24H23z"/><path d="M26.082 22.654H28.419V31.846H26.082z" transform="rotate(-45.001 27.25 27.25)"/><path d="M27,24c0,1.657-1.344,3-3,3c-1.657,0-3-1.343-3-3s1.343-3,3-3C25.656,21,27,22.343,27,24"/><path fill="#00acc1" d="M25,24c0,0.551-0.448,1-1,1s-1-0.449-1-1c0-0.553,0.448-1,1-1S25,23.447,25,24"/></svg>

After

Width:  |  Height:  |  Size: 610 B

16
public/install/index.php Normal file
View File

@ -0,0 +1,16 @@
<?php
ini_set('display_errors', 'on');
$installerDir = sprintf('%s/installer', dirname(dirname(__DIR__)));
require_once sprintf('%s/vendor/autoload.php', $installerDir);
require_once sprintf('%s/helpers.php', $installerDir);
try
{
$installer = new \AppInstaller\Installer();
$installer->handleRequest();
}
catch (\Exception $ex)
{
echo sprintf('ERROR: %s', $ex);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
public/svg/403.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

1
public/svg/404.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024"><defs><linearGradient id="a" x1="50.31%" x2="50%" y1="74.74%" y2="0%"><stop offset="0%" stop-color="#FFE98A"/><stop offset="67.7%" stop-color="#B63E59"/><stop offset="100%" stop-color="#68126F"/></linearGradient><circle id="c" cx="603" cy="682" r="93"/><filter id="b" width="203.2%" height="203.2%" x="-51.6%" y="-51.6%" filterUnits="objectBoundingBox"><feOffset in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur in="shadowOffsetOuter1" result="shadowBlurOuter1" stdDeviation="32"/><feColorMatrix in="shadowBlurOuter1" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/></filter><linearGradient id="d" x1="49.48%" x2="49.87%" y1="11.66%" y2="77.75%"><stop offset="0%" stop-color="#F7EAB9"/><stop offset="100%" stop-color="#E5765E"/></linearGradient><linearGradient id="e" x1="91.59%" x2="66.97%" y1="5.89%" y2="100%"><stop offset="0%" stop-color="#A22A50"/><stop offset="100%" stop-color="#EE7566"/></linearGradient><linearGradient id="f" x1="49.48%" x2="49.61%" y1="11.66%" y2="98.34%"><stop offset="0%" stop-color="#F7EAB9"/><stop offset="100%" stop-color="#E5765E"/></linearGradient><linearGradient id="g" x1="78.5%" x2="36.4%" y1="106.76%" y2="26.41%"><stop offset="0%" stop-color="#A22A50"/><stop offset="100%" stop-color="#EE7566"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><rect width="1024" height="1024" fill="url(#a)"/><use fill="black" filter="url(#b)" xlink:href="#c"/><use fill="#FFF6CB" xlink:href="#c"/><g fill="#FFFFFF" opacity=".3" transform="translate(14 23)"><circle cx="203" cy="255" r="3" fill-opacity=".4"/><circle cx="82" cy="234" r="2"/><circle cx="22" cy="264" r="2" opacity=".4"/><circle cx="113" cy="65" r="3"/><circle cx="202" cy="2" r="2"/><circle cx="2" cy="114" r="2"/><circle cx="152" cy="144" r="2"/><circle cx="362" cy="224" r="2"/><circle cx="453" cy="65" r="3" opacity=".4"/><circle cx="513" cy="255" r="3"/><circle cx="593" cy="115" r="3"/><circle cx="803" cy="5" r="3" opacity=".4"/><circle cx="502" cy="134" r="2"/><circle cx="832" cy="204" r="2"/><circle cx="752" cy="114" r="2"/><circle cx="933" cy="255" r="3" opacity=".4"/><circle cx="703" cy="225" r="3"/><circle cx="903" cy="55" r="3"/><circle cx="982" cy="144" r="2"/><circle cx="632" cy="14" r="2"/></g><g transform="translate(0 550)"><path fill="#8E2C15" d="M259 5.47c0 5.33 3.33 9.5 10 12.5s9.67 9.16 9 18.5h1c.67-6.31 1-11.8 1-16.47 8.67 0 13.33-1.33 14-4 .67 4.98 1.67 8.3 3 9.97 1.33 1.66 2 5.16 2 10.5h1c0-5.65.33-9.64 1-11.97 1-3.5 4-10.03-1-14.53S295 7 290 3c-5-4-10-3-13 2s-5 7-9 7-5-3.53-5-5.53c0-2 2-5-1.5-5s-7.5 0-7.5 2c0 1.33 1.67 2 5 2z"/><path fill="url(#d)" d="M1024 390H0V105.08C77.3 71.4 155.26 35 297.4 35c250 0 250.76 125.25 500 125 84.03-.08 160.02-18.2 226.6-40.93V390z"/><path fill="url(#d)" d="M1024 442H0V271.82c137.51-15.4 203.1-50.49 356.67-60.1C555.24 199.3 606.71 86.59 856.74 86.59c72.78 0 124.44 10.62 167.26 25.68V442z"/><path fill="url(#e)" d="M1024 112.21V412H856.91c99.31-86.5 112.63-140.75 39.97-162.78C710.24 192.64 795.12 86.58 856.9 86.58c72.7 0 124.3 10.6 167.09 25.63z"/><path fill="url(#e)" d="M1024 285.32V412H857c99.31-86.6 112.63-140.94 39.97-163L1024 285.32z"/><path fill="url(#f)" d="M0 474V223.93C67.12 190.69 129.55 155 263 155c250 0 331.46 162.6 530 175 107.42 6.71 163-26.77 231-58.92V474H0z"/><path fill="url(#e)" d="M353.02 474H0V223.93C67.12 190.69 129.55 155 263 155c71.14 0 151.5 12.76 151.5 70.5 0 54.5-45.5 79.72-112.5 109-82.26 35.95-54.57 111.68 51.02 139.5z"/><path fill="url(#g)" d="M353.02 474H0v-14.8l302-124.7c-82.26 35.95-54.57 111.68 51.02 139.5z"/></g><g fill="#FFFFFF" opacity=".2" transform="translate(288 523)"><circle cx="250" cy="110" r="110"/><circle cx="420" cy="78" r="60"/><circle cx="70" cy="220" r="70"/></g><g fill="#FFFFFF" fill-rule="nonzero" opacity=".08" transform="translate(135 316)"><path d="M10 80.22a14.2 14.2 0 0 1 20 0 14.2 14.2 0 0 0 20 0l20-19.86a42.58 42.58 0 0 1 60 0l15 14.9a21.3 21.3 0 0 0 30 0 21.3 21.3 0 0 1 30 0l.9.9A47.69 47.69 0 0 1 220 110H0v-5.76c0-9.02 3.6-17.67 10-24.02zm559.1-66.11l5.9-5.86c11.07-11 28.93-11 40 0l10 9.94a14.19 14.19 0 0 0 20 0 14.19 14.19 0 0 1 20 0 16.36 16.36 0 0 0 21.3 1.5l8.7-6.47a33.47 33.47 0 0 1 40 0l4.06 3.03A39.6 39.6 0 0 1 755 48H555a47.77 47.77 0 0 1 14.1-33.89z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

1
public/svg/500.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

1
public/svg/503.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -1,255 +0,0 @@
<?php
namespace BtwInstaller;
class BlueTwilightUpdater
{
private $baseDirectory;
private $composerSignature;
public function __construct()
{
$this->baseDirectory = dirname(__DIR__);
chdir($this->baseDirectory);
putenv('HOME=' . $this->baseDirectory);
// Display errors so installer never gets a WSOD!
ini_set('display_errors', true);
}
public function run()
{
if (strtoupper($_SERVER['REQUEST_METHOD']) == 'POST')
{
// Handle post
$this->runUpdate();
exit();
}
?>
<html>
<head>
<title>Blue Twilight Update</title>
</head>
<body>
<h1>Blue Twilight Update</h1>
<p>This update routine ensures your Blue Twilight Composer packages are up-to-date.</p>
<p>To get started, simply click the button below.</p>
<p style="font-weight: bold; color: #ff0000;">This can take a few minutes so please be patient, and only click the button once!</p>
<form method="post">
<button type="submit">Update Composer and dependencies for me</button>
</form>
<hr/>
<h2>Got Composer?</h2>
<p>If you already have Composer installed, however, you may want to use that instead. Just run the below commands on your server, changing the path to Composer as appropriate:</p>
<p><em>Please note: &quot;composer.phar&quot; may actually be &quot;composer&quot; on your system.</em></p>
<pre>
cd <?php echo $this->baseDirectory; ?>
php artisan clear-compiled
php artisan cache:clear
php artisan config:clear
php artisan view:clear
/path/to/composer.phar install
</pre>
</body>
</html>
<?php
}
private function runUpdate()
{
?>
<h1>Updating Blue Twilight Composer packages</h1>
<ul>
<?php
$steps = [
['Removing compiled cache', 'removeCompiledCached'],
['Fetching Composer signature', 'fetchComposerSignature'],
['Installing Composer', 'installComposer'],
['Updating dependencies using Composer', 'runComposer']
];
$successful = true;
foreach ($steps as $step)
{
echo sprintf("<li>%s...</li>%s", $step[0], PHP_EOL);
$result = call_user_func([$this, $step[1]]);
if (!$result)
{
$successful = false;
break;
}
}
if ($successful)
{
header('Location: admin');
exit();
}
?>
</ul>
<?php
}
private function fetchComposerSignature()
{
if (!boolval(ini_get('allow_url_fopen')))
{
$this->echoError('allow_url_fopen is disabled so we cannot use Composer');
echo '<br/>';
$this->echoError('You will need to install the vendor libraries manually - <a href="https://github.com/pandy06269/blue-twilight/wiki/Install-Vendor-libraries-manually" target="_blank">see this page for more details</a>');
return false;
}
$signatureUrl = 'https://composer.github.io/installer.sig';
$this->composerSignature = trim(file_get_contents($signatureUrl));
if (strlen($this->composerSignature) == 0)
{
$this->echoError(sprintf("Failed downloading the Composer signature from %s", $signatureUrl));
return false;
}
else
{
$this->echoOK($this->composerSignature);
}
return true;
}
private function removeCompiledCached()
{
ob_start();
system('php artisan clear-compiled', $rc);
$result = ob_get_clean();
echo nl2br($result);
if ($rc != 0)
{
$this->echoError('clear-compiled command failed');
return false;
}
ob_start();
system('php artisan cache:clear', $rc);
$result = ob_get_clean();
echo nl2br($result);
if ($rc != 0)
{
$this->echoError('cache:clear command failed');
return false;
}
ob_start();
system('php artisan config:clear', $rc);
$result = ob_get_clean();
echo nl2br($result);
if ($rc != 0)
{
$this->echoError('config:clear command failed');
return false;
}
ob_start();
system('php artisan view:clear', $rc);
$result = ob_get_clean();
echo nl2br($result);
if ($rc != 0)
{
$this->echoError('view:clear command failed');
return false;
}
$this->echoOK();
return true;
}
private function echoError($message)
{
echo sprintf("<span style=\"color: #ff0000;\">%s.</span>%s", $message, PHP_EOL);
}
private function echoOK($message = '')
{
echo "<span style=\"color: #008800;\">OK";
if (strlen($message) > 0)
{
echo sprintf('... %s', $message);
}
echo '</span>' . PHP_EOL;
}
private function installComposer()
{
$rc = -1;
ob_start();
system('php -r "copy(\'https://getcomposer.org/installer\', \'composer-setup.php\');"', $rc);
$result = ob_get_clean();
echo nl2br($result);
if ($rc != 0)
{
$this->echoError('Failed to fetch Composer');
return false;
}
ob_start();
system(sprintf('php -r "if (hash_file(\'SHA384\', \'composer-setup.php\') === \'%s\') { echo \'Installer verified\'; } else { echo \'Installer corrupt\'; unlink(\'composer-setup.php\'); } echo PHP_EOL;"', $this->composerSignature), $rc);
$result = ob_get_clean();
echo nl2br($result);
if ($rc != 0)
{
$this->echoError('Composer verification failed');
return false;
}
ob_start();
system('php composer-setup.php', $rc);
$result = ob_get_clean();
echo nl2br($result);
if ($rc != 0)
{
$this->echoError('Failed to install Composer');
return false;
}
ob_start();
system('php -r "unlink(\'composer-setup.php\');"', $rc);
$result = ob_get_clean();
echo nl2br($result);
if ($rc != 0)
{
$this->echoError('Failed to remove Composer setup file');
return false;
}
$this->echoOK();
return true;
}
private function runComposer()
{
ob_start();
system('php composer.phar --no-interaction install', $rc);
$result = ob_get_clean();
echo nl2br($result);
if ($rc != 0)
{
$this->echoError('Updating Composer packages failed');
return false;
}
$this->echoOK();
return true;
}
}
$installer = new BlueTwilightUpdater();
$installer->run();

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="100px" height="100px"><path fill="#43A047" d="M40.6 12.1L17 35.7 7.4 26.1 4.6 29 17 41.3 43.4 14.9z"/></svg>

After

Width:  |  Height:  |  Size: 175 B

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" style="margin: auto; background: none; display: block; shape-rendering: auto;"
width="200px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
<path d="M10 50A40 40 0 0 0 90 50A40 42 0 0 1 10 50" fill="#1d3f72" stroke="none" transform="rotate(222.794 50 51)">
<animateTransform attributeName="transform" type="rotate" dur="1s" repeatCount="indefinite" keyTimes="0;1" values="0 50 51;360 50 51"></animateTransform>
</path>
<!-- [ldio] generated by https://loading.io/ --></svg>

After

Width:  |  Height:  |  Size: 581 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="100px" height="100px"><path fill="#00acc1" d="M44,24c0,11.044-8.956,20-20,20S4,35.044,4,24S12.956,4,24,4S44,12.956,44,24z"/><path fill="#eee" d="M40,24c0,8.838-7.162,16-16,16S8,32.838,8,24S15.163,8,24,8S40,15.163,40,24z"/><path d="M23 11H25V24H23z"/><path d="M26.082 22.654H28.419V31.846H26.082z" transform="rotate(-45.001 27.25 27.25)"/><path d="M27,24c0,1.657-1.344,3-3,3c-1.657,0-3-1.343-3-3s1.343-3,3-3C25.656,21,27,22.343,27,24"/><path fill="#00acc1" d="M25,24c0,0.551-0.448,1-1,1s-1-0.449-1-1c0-0.553,0.448-1,1-1S25,23.447,25,24"/></svg>

After

Width:  |  Height:  |  Size: 610 B

17
public/update/index.php Normal file
View File

@ -0,0 +1,17 @@
<?php
ini_set('display_errors', 'on');
$installerDir = sprintf('%s/installer', dirname(dirname(__DIR__)));
require_once sprintf('%s/vendor/autoload.php', $installerDir);
require_once sprintf('%s/helpers.php', $installerDir);
try
{
$installer = new \AppInstaller\Installer();
$installer->setIsUpgrade(true);
$installer->handleRequest();
}
catch (\Exception $ex)
{
echo sprintf('ERROR: %s', $ex);
}

View File

@ -6,10 +6,6 @@ It takes advantage of modern frameworks (Laravel, Bootstrap 4, VueJS) as well as
You can see Blue Twilight in action on my own photo gallery - the reason I wrote Blue Twilight - at: [photos.andysh.uk](https://photos.andysh.uk)
## Blue Twilight Cloud
If you want your own dedicated, private instance of Blue Twilight without the hassle of managing servers, hosting and updates - check out [Blue Twilight Cloud](https://showmy.photos).
## Demo System
See Blue Twilight in action using the demo system. Full details are [available here](https://showmy.photos/demo/).
@ -24,7 +20,7 @@ The link to the demo system is: https://demo.showmy.photos. Login with:
* [Blue Twilight website](https://showmy.photos/)
* [User Manual](https://showmy.photos/user-guide/)
* [Installation Guide](https://showmy.photos/user-guide/installation/)
* [Issues/Tasks](https://apps.andysh.uk/aheathershaw/blue-twilight/issues)
* [Issues/Tasks](https://projects.waggybytes.com/aheathershaw/blue-twilight/issues)
* [Roadmap](https://apps.andysh.uk/aheathershaw/blue-twilight/milestones)
## Need Help?

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,331 +0,0 @@
/*!
* Bootstrap Reboot v4.1.2 (https://getbootstrap.com/)
* Copyright 2011-2018 The Bootstrap Authors
* Copyright 2011-2018 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: sans-serif;
line-height: 1.15;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
-ms-overflow-style: scrollbar;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
@-ms-viewport {
width: device-width;
}
article, aside, figcaption, figure, footer, header, hgroup, main, nav, section {
display: block;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529;
text-align: left;
background-color: #fff;
}
[tabindex="-1"]:focus {
outline: 0 !important;
}
hr {
box-sizing: content-box;
height: 0;
overflow: visible;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 0;
margin-bottom: 0.5rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-original-title] {
text-decoration: underline;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
border-bottom: 0;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: .5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
dfn {
font-style: italic;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 80%;
}
sub,
sup {
position: relative;
font-size: 75%;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -.25em;
}
sup {
top: -.5em;
}
a {
color: #007bff;
text-decoration: none;
background-color: transparent;
-webkit-text-decoration-skip: objects;
}
a:hover {
color: #0056b3;
text-decoration: underline;
}
a:not([href]):not([tabindex]) {
color: inherit;
text-decoration: none;
}
a:not([href]):not([tabindex]):hover, a:not([href]):not([tabindex]):focus {
color: inherit;
text-decoration: none;
}
a:not([href]):not([tabindex]):focus {
outline: 0;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
}
pre {
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
-ms-overflow-style: scrollbar;
}
figure {
margin: 0 0 1rem;
}
img {
vertical-align: middle;
border-style: none;
}
svg:not(:root) {
overflow: hidden;
vertical-align: middle;
}
table {
border-collapse: collapse;
}
caption {
padding-top: 0.75rem;
padding-bottom: 0.75rem;
color: #6c757d;
text-align: left;
caption-side: bottom;
}
th {
text-align: inherit;
}
label {
display: inline-block;
margin-bottom: 0.5rem;
}
button {
border-radius: 0;
}
button:focus {
outline: 1px dotted;
outline: 5px auto -webkit-focus-ring-color;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
button,
html [type="button"],
[type="reset"],
[type="submit"] {
-webkit-appearance: button;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
padding: 0;
border-style: none;
}
input[type="radio"],
input[type="checkbox"] {
box-sizing: border-box;
padding: 0;
}
input[type="date"],
input[type="time"],
input[type="datetime-local"],
input[type="month"] {
-webkit-appearance: listbox;
}
textarea {
overflow: auto;
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
display: block;
width: 100%;
max-width: 100%;
padding: 0;
margin-bottom: .5rem;
font-size: 1.5rem;
line-height: inherit;
color: inherit;
white-space: normal;
}
progress {
vertical-align: baseline;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
height: auto;
}
[type="search"] {
outline-offset: -2px;
-webkit-appearance: none;
}
[type="search"]::-webkit-search-cancel-button,
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
summary {
display: list-item;
cursor: pointer;
}
template {
display: none;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

View File

@ -1,8 +0,0 @@
/*!
* Bootstrap Reboot v4.1.2 (https://getbootstrap.com/)
* Copyright 2011-2018 The Bootstrap Authors
* Copyright 2011-2018 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:transparent}@-ms-viewport{width:device-width}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus{outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}dfn{font-style:italic}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent;-webkit-text-decoration-skip:objects}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([tabindex]){color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus,a:not([href]):not([tabindex]):hover{color:inherit;text-decoration:none}a:not([href]):not([tabindex]):focus{outline:0}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg:not(:root){overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.min.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More