Compare commits

...

276 Commits
v2.0.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
Andy Heathershaw da99b0b05a Merge branch 'feature/135-backblaze-driver' of aheathershaw/blue-twilight into master 2019-09-14 15:38:15 +01:00
Andy Heathershaw 99cafbc9a5 Backblaze #135 - B2 storage source now removes the current file version before uploading a new one 2019-09-14 15:35:05 +01:00
Andy Heathershaw a6825bcef9 Backblaze #135 - implemented the re-use of the upload token/URL. Fetching file contents now works by using the b2_download_file_by_id method with an auth header. 2019-09-14 10:04:09 +01:00
Andy Heathershaw 69422ffaa4 Backblaze #135 - implemented a retry and backoff period for 500/503 errors 2019-09-11 14:59:25 +01:00
Andy Heathershaw fb6754b8e9 Backblaze #135 - tried to implement b2_download_file_by_id for private buckets, but this doesn't work correctly, logged with Backblaze 2019-09-10 16:24:26 +01:00
Andy Heathershaw ce03b2596f Backblaze #135 - album storage driver is now cached to maintain state within the same request, prevents multiple calls to B2. Images can now be deleted and (I think) edited. 2019-09-10 15:11:53 +01:00
Andy Heathershaw 608442d566 Backblaze #135 - introduced the config setting to choose whether to generate private or public URLs, or to auto-detect. Photos are now displayed from B2. 2019-09-09 21:52:26 +01:00
Andy Heathershaw 437fe9fe1f Updated composer.lock file 2019-09-09 20:51:06 +01:00
Andy Heathershaw 4b6bdeba15 Backblaze #135 - added the storage UI and initial connectivity to B2 2019-09-09 20:35:32 +01:00
Andy Heathershaw 85d5926c3a Updated read me to remove the now-ancient V2 message and introduce Cloud 2019-09-03 19:31:04 +01:00
Andy Heathershaw d0d322120a #132: Added support for using vhosts with RabbitMQ 2019-08-11 07:49:25 +01:00
Andy Heathershaw 3c6c5b174d Update the permissions cache post-upgrade 2019-08-06 21:17:53 +01:00
Andy Heathershaw b141061406 Fixes #131: quick upload feature creates a new album with inherited permissions, and also rebuilds the permissions cache. Added an alert to the quick upload to advise of the permissions change. 2019-07-31 08:56:30 +01:00
Andy Heathershaw d9b68716c8 Force composer to always discard any local changes to packages - this gets around a checksumming issue in the AWS SDK upgrade. 2019-07-28 13:21:52 +01:00
Andy Heathershaw 728d14563e Do a comparison for Laravel 5.5 when auto-discovering commands 2019-07-28 12:51:12 +01:00
Andy Heathershaw c8952a8ac1 Enable users public profile page by default when profile pages are enabled 2019-07-28 08:17:17 +01:00
Andy Heathershaw 24f2155a35 Remove temporary files once they're in the analysis queue following an upload 2019-07-27 14:05:19 +01:00
Andy Heathershaw f4f4de1b34 Fixes #130: Local file system queue copies the file instead of moving it, so the temp file is still available to analyse 2019-07-27 13:50:27 +01:00
Andy Heathershaw 4ef3285eb2 Fixes #123: Processing queue is now used for bulk uploads. If an error occurs during processing on the queue, this is now relayed to the user. Fixed an issue when creating an album where the default storage wasn't defaulted. 2019-07-21 17:22:40 +01:00
Andy Heathershaw 3b76f20738 Fixes #128: Profiles page details are only displayed when social user profiles is enabled 2019-07-21 08:39:04 +01:00
Andy Heathershaw b2443d0ff9 Fixes #102: include the parent album ID in the edit form to work with the validation 2019-07-21 08:25:44 +01:00
Andy Heathershaw 8a758f2b06 Fixes #100: set the first active storage location as default when the current default is deactivated. Also remove the stupid _old_input thingy as this was the very first screen I did in Laravel and it's not needed! 2019-07-20 18:06:21 +01:00
Andy Heathershaw 5947b6e08c Bumped version number to 2.2.0-beta.1. Use the url helper instead of the config to get the app root URL 2019-07-14 16:33:24 +01:00
Andy Heathershaw 806d0696f0 #127: InstallController now sets default options and permissions for new installations 2019-07-14 16:28:46 +01:00
Andy Heathershaw 0519a4ecc5 #124: Corrected the base class in the comment replied to mailable. 2019-07-14 16:16:35 +01:00
Andy Heathershaw 461517dd55 #124: Made the queue settings configurable through the .env file 2019-07-14 16:12:56 +01:00
Andy Heathershaw 4905dd1caa #124: Converted the comment-related e-mails to be queueable 2019-07-14 15:58:12 +01:00
Andy Heathershaw e794f99ead #124: Updated the change e-mail confirmation to be a notification 2019-07-14 14:35:26 +01:00
Andy Heathershaw 4af68cd055 #125: Updated the password reset and change passwords forms to BS4 validation styles 2019-07-14 14:24:34 +01:00
Andy Heathershaw 624334570f #124: Updated the user self-registration required and user self-activated e-mails to be notifications so they can be queued. 2019-07-14 14:18:15 +01:00
Andy Heathershaw 216c93a750 #124: E-mails now send and log to the database as sent when queuing is not enabled 2019-07-14 12:29:25 +01:00
Andy Heathershaw 7418438d63 #123: The framework of sending e-mails using Mailables but queued in the database is now there. Password reset e-mails are now the first ones being sent using the queue. 2019-07-14 12:13:58 +01:00
Andy Heathershaw bfbf740810 #124, #125, #126: Started implementing e-mail queueing. Fixed the display of validation messages on the forgotten password form. Corrected the generation of the action URL when the APP_URL environment settings is not configured. 2019-07-13 21:40:13 +01:00
Andy Heathershaw 07fa9639b5 #123: The setting tab now only lists compatible storages. Added support for IAnalysisQueueSource to the LocalFileSystemSource driver 2019-07-13 10:27:35 +01:00
Andy Heathershaw 95e79f2d28 #123: The process command (which is now bt-queue:process to avoid conflicts with Laravel's default queue namespace) now uses the S3 storage to retrieve images 2019-07-13 10:15:13 +01:00
Andy Heathershaw 7a71a06e4e #123: Images are now uploaded to the storage driver specified using the new IAnalysisQueueSource interface 2019-07-12 16:21:30 +01:00
Andy Heathershaw bb5ed4d68d #123: Actually save the analysis queue storage location 2019-07-12 07:28:53 +01:00
Andy Heathershaw cffe8332fc #123: Display list of storages on the Image Processing tab 2019-07-12 07:26:02 +01:00
Andy Heathershaw f26f545b76 #123: Added the dropdown to the settings page to select from storage locations 2019-07-11 09:01:20 +01:00
Andy Heathershaw ebbc5ba097 #121: Bulk updates to photos now take place on the queue if enabled (just need to handle album changes) 2019-07-10 14:31:04 +01:00
Andy Heathershaw 0cca6eec66 #104: Added left/right key bindings to navigate through an album 2019-07-09 23:14:54 +01:00
Andy Heathershaw 3995d79955 Merge branch 'feature/121-rabbitmq-queuing' of aheathershaw/blue-twilight into master 2019-07-09 23:07:27 +01:00
Andy Heathershaw 0de33065fe #121: The current photo analysis method now polls the queue table until the photo is analysed (or 60 seconds, which ever comes first.) The process command now creates user_activity records for the profile pages. Added an example systemd file to run the message queue. 2019-07-09 23:05:22 +01:00
Andy Heathershaw 961603acd5 #121: Photos are now being analysed by the queue 2019-07-09 22:03:54 +01:00
Andy Heathershaw ca893359c9 #121: Added new configuration tab (Image Processing) for RabbitMQ-related settings 2019-07-09 13:08:57 +01:00
Andy Heathershaw de66c489a6 Updated Composer to include PHP AMQPLIB 2019-07-09 09:03:21 +01:00
Andy Heathershaw dbdf17cc9e Merge branch 'feature/111-user-activity-feeds' of aheathershaw/blue-twilight into master 2018-11-19 19:08:49 +00:00
Andy Heathershaw 54e327ee9e #111: Added the migration SQL to populate the user_activity table 2018-11-19 14:14:15 +00:00
Andy Heathershaw 80a7824f22 #111: User feed now displays a "user created" event logged via the register/activation controllers 2018-11-19 13:26:44 +00:00
Andy Heathershaw e59311244a #111: Updated CSS/JS files from previous development 2018-11-18 21:41:17 +00:00
Andy Heathershaw f36aa61506 #71, #111: Implemented security checking in the JSON feed methods. Also users now automatically inherit the anonymous permissions by way of an additional check specifically against the anonymous user first 2018-11-18 21:39:19 +00:00
Andy Heathershaw 2394bbd077 #116: Completed the Explore Users page 2018-11-18 20:50:09 +00:00
Andy Heathershaw 386bd30208 #111: Fleshed out the content of the Explore Photographers page. Also added a redirect to the activity feed if accessing the dashboard and logging in 2018-11-18 09:07:13 +00:00
Andy Heathershaw 42a1e4b770 #111: Accessing the user activity page/JSON feed is now impossible when feeds are disabled. Also the Activity icon is not shown in the navbar when feeds are disabled. 2018-11-17 16:02:31 +00:00
Andy Heathershaw 3982f47243 UserConfig::getOrCreateModel was incorrectly reloading the cache with non-objects 2018-11-17 15:30:56 +00:00
Andy Heathershaw 1d81185779 #111: Added no feed items language strings and link to the Photographer Explorer page. 2018-11-17 09:47:28 +00:00
Andy Heathershaw c0ab6a7acc #117: Avoid repeated calls to the configuration table - cache the entire table on the first request and use the cache in subsequent calls 2018-10-15 13:59:14 +01:00
Andy Heathershaw d6d2420eb7 #111: Implemented the /me feed endpoint 2018-10-14 20:08:31 +01:00
Andy Heathershaw 2304b23564 Merge remote-tracking branch 'origin/feature/111-user-activity-feeds' into feature/111-user-activity-feeds 2018-10-13 04:48:05 +01:00
Andy Heathershaw bfd7a8100d Album URL now uses the Photo's thumbnailUrl method - allowing for cache-busting on the main gallery index 2018-10-13 04:47:48 +01:00
Andy Heathershaw 245bfe546c #111: It's now possible for a logged-in user to follow another user from their public profile page 2018-10-10 13:46:42 +01:00
Andy Heathershaw 4ec23668ff #111: Corrected a bug where the activity feed always showed the logged-in user's feed. Added template text when there are no activity items to display. Updated the deployment Javascript files with the latest viewmodel changes. 2018-10-10 12:58:43 +01:00
Andy Heathershaw 44591790d1 #111: Added an activity feed to the user profile screen, with a configurable setting on the social tab 2018-10-09 22:16:43 +01:00
Andy Heathershaw 5df6ae770f Merge branch 'feature/57-user-activation-email' of aheathershaw/blue-twilight into master 2018-10-06 09:44:45 +01:00
Andy Heathershaw 09de0f1356 #57: An e-mail is now sent to administrator accounts when a user is self-created and activated (if e-mail activation is required) 2018-10-06 09:43:58 +01:00
Andy Heathershaw 54fe543fe6 Merge branch 'feature/4-commenting-on-photos' of aheathershaw/blue-twilight into master 2018-10-05 23:37:26 +01:00
Andy Heathershaw 2c0595bb98 #4: A notification is now sent to the original poster when a reply is posted to their comment. Removed the "edit comment" link as this functionality doesn't (and won't) exist. 2018-10-05 23:35:01 +01:00
Andy Heathershaw 38e24cc4d6 #4: It's now possible to bulk-approve and bulk-reject comments in the admin screen 2018-10-05 23:11:19 +01:00
Andy Heathershaw 17359e0cbe #4: Comments can now be individually approved/rejected through the admin screen. 2018-10-05 22:57:39 +01:00
Andy Heathershaw 734e88bfc7 #4: It's now possible to delete single comments and bulk-delete multiple comments in the admin screen 2018-10-05 22:17:41 +01:00
Andy Heathershaw 62659c13f7 #4: Added e-mail notifications to the album owner when a new comment has been approved, and to the comment poster when it is approved. 2018-10-05 21:08:14 +01:00
Andy Heathershaw a61029cf78 #4: Moderators now receive an e-mail notification of a pending comment. Resolved an issue with the HTML filtering. 2018-09-26 16:51:17 +01:00
Andy Heathershaw e00c1631bb #4: Comments posted on photos are now checked against a white-list of allowed HTML tags, and these are filtered out (the inner content is still displayed.) 2018-09-24 15:55:48 +01:00
Andy Heathershaw f56d989d75 #4: Added config setting to configure the HTML tags that are allowed in comments 2018-09-24 09:12:07 +01:00
Andy Heathershaw 90c7591c31 #112: Default album permissions are now used when creating a new album that does not inherit permissions, and is not marked as a private album 2018-09-23 22:28:12 +01:00
Andy Heathershaw a5569924be #4, #112: Default permissions can now be saved. There's a link to the default permissions screen from the admin/settings screen. The permissions cache rebuild now takes into account the default permissions. The permissions tab on the albums screen now correctly shows text based on if permissions are inherited from a parent album, or the default permissions. 2018-09-23 22:20:03 +01:00
Andy Heathershaw da0667711a #4, 112: Started working on an admin screen and database structure to be able to set default album permissions for top-level albums to inherit from (and a base for new albums created without inheriting.) 2018-09-23 10:28:54 +01:00
Andy Heathershaw 84f8ad75e9 #4: Added the comment date to the admin screen and a checkbox to bulk-select comments 2018-09-22 08:49:01 +01:00
Andy Heathershaw 428c43a4c3 #4: Added an admin screen to manage comments 2018-09-21 15:00:07 +01:00
Andy Heathershaw f2ba0e9475 #4: Started working on notification to moderators when a comment has been posted 2018-09-20 21:32:50 +01:00
Andy Heathershaw e398bc1b68 #4: Added a permission to determine if a user can post a comment - this supercedes the "photo:post-comment" gate. 2018-09-20 14:38:34 +01:00
Andy Heathershaw 67bf7086c0 #4: Added settings to configure moderation for known/anonymous users 2018-09-19 20:35:43 +01:00
Andy Heathershaw d1d77752b3 #4: Added a global setting that specifies if comments from anonymous users are allowed 2018-09-19 20:23:02 +01:00
Andy Heathershaw 97ee60cfc9 #4: Comments can now be approved and rejected from the front-end gallery 2018-09-19 19:54:59 +01:00
Andy Heathershaw 1d10d50557 #4: Updated deployed CSS files with previous changes 2018-09-19 14:22:03 +01:00
Andy Heathershaw 3f7badd98a #4: Known users pre-fill the user/email password, added user Gravatar for the comment form, and a link to logout. Login/logout redirects back to the previous page. 2018-09-19 13:49:53 +01:00
Andy Heathershaw 1802aa84d8 #4: Added a basic template for the comment design. Comments now display nested. Renamed the columns in the database table so the default validation error messages look better. Corrected a few issues with the TinyMCE implementation. 2018-09-19 09:44:20 +01:00
Andy Heathershaw 60e747bd75 #4: Added TinyMCE as a rich text editor for posting comments 2018-09-18 22:35:22 +01:00
Andy Heathershaw 7d77fe57c5 #4: If a validation error occurs while replying to a comment, the form is now re-rendered instead of JSON and a 422 being returned. 2018-09-18 15:50:12 +01:00
Andy Heathershaw 9702366d11 #4: It's now possible to reply to a comment in threaded comments. Also started implementing validation. 2018-09-18 14:28:59 +01:00
Andy Heathershaw c9ab590afe #4: Started work on threaded comments in the front-end gallery. There is also a settings tab dedicated to commenting now. 2018-09-18 10:19:47 +01:00
Andy Heathershaw 1f7befafab #4: Added navigation properties to retrieved approved comments and comment owner. Started adding comments to the view. 2018-09-17 22:30:27 +01:00
Andy Heathershaw 0ebd7a1c5f #4: Comments can now be posted from a photo page in the gallery, and are saved in the database in the photo_comments table. 2018-09-17 14:15:06 +01:00
Andy Heathershaw c2e71b0084 #4: Added config settings to turn on comments and require login before commenting 2018-09-17 09:29:09 +01:00
Andy Heathershaw 055137935d Merge branch 'feature/71-permissions-cache' of aheathershaw/blue-twilight into master 2018-09-16 22:19:38 +01:00
Andy Heathershaw a137f36eab Updated public CSS/JS 2018-09-16 22:14:34 +01:00
Andy Heathershaw ee4978878f #71: Permissions are now fully inherited from an "ultimate parent". Most actions that can change the outcome of a user's permissions rebuild the permissions cache. Corrected a few minor HTML issues in layouts. 2018-09-16 22:11:53 +01:00
Andy Heathershaw 138cb91986 #71: Removed incorrectly-added active tab check 2018-09-16 10:28:06 +01:00
Andy Heathershaw 9ad52359df #71: Albums edit page now shows if an album is inheriting permissions, and this can be changed on edit album screen 2018-09-16 09:12:35 +01:00
Andy Heathershaw 90e9061ebc #71: Permissions are now read from the new cache table, which has reduced complexity in the code significantly 2018-09-16 08:41:36 +01:00
Andy Heathershaw 835a3e611b #71: The rebuildPermissionsCache controller method now calls a new helper class, PermissionsHelper, that rebuilds the permissions in the new album_permissions_cache DB table 2018-09-14 21:03:07 +01:00
Andy Heathershaw c5ccc4ef9a #71: Updated deployed JS file with the new SettingsViewModel 2018-09-14 11:15:40 +01:00
Andy Heathershaw b03ab47039 #71: The settings screen is now hooked up to the rebuildPermissionsCache method on the Admin\DefaultController 2018-09-14 11:14:39 +01:00
Andy Heathershaw cb3791b4da Merge branch 'master' into feature/71-permissions-cache 2018-09-14 11:02:43 +01:00
Andy Heathershaw 3f55d4e0f0 #71: Started adding support for a DB-based permissions cache and ability to rebuild it 2018-09-14 11:02:08 +01:00
Andy Heathershaw 4b8cd6e5ab Merge branch 'feature/99-user-settings-page' of aheathershaw/blue-twilight into master 2018-09-12 21:25:02 +01:00
Andy Heathershaw c369ea5703 Added validation to the user’s profile alias field 2018-09-12 21:24:15 +01:00
Andy Heathershaw 2e0e98810a #99: Email address can now be changed and confirmed with registration 2018-09-12 17:08:27 +01:00
Andy Heathershaw 5a04247621 #99: Started working on e-mail address activation when changed using the user settings page 2018-09-12 14:27:34 +01:00
Andy Heathershaw 929237ef90 #99: User settings are now saving. Still need to implement validation on the profile name and e-mail verification for the new e-mail address. 2018-09-07 10:05:38 +01:00
Andy Heathershaw e4863af668 #5: Don't show the "alternatively..." message when no social media providers are configured 2018-09-07 09:03:21 +01:00
Andy Heathershaw 37375eb7e6 #99: Added user settings related to the new profile pages feature 2018-08-27 21:35:48 +01:00
Andy Heathershaw eddb72c265 Merge branch 'master' into feature/99-user-settings-page 2018-08-27 21:29:10 +01:00
Andy Heathershaw 4a54544756 #99: Added a basic user profile settings screen 2018-08-27 21:28:44 +01:00
Andy Heathershaw 56f555cda6 Merge branch 'feature/5-social-media-sso' of aheathershaw/blue-twilight into master 2018-08-18 08:57:23 +01:00
Andy Heathershaw 98ddb06b76 #5: Implemented login with Google account 2018-08-17 13:37:58 +01:00
Andy Heathershaw c56fe271ef #5: Twitter login is now working completely. If the Twitter app is not authorised to access the user's e-mail address, they still have to create a new account. 2018-08-16 14:01:56 +01:00
Andy Heathershaw 1ed4f297d2 #5: Added settings for configuring Twitter login, this is getting as far now as presenting the Twitter login screen. Login/register screens now respect the social media provider settings 2018-08-16 09:18:54 +01:00
Andy Heathershaw 5926bbcb33 Merge branch 'master' into feature/5-social-media-sso 2018-08-16 08:48:12 +01:00
Andy Heathershaw 29f43db7ea Merge branch 'v2.1' 2018-08-16 08:47:37 +01:00
Andy Heathershaw fa861c0b09 #86: Updated more references from Github to Gitea (Admin > About page) 2018-08-16 08:46:19 +01:00
Andy Heathershaw 8290bafb04 #5: It's now possible to sign in/register with a Facebook account, and to link the FB account to an existing account by entering the account's password. 2018-08-15 14:22:13 +01:00
Andy Heathershaw 40fc25eba9 #5: Facebook login is now working using the app ID/secret stored in the database, instead of in the services file 2018-08-14 12:57:41 +01:00
Andy Heathershaw 8af88c56aa #5: Added settings UI to configure Facebook login 2018-08-14 09:12:28 +01:00
Andy Heathershaw 52473f846e #5: Facebook social login now works. Added Facebook to the login screen template (I'm not 100% happy with this, may need a bit more work.) 2018-08-13 22:03:12 +01:00
Andy Heathershaw 564fcd4b09 #5: Added Laravel Socialite. The redirect to Facebook is now working. 2018-08-13 14:25:56 +01:00
Andy Heathershaw eaba161f5c #19: Added a link to user's public profile page, if enabled. Removed a stray semi-colon in album views. Updated user's profile page to use the new partial view for child albums. 2018-08-11 09:30:48 +01:00
Andy Heathershaw a22ce0c57a #19: Added a check for the user.enable_profile_page column when viewing the profile page. Added link to user's profile page (if enabled) in album footers. Tooltips are now enabled globally. Child album's footer now shows the details as tooltips. 2018-08-11 09:20:40 +01:00
Andy Heathershaw 7e721966e9 #19: The activity grids are now responsive. Improved the responsiveness of the user's image/nickname. 2018-08-08 09:31:56 +01:00
Andy Heathershaw 95a1298233 Merge branch 'master' into feature/19-user-profile-screen 2018-08-07 09:19:44 +01:00
Andy Heathershaw f3e52ac5b5 Merge branch 'bugfix/93-album-redirect-checkbox' of aheathershaw/blue-twilight into master 2018-08-07 09:16:30 +01:00
Andy Heathershaw 6e4eaf35dd #93: Updated mark-up of "add album redirect" checkbox to BS4 2018-08-07 09:15:06 +01:00
Andy Heathershaw cff66e72d6 #94: Child albums now display as 6 and 6 columns on mobile (xs) devices. Pager links are also centered. 2018-08-07 09:07:54 +01:00
Andy Heathershaw 8df9fe7827 Merge branch 'feature/90-photo-date-uploaded' of aheathershaw/blue-twilight into master 2018-08-06 17:08:25 +01:00
Andy Heathershaw bdfb3067dc #90: Added language string (mistakenly committed to user profile branch) 2018-08-06 17:06:59 +01:00
Andy Heathershaw 07f34aa158 Merge branch 'feature/89-more-date-formats' of aheathershaw/blue-twilight into master 2018-08-06 17:04:45 +01:00
Andy Heathershaw 372cc0839f #90: Date created is now displayed on the individual photo page 2018-08-06 17:02:51 +01:00
Andy Heathershaw 4575bac725 #89: Added additional date formats 2018-08-06 17:00:43 +01:00
Andy Heathershaw 4f91863f75 #19: The user profile now displays activity based on taken and uploaded dates 2018-08-06 16:59:50 +01:00
Andy Heathershaw 7e25e65336 #19: The user profile grid is now much more Github-like 2018-08-06 14:02:45 +01:00
Andy Heathershaw 843f284570 Merge branch 'master' into feature/19-user-profile-screen 2018-08-03 12:28:32 +01:00
Andy Heathershaw 1553bd8620 Update 'readme.md' 2018-07-31 22:16:53 +01:00
Andy Heathershaw 031accdf78 Update 'contributing.md' 2018-07-31 21:29:22 +01:00
Andy Heathershaw 30dd0c807d Update 'contributing.md' 2018-07-31 21:26:43 +01:00
Andy Heathershaw bc764d4ee1 Merge branch '86-contribution-readme-links' of Pandy06269/blue-twilight into master 2018-07-31 18:17:34 +01:00
Andy Heathershaw 3904d29c5c #86: Updated contributing file with the new web page and updated links in the readme to Gitea instead of Github, and the rebrand of andysh.uk 2018-07-31 08:46:03 +01:00
Andy Heathershaw 0836ca5557 #86: Switched the update check from Github to Gitea 2018-07-29 22:07:55 +01:00
Andy Heathershaw 4456cd5fa7 #86: Switched the update check from Github to Gitea 2018-07-29 21:58:28 +01:00
Andy Heathershaw 1220e87bc9 Merge branch 'v2.1'
# Conflicts:
#	config/app.php
2018-07-28 09:01:32 +01:00
Andy Heathershaw c029c6ca00 Bumped version for the 2.1.2 release 2018-07-28 09:00:57 +01:00
Andy Heathershaw aa2998ac70 #85: Changed the way next/previous buttons work, and introduced a more consistent ordering when large numbers of photos were uploaded at the same time 2018-07-28 09:00:23 +01:00
Andy Heathershaw eedfd5abdd #84: Corrected permissions query for a non-admin user returning incorrect child albums 2018-07-28 09:00:18 +01:00
Andy Heathershaw efd57cde08 #85: Changed the way next/previous buttons work, and introduced a more consistent ordering when large numbers of photos were uploaded at the same time 2018-07-28 08:59:51 +01:00
Andy Heathershaw 566db25316 #84: Corrected permissions query for a non-admin user returning incorrect child albums 2018-07-28 08:59:07 +01:00
Andy Heathershaw 33680faf92 #19: First draft of the new user profile page, incorporating the beginnings of a heat-map of activity 2018-07-16 06:04:44 +01:00
Andy Heathershaw cd2dcc22a2 #80: Updated Laravel to 5.5 LTS 2018-07-16 03:51:59 +01:00
Andy Heathershaw 9a65e8f1c9 Bumped version number for 2.1.1 2018-07-16 03:31:35 +01:00
Andy Heathershaw 9ea1953ada #79: Corrected validation errors on the login screen 2018-07-15 21:57:15 +01:00
Andy Heathershaw 8dd31961e7 #79: Updated the checkbox on the statistics page for Bootstrap 4 final 2018-07-15 21:45:39 +01:00
Andy Heathershaw c784c623ba Include all update steps in update.php 2018-07-15 21:34:15 +01:00
Andy Heathershaw 9064495f1f Updated the Github URL to andysh-uk. Update script now downloads Composer, like the install script does. 2018-07-15 21:32:17 +01:00
Andy Heathershaw e2d66fd228 Merge branch 'master' into v2.1 2018-07-14 08:22:38 +01:00
Andy Heathershaw 9740582b6e #73: Updated the message when a metadata-update fails so it doesn't say it's removing the photo 2018-07-14 08:15:19 +01:00
Andy Heathershaw d63423bc47 Bumped version number ready for the beta release 2018-07-13 08:12:38 +01:00
Andy Heathershaw 2571675b24
Bumped version number ready for the beta release 2018-07-13 07:51:10 +01:00
Andy Heathershaw 6040c7d4ef #65: Don't start uploading if no file was selected 2018-07-13 00:00:45 +01:00
Andy Heathershaw 4b7b99431f #79: Upgraded Bootstrap to 4.1.2. Number of HTML mark-up changes following the BS upgrade. 2018-07-12 23:46:59 +01:00
Andy Heathershaw ab690b1e25 #68: Reworked the upload progress modal to use modal-footer correctly 2018-07-12 23:18:40 +01:00
Andy Heathershaw 393cc590c1 #68: Added a "Close" button when an single file into an upload fails 2018-07-12 23:08:39 +01:00
Andy Heathershaw ef4df1ab32 #59: Added 2 new settings to customise the albums drop-down navigator. It is now possible to choose to only display top-level albums, and also to restrict the number of items. 2018-07-12 22:52:50 +01:00
Andy Heathershaw f96a9cd9f7 #58: It is now possible to create albums named the same within different parent albums. Albums with child albums can now not be deleted, as this could leave duplicate albums in the same parent album. 2018-07-12 21:55:01 +01:00
Andy Heathershaw 790d354167 #72: When counting albums in the admin stats widget, count all, not just the current page 2018-07-12 06:42:57 +01:00
Andy Heathershaw 036814705f #74: Suppress warning on mkdir due to a race condition for multiple uploads 2018-07-12 06:38:56 +01:00
Andy Heathershaw 534c8f6090 #75: Reworked the way metadata is calculated so empty albums are not displayed as upgradable. Also improved the "no albums" message, as it's no longer accurate. 2018-07-12 06:35:08 +01:00
Andy Heathershaw 309d97cb75 #77: Minor improvements to meta-data update page 2018-07-12 06:09:28 +01:00
Andy Heathershaw 06869a157c #68: Updated footer links to andysh.uk 2018-07-12 06:05:29 +01:00
Andy Heathershaw f007371a79 Merge remote-tracking branch 'origin/master' 2018-07-12 05:59:29 +01:00
Andy Heathershaw cb6ae98907 #62: Found another reference to the global $albums variable, now $g_albums 2018-07-12 05:58:40 +01:00
Andy Heathershaw 189aafe61c
#62: another $albums reference 2018-07-11 08:49:43 +01:00
Andy Heathershaw efdcecfca6
#62: Further correction to $albums variable 2018-07-11 08:45:40 +01:00
Andy Heathershaw cc3370c4b1 #62: Don't clobber the $albums variables in the navbar, so it now shows all albums not just the single page you're currently viewing 2018-07-11 08:00:40 +01:00
Andy Heathershaw 04d1e59778 #61: Album breadcrumbs in admin panel now include full path of parent albums 2018-07-11 07:52:59 +01:00
Andy Heathershaw 2bbaa81ffe Added more apps to the readme file 2018-02-10 16:35:52 +00:00
Andy Heathershaw f3f5e5b615 #60: Slight tweak to the layout of the About page 2017-10-01 21:15:47 +01:00
Andy Heathershaw adb86f1d4e #60: Added the new version information when an update is available 2017-10-01 17:03:19 +01:00
Andy Heathershaw dcfcbca530 #60: Added a basic about page with a link to Github's API to fetch the latest release 2017-10-01 16:48:50 +01:00
Andy Heathershaw 89544437cd Bumped version number in readiness for the final 2.1.0 release 2017-09-30 15:17:23 +01:00
Andy Heathershaw f38911f79e Committed version change for v2.1.0-beta.4 2017-09-30 15:08:24 +01:00
Andy Heathershaw 4326bc427c #52: Removed the Bootstrap 4 custom file inputs as they don't show the filename correctly - and I can't get the 'change' event to fire to change manually. This works fine with a native file input. 2017-09-30 08:34:04 +01:00
Andy Heathershaw a6303410cf #48: Added a check to the Composer install if Composer cannot be used due to allow_url_fopen being disabled 2017-09-30 08:14:21 +01:00
Andy Heathershaw c5e22c7a6e #50: Added a check to see if exec() is available to provide more OS-level information, or falls back to standard php_uname if not 2017-09-29 20:15:24 +01:00
Andy Heathershaw 435e47af17 #56: Stop the Open Album/Manage Album shortcuts opening in a new tab 2017-09-29 19:40:52 +01:00
Andy Heathershaw d575560209 #54, #55: Number of corrections to child albums behaviour. The count of child albums is now displayed in the gallery next to the "X photos" text. Child albums are no longer displayed if the user does not have permissions. 2017-09-29 13:57:45 +01:00
Andy Heathershaw cef1ea63cf #49: Corrected instances of undocumented method set() which has now been removed in Laravel 5.4 in favour if put() 2017-09-29 12:54:55 +01:00
Andy Heathershaw 1618ae64c0 #51: Run the database seeders during a clean install as well as during an update 2017-09-29 12:53:49 +01:00
Andy Heathershaw 150f0a4966 #38: Modified the way the metadata upgrade page works - which now does a "re-analyse" the same way as it does an "analyse" 2017-09-17 16:04:07 +01:00
Andy Heathershaw 010e847835 #42: Slight fix to output order in composer update script 2017-09-17 12:33:10 +01:00
Andy Heathershaw ace6917c7d #42: Clear caches before updating Composer 2017-09-17 12:30:36 +01:00
Andy Heathershaw 5092335761 #47: Linked the default view photo image to the photo page itself 2017-09-17 12:27:59 +01:00
Andy Heathershaw 34a6c446a9 Bumped version number for 2.1.0-beta.2 2017-09-17 12:16:09 +01:00
Andy Heathershaw c04e2d286f #42: Added a Composer update script 2017-09-17 12:04:30 +01:00
Andy Heathershaw 502a13b39f #43: Included ChartJS references locally within the Javascript 2017-09-17 11:54:40 +01:00
Andy Heathershaw 363f6d52f8 #44, #45, #46: Number of small tweaks and fixes to the slideshow view. Label view causes an exception when no photos are tagged to that label. 2017-09-17 11:49:36 +01:00
Andy Heathershaw c258303700 #41: Read and display more photographer-specific details 2017-09-17 09:20:35 +01:00
Andy Heathershaw ab76fb6de5 #35: Removed the disable-on-click attribute from the Composer button 2017-09-16 12:54:43 +01:00
Andy Heathershaw dc2883db20 #39: Raw EXIF data is now removed when an existing photo's image is replaced 2017-09-16 12:52:40 +01:00
Andy Heathershaw 88c687a3d1 #38, #39: EXIF data is now stored base64-encoded in the database to prevent issues with raw characters coming off some cameras. EXIF data is no longer replaced on analysis - allowing rotated images to maintain the data. 2017-09-16 12:49:34 +01:00
Andy Heathershaw 48f43b3c04 #38: Made a few tweaks to the analysis function that doesn't delete the photo if it was previously analysed (i.e. it has a metadata version). Also if the original image contained Exif data (e.g. camera make), we no longer remove it if the re-analysed image doesn't (see #39) 2017-09-16 09:02:25 +01:00
Andy Heathershaw 4f7ad41009 Reverted an erroneous change that shouldn't have happened from git committing on the server 2017-09-16 08:32:23 +01:00
Andy Heathershaw 0b64728d0a First batch of changes for #38 to allow photo metadata updates 2017-09-16 08:26:05 +01:00
Andy Heathershaw 24348b52a4 Update readme.md 2017-09-12 21:49:57 +01:00
Andy Heathershaw c2fa395df6 Update readme.md 2017-09-12 21:49:29 +01:00
Andy Heathershaw a1aeb6b9ef Added latest compiled JS files 2017-09-12 21:47:07 +01:00
Andy Heathershaw 69a7a4e0ab #31: Added a new item on the image edit menu - "Replace image" - which allows an image to be replaced without losing the meta-data 2017-09-12 21:41:47 +01:00
Andy Heathershaw 7bfc829931 #32: Added next/previous buttons to the individual photo page 2017-09-12 20:54:29 +01:00
Andy Heathershaw 365ea689ef #3: Fixed some issues with the statistics combined graph and file size pie graph 2017-09-12 20:36:39 +01:00
Andy Heathershaw 15492f5ad7 Merge branch 'task/6-upgrade-laravel-54' 2017-09-12 20:24:05 +01:00
Andy Heathershaw 9b13120c41 #6: Updated the mailing config to use Markdown. Converted the current e-mail templates to Markdown. 2017-09-12 20:23:48 +01:00
Andy Heathershaw ecd714cbf1 Update .gitattributes 2017-09-11 18:22:37 +01:00
Andy Heathershaw a7b65c21a9 Update readme.md 2017-09-11 14:42:11 +01:00
Andy Heathershaw 187db7ddef Update readme.md 2017-09-11 14:41:37 +01:00
Andy Heathershaw 3d6935774e Update contributing.md 2017-09-11 14:39:59 +01:00
Andy Heathershaw 5bb6c9145e #6: Updated Composer file to bump Laravel to 5.4 2017-09-10 21:56:00 +01:00
Andy Heathershaw e1f6cc4d51 #3: Tweaked the layout of the stats figures on the analytics dashboard 2017-09-10 21:42:08 +01:00
Andy Heathershaw 44aa70d59a Slight tweak to the login/cancel links on smaller forms 2017-09-10 21:33:13 +01:00
Andy Heathershaw 3533c978a5 #2: Moved the quick-upload icon into the normal navbar as it caused the navbar to wrap on mobile devices 2017-09-10 21:27:40 +01:00
Andy Heathershaw 1af9f2658a Individual pages now pick up a layout from the theme, if available, instead of always picking up the base one 2017-09-10 21:12:57 +01:00
Andy Heathershaw 53dc0177fa Added album deletion policy 2017-09-10 17:21:52 +01:00
Andy Heathershaw b7285888cf #3: Merged the two photo charts into one and added a "number at-a-glance" widget on the statistics page 2017-09-10 17:02:15 +01:00
Andy Heathershaw c72c4cc45c Merge remote-tracking branch 'origin/feature/3-analytics-dashboard' into feature/3-analytics-dashboard 2017-09-10 15:49:45 +01:00
Andy Heathershaw c2e9fe617b #2: Added a loading animation to the quick-post/upload function whilst uploading 2017-09-10 15:46:16 +01:00
Andy Heathershaw fee2841910 #2: Added an intermediate step to the quick-post/upload feature that validates the request 2017-09-10 15:25:59 +01:00
Andy Heathershaw 544d3c5153 #2: Basic implementation of the quick-upload/quick-post feature 2017-09-10 15:10:45 +01:00
Andy Heathershaw d9d43e9c29 #29: Number of improvements to Labels to show the count and thumbnail correctly based on the allowed albums 2017-09-10 14:01:20 +01:00
Andy Heathershaw 3254ca1500 #29: Added a new /labels endpoint to display labels and a preview of their photos 2017-09-10 13:21:45 +01:00
Andy Heathershaw 0d0584086b #29: Labels are now included in the sitemap.xml 2017-09-10 12:52:41 +01:00
Andy Heathershaw 693f0c6760 #34: sitemap.xml generator now checks for albums and photos that are accessible to the current user (so anonymous albums aren't included in search engine listings.) 2017-09-10 12:48:48 +01:00
Andy Heathershaw 1f2552c743 #29: Corrected permission check when displaying photos linked to a label. Multiple instances of a new label are no longer duplicated (separated out the creation logic into the LabelController.) 2017-09-10 12:40:24 +01:00
Andy Heathershaw f46d9e1101 #29: Album permissions are now checked when retrieving photos linked with a label. Labels are displayed in the gallery with their own views. 2017-09-10 11:24:44 +01:00
Andy Heathershaw 88d660d92e #33: Fixed an issue where by the anonymous album check did not include the album ID, thereby allowing access if other albums allowed anonymous users. 2017-09-10 11:18:12 +01:00
Andy Heathershaw 818d4c39d2 #29: Added Selectize to allow labels to be assigned to photos, which is now working. 2017-09-10 10:24:15 +01:00
Andy Heathershaw 6280766d70 #29: Labels can now be added and managed through the admin panel 2017-09-10 09:07:56 +01:00
Andy Heathershaw aa99d76ae5 Merge remote-tracking branch 'origin/feature/3-analytics-dashboard' into feature/29-label-photos 2017-09-10 07:51:10 +01:00
Andy Heathershaw e9a6aff7e6 Used a broader colour palette for charts 2017-09-06 22:21:48 +01:00
Andy Heathershaw 7f27921cf7 #3: Implemented the majority of the public facing charts I'd envisaged 2017-09-06 22:00:12 +01:00
Andy Heathershaw 51bd8ca32f Merge branch 'v2.0' into feature/3-analytics-dashboard 2017-09-06 21:01:14 +01:00
Andy Heathershaw f5a269d634 #3: Added a quick and simple pie chart of cameras used in the gallery. Added an image to the "Albums" menu item. 2017-09-06 16:08:38 +01:00
509 changed files with 100226 additions and 73182 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

3
.gitattributes vendored
View File

@ -1,3 +1,2 @@
* text=auto
*.css linguist-vendored
*.scss linguist-vendored
resources/assets/* linguist-vendored

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

@ -2,6 +2,7 @@
namespace App;
use App\AlbumSources\AlbumSourceBase;
use App\AlbumSources\IAlbumSource;
use App\AlbumSources\LocalFilesystemSource;
use App\Helpers\MiscHelper;
@ -21,7 +22,7 @@ class Album extends Model
* @var array
*/
protected $fillable = [
'name', 'description', 'url_alias', 'user_id', 'storage_id', 'default_view', 'parent_album_id', 'url_path'
'name', 'description', 'url_alias', 'user_id', 'storage_id', 'default_view', 'parent_album_id', 'url_path', 'is_permissions_inherited'
];
/**
@ -103,6 +104,35 @@ class Album extends Model
}
}
/**
* Try and locate the parent album ID that permissions are inherited from.
* @return integer
*/
public function effectiveAlbumIDForPermissions()
{
$current = $this;
while (!is_null($current->parent_album_id))
{
if ($current->is_permissions_inherited)
{
$current = $current->parent;
}
else
{
break;
}
}
if (is_null($current->parent_album_id) && $current->is_permissions_inherited)
{
// Use default permissions list
return 0;
}
return $current->id;
}
public function generateAlias()
{
$this->url_alias = MiscHelper::capitaliseWord(preg_replace('/[^a-z0-9\-]/', '-', strtolower($this->name)));
@ -129,10 +159,7 @@ class Album extends Model
*/
public function getAlbumSource()
{
$fullClassName = sprintf('App\AlbumSources\%s', $this->storage->source);
/** @var IAlbumSource $source */
$source = new $fullClassName;
$source = AlbumSourceBase::make($this->storage->source);
$source->setAlbum($this);
$source->setConfiguration($this->storage);
@ -186,21 +213,38 @@ class Album extends Model
public function thumbnailUrl($thumbnailName)
{
/** @var Photo $photo */
$photo = $this->photos()
->inRandomOrder()
->first();
if (!is_null($photo))
{
return $this->getAlbumSource()->getUrlToPhoto($photo, $thumbnailName);
return $photo->thumbnailUrl($thumbnailName);
}
// See if any child albums have an image
$images = [];
/** @var Album $childAlbum */
foreach ($this->children as $childAlbum)
{
if ($childAlbum->photos()->count() > 0)
{
$images[] = $childAlbum->thumbnailUrl($thumbnailName);
}
}
if (count($images) == 0)
{
// Rotate standard images
$images = [
asset('themes/base/images/empty-album-1.jpg'),
asset('themes/base/images/empty-album-2.jpg'),
asset('themes/base/images/empty-album-3.jpg')
];
}
// Rotate standard images
$images = [
asset('themes/base/images/empty-album-1.jpg'),
asset('themes/base/images/empty-album-2.jpg'),
asset('themes/base/images/empty-album-3.jpg')
];
return $images[rand(0, count($images) - 1)];
}
@ -215,6 +259,11 @@ class Album extends Model
return route('viewAlbum', $this->url_path);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function userPermissions()
{
return $this->belongsToMany(Permission::class, 'album_user_permissions');

View File

@ -0,0 +1,9 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class AlbumDefaultAnonymousPermission extends Model
{
}

View File

@ -0,0 +1,9 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class AlbumDefaultGroupPermission extends Model
{
}

View File

@ -0,0 +1,9 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class AlbumDefaultUserPermission extends Model
{
}

View File

@ -17,6 +17,36 @@ abstract class AlbumSourceBase
*/
protected $configuration;
/**
* @var mixed
*/
private static $albumSourceCache = [];
/**
* Makes an album source class for the given source name (relative class name.)
* @param string $sourceName Name of the source.
* @return IAlbumSource
*/
public static function make($sourceName)
{
$fullClassName = sprintf('App\AlbumSources\%s', $sourceName);
if (!array_key_exists($fullClassName, self::$albumSourceCache))
{
/** @var IAlbumSource $source */
$source = app($fullClassName);
self::$albumSourceCache[$fullClassName] = $source;
}
return self::$albumSourceCache[$fullClassName];
}
public function getConfiguration()
{
return $this->configuration;
}
public function setAlbum(Album $album)
{
$this->album = $album;

View File

@ -2,12 +2,14 @@
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
class AmazonS3Source extends AlbumSourceBase implements IAlbumSource, IAnalysisQueueSource
{
/**
* Deletes an entire album's media contents.
@ -19,6 +21,30 @@ class AmazonS3Source extends AlbumSourceBase implements IAlbumSource
// The delete routine will have already removed all photos
}
/**
* Deletes a photo to be analysed from the storage source
* @param string $queueToken Queue token holding the photo
* @param string $fileName Filename of the photo to download
* @return void
*/
public function deleteItemFromAnalysisQueue($queueToken, $fileName)
{
$fileToDelete = $this->getPathToAnalysisQueueItem($queueToken, $fileName);
try
{
$this->getClient()->deleteObject([
'Bucket' => $this->configuration->container_name,
'Key' => $fileToDelete
]);
}
catch (GuzzleException $ex)
{
// Don't worry if the file no longer exists
Log::warning('Failed deleting image from S3.', ['error' => $ex->getMessage(), 'path' => $fileToDelete]);
}
}
/**
* Deletes a thumbnail file for a photo.
* @param Photo $photo Photo to delete the thumbnail from.
@ -36,18 +62,37 @@ class AmazonS3Source extends AlbumSourceBase implements IAlbumSource
'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]);
}
}
/**
* Downloads a photo to be analysed from the storage source to a temporary file
* @param string $queueToken Queue token holding the photo
* @param string $fileName Filename of the photo to download
* @return string Path to the photo that was downloaded
*/
public function fetchItemFromAnalysisQueue($queueToken, $fileName)
{
$tempFile = tempnam(sys_get_temp_dir(), 'BlueTwilight_');
$this->getClient()->getObject([
'Bucket' => $this->configuration->container_name,
'Key' => $this->getPathToAnalysisQueueItem($queueToken, $fileName),
'SaveAs' => $tempFile
]);
return $tempFile;
}
/**
* 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)
{
@ -61,7 +106,7 @@ class AmazonS3Source extends AlbumSourceBase implements IAlbumSource
'SaveAs' => $tempFile
]);
return EntityBody::factory(fopen($tempFile, 'r+'));
return stream_for(fopen($tempFile, 'r+'));
}
finally
{
@ -86,7 +131,19 @@ class AmazonS3Source extends AlbumSourceBase implements IAlbumSource
*/
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));
}
/**
@ -100,7 +157,33 @@ class AmazonS3Source extends AlbumSourceBase implements IAlbumSource
{
$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);
}
/**
* Uploads a new file to the analysis queue specified by queue token.
*
* @param string $sourceFilePath Path to the file to upload to the analysis queue
* @param string $queueToken Queue token to hold the photo
* @param string $overrideFilename Use a specific filename, or false to set a specific name
* @return string Path to the file
*/
public function uploadToAnalysisQueue($sourceFilePath, $queueToken, $overrideFilename = null)
{
$targetPath = sprintf(
'%s/%s',
$this->getPathToAnalysisQueue($queueToken),
is_null($overrideFilename) ? MiscHelper::randomString(20) : $overrideFilename
);
// Note: we don't use "public-read" here as it will not be publicly-accessible, and will be retrieved by an authenticated client
$this->getClient()->upload($this->configuration->container_name, $targetPath, fopen($sourceFilePath, 'r+'));
return $targetPath;
}
private function getClient()
@ -127,6 +210,16 @@ class AmazonS3Source extends AlbumSourceBase implements IAlbumSource
return '_originals';
}
private function getPathToAnalysisQueue($queueToken)
{
return sprintf('analysis-queue/%s', $queueToken);
}
private function getPathToAnalysisQueueItem($queueToken, $fileName)
{
return sprintf('%s/%s', $this->getPathToAnalysisQueue($queueToken), $fileName);
}
private function getPathToPhoto(Photo $photo, $thumbnail = null)
{
return sprintf(

View File

@ -0,0 +1,247 @@
<?php
namespace App\AlbumSources;
use App\BackblazeB2FileIdCache;
use App\Photo;
use App\Services\BackblazeB2Service;
use App\Storage;
use GuzzleHttp\Psr7\Stream;
use Illuminate\Support\Facades\Log;
use function GuzzleHttp\Psr7\stream_for;
class BackblazeB2Source extends AlbumSourceBase implements IAlbumSource
{
const BUCKET_TYPE_AUTO = 0;
const BUCKET_TYPE_PRIVATE = 1;
const BUCKET_TYPE_PUBLIC = 2;
/**
* @var BackblazeB2Service
*/
private $backblaze;
/**
* Type of bucket which determines what type of URLs to generate to images.
* @var integer
*/
private $bucketType;
/**
* Token used to download files from a private bucket.
* @var string
*/
private $downloadToken;
/**
* Deletes an entire album's media contents.
* @return void
*/
public function deleteAlbumContents()
{
// No need to do anything for the album container - once the files are gone, the virtual folder is also gone
}
/**
* 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)
{
$pathOnStorage = $this->getPathToPhoto($photo, $thumbnail);
// Create or update our cache record
$b2Cache = $this->getB2FileFromCache($pathOnStorage);
if (is_null($b2Cache))
{
return;
}
$this->getClient()->deleteFile($b2Cache->b2_file_id, $pathOnStorage);
$b2Cache->delete();
}
/**
* 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);
// First we need the file ID
$b2Cache = $this->getB2FileFromCache($pathOnStorage);
if (is_null($b2Cache))
{
return stream_for('');
}
return stream_for($this->getClient()->downloadFile($b2Cache->b2_file_id));
}
/**
* Gets the name of this album source.
* @return string
*/
public function getName()
{
return 'global.album_sources.backblaze_b2';
}
/**
* Gets the absolute URL to the given photo file.
* @param Photo $photo Photo to get the URL to.
* @param string $thumbnail Thumbnail to get the image to.
* @return string
*/
public function getUrlToPhoto(Photo $photo, $thumbnail = null)
{
$client = $this->getClient();
$pathOnStorage = $this->getPathToPhoto($photo, $thumbnail);
switch ($this->bucketType)
{
case self::BUCKET_TYPE_PRIVATE:
if (is_null($this->downloadToken))
{
$this->downloadToken = $client->getDownloadAuthToken();
}
// Once I sort out the issue with b2_download_file_by_id, this line can be removed
return sprintf('%s/file/%s/%s?Authorization=%s', $client->getDownloadUrl(), $this->configuration->container_name, $pathOnStorage, $this->downloadToken);
$b2Cache = $this->getB2FileFromCache($pathOnStorage);
if (is_null($b2Cache))
{
return '';
}
return sprintf('%s/b2api/v2/b2_download_file_by_id?fileId=%s&Authorization=%s', $client->getDownloadUrl(), urlencode($b2Cache->b2_file_id), urlencode($this->downloadToken));
case self::BUCKET_TYPE_PUBLIC:
/*
* From https://www.backblaze.com/b2/docs/b2_download_file_by_name.html:
* The base URL to use comes from the b2_authorize_account call, and looks something like
* https://f345.backblazeb2.com. The "f" in the URL stands for "file", and the number is the cluster
* number containing your account. To this base, you add "file/", your bucket name, a "/", and then the
* name of the file. The file name may itself include more "/" characters.
*/
return sprintf('%s/file/%s/%s', $client->getDownloadUrl(), $this->configuration->container_name, $pathOnStorage);
}
}
/**
* 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);
$b2Cache = $this->getB2FileFromCache($pathOnStorage);
if (!is_null($b2Cache))
{
// Delete the current file version if we're replacing a file that already exists
$this->getClient()->deleteFile($b2Cache->b2_file_id, $pathOnStorage);
}
// Upload the file to B2
$b2FileID = $this->getClient()->uploadFile($tempFilename, $pathOnStorage);
// Create or update our cache record
if (is_null($b2Cache))
{
$b2Cache = new BackblazeB2FileIdCache([
'photo_id' => $photo->id,
'storage_path' => $pathOnStorage,
'b2_file_id' => $b2FileID
]);
}
else
{
$b2Cache->b2_file_id = $b2FileID;
}
$b2Cache->save();
}
public function setConfiguration(Storage $configuration)
{
parent::setConfiguration($configuration);
}
/**
* @param $pathOnStorage
* @return BackblazeB2FileIdCache|null
*/
private function getB2FileFromCache($pathOnStorage)
{
$b2Cache = BackblazeB2FileIdCache::where('storage_path', $pathOnStorage)->first();
if (is_null($b2Cache))
{
// TODO: lookup the file on B2 to get the file ID
Log::warning(sprintf('B2 file ID not found in cache: %s', $pathOnStorage));
return null;
}
return $b2Cache;
}
private function getClient()
{
if (is_null($this->backblaze))
{
$this->backblaze = new BackblazeB2Service();
$this->backblaze->setCredentials(decrypt($this->configuration->access_key), decrypt($this->configuration->secret_key));
$this->backblaze->authorizeAccount();
$this->backblaze->setBucketName($this->configuration->container_name);
if (intval($this->configuration->b2_bucket_type) == self::BUCKET_TYPE_AUTO)
{
/* Auto-detect the type of bucket in use on B2 */
switch ($this->backblaze->getBucketType())
{
case 'allPrivate':
$this->configuration->b2_bucket_type = self::BUCKET_TYPE_PRIVATE;
break;
case 'allPublic':
$this->configuration->b2_bucket_type = self::BUCKET_TYPE_PUBLIC;
break;
}
$this->configuration->save();
}
// Set the bucket type
$this->bucketType = $this->configuration->b2_bucket_type;
}
return $this->backblaze;
}
private function getOriginalsFolder()
{
return '_originals';
}
private function getPathToPhoto(Photo $photo, $thumbnail = null)
{
return sprintf(
'%s/%s/%s',
$this->album->url_alias,
is_null($thumbnail) ? $this->getOriginalsFolder() : $thumbnail,
$photo->storage_file_name
);
}
}

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

@ -0,0 +1,40 @@
<?php
namespace App\AlbumSources;
use App\Storage;
interface IAnalysisQueueSource
{
/**
* Deletes a photo to be analysed from the storage source
* @param string $queueToken Queue token holding the photo
* @param string $fileName Filename of the photo to download
* @return void
*/
function deleteItemFromAnalysisQueue($queueToken, $fileName);
/**
* Downloads a photo to be analysed from the storage source to a temporary file
* @param string $queueToken Queue token holding the photo
* @param string $fileName Filename of the photo to download
* @return string Path to the photo that was downloaded
*/
function fetchItemFromAnalysisQueue($queueToken, $fileName);
/**
* @param Storage $configuration
* @return mixed
*/
function setConfiguration(Storage $configuration);
/**
* Uploads a new file to the analysis queue specified by queue token.
*
* @param string $sourceFilePath Path to the file to upload to the analysis queue
* @param string $queueToken Queue token to hold the photo
* @param string $overrideFilename Use a specific filename, or false to set a specific name
* @return string Path to the file
*/
function uploadToAnalysisQueue($sourceFilePath, $queueToken, $overrideFilename = null);
}

View File

@ -2,18 +2,18 @@
namespace App\AlbumSources;
use App\Album;
use App\Helpers\FileHelper;
use App\Helpers\MiscHelper;
use App\Photo;
use App\Services\PhotoService;
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.
* @package App\AlbumSources
*/
class LocalFilesystemSource extends AlbumSourceBase implements IAlbumSource
class LocalFilesystemSource extends AlbumSourceBase implements IAlbumSource, IAnalysisQueueSource
{
public function deleteAlbumContents()
{
@ -38,7 +38,7 @@ class LocalFilesystemSource 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)
{
@ -51,7 +51,7 @@ class LocalFilesystemSource extends AlbumSourceBase implements IAlbumSource
'r+'
);
return EntityBody::factory($fh);
return stream_for($fh);
}
public function getName()
@ -120,4 +120,86 @@ class LocalFilesystemSource extends AlbumSourceBase implements IAlbumSource
rmdir($directory);
}
/**
* Deletes a photo to be analysed from the storage source
* @param string $queueToken Queue token holding the photo
* @param string $fileName Filename of the photo to download
* @return void
*/
public function deleteItemFromAnalysisQueue($queueToken, $fileName)
{
$queueFolder = $this->getQueuePath($queueToken);
$filePath = $this->getQueueItemPath($queueToken, $fileName);
@unlink($filePath);
// Delete the parent folder if empty
FileHelper::deleteIfEmpty($queueFolder);
}
/**
* Downloads a photo to be analysed from the storage source to a temporary file
* @param string $queueToken Queue token holding the photo
* @param string $fileName Filename of the photo to download
* @return string Path to the photo that was downloaded
*/
public function fetchItemFromAnalysisQueue($queueToken, $fileName)
{
// Don't actually need to download anything as it's already local
return $this->getQueueItemPath($queueToken, $fileName);
}
/**
* Uploads a new file to the analysis queue specified by queue token.
*
* @param string $sourceFilePath Path to the file to upload to the analysis queue
* @param string $queueToken Queue token to hold the photo
* @param string $overrideFilename Use a specific filename, or false to set a specific name
* @return string Path to the file
*/
public function uploadToAnalysisQueue($sourceFilePath, $queueToken, $overrideFilename = null)
{
$uploadedFile = new File($sourceFilePath);
$tempFilename = $this->getQueueItemPath(
$queueToken,
is_null($overrideFilename) ? MiscHelper::randomString(20) : basename($overrideFilename)
);
// Only add an extension if an override filename was not given, assume this is present
if (is_null($overrideFilename))
{
$extension = $uploadedFile->guessExtension();
if (!is_null($extension))
{
$tempFilename .= '.' . $extension;
}
}
copy($uploadedFile->getRealPath(), $tempFilename);
return $tempFilename;
}
private function getQueueItemPath($queueToken, $fileName)
{
return join(DIRECTORY_SEPARATOR, [$this->getQueuePath($queueToken), $fileName]);
}
private function getQueuePath($queueUid)
{
$path = join(DIRECTORY_SEPARATOR, [
dirname(dirname(__DIR__)),
'storage',
'app',
'analysis-queue',
str_replace(['.', '/', '\\'], '', $queueUid)
]);
if (!file_exists($path))
{
@mkdir($path, 0755, true);
}
return $path;
}
}

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,19 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class BackblazeB2FileIdCache extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'photo_id',
'storage_path',
'b2_file_id'
];
}

View File

@ -0,0 +1,274 @@
<?php
namespace App\Console\Commands;
use App\Album;
use App\Facade\UserConfig;
use App\Photo;
use App\QueueItem;
use App\Services\PhotoService;
use App\Services\RabbitMQService;
use App\User;
use App\UserActivity;
use Illuminate\Console\Command;
class ProcessQueueCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bt-queue:process';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Processes items in the processing queue.';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
if (!UserConfig::isImageProcessingQueueEnabled())
{
$this->output->error('The image processing queue is not enabled');
}
$rabbitmq = new RabbitMQService();
$this->output->writeln('Monitoring queue');
$rabbitmq->waitOnQueue([$this, 'processQueueItem']);
}
/**
* Processes a single item from the queue.
*
* @param $msg
* @return void
*/
public function processQueueItem($msg)
{
$queueItemID = intval($msg->body);
$this->output->writeln(sprintf('Processing queue item %d', $queueItemID));
/** @var QueueItem $queueItem */
$queueItem = QueueItem::where('id', $queueItemID)->first();
if (is_null($queueItem))
{
$this->output->writeln('Queue item does not exist; skipping');
$msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']);
return;
}
try
{
switch (strtolower($queueItem->action_type))
{
case 'photo.analyse':
$this->processPhotoAnalyseMessage($queueItem);
break;
case 'photo.bulk_action.change_album':
case 'photo.bulk_action.delete':
case 'photo.bulk_action.flip_both':
case 'photo.bulk_action.flip_horizontal':
case 'photo.bulk_action.flip_vertical':
case 'photo.bulk_action.refresh_thumbnails':
case 'photo.bulk_action.rotate_left':
case 'photo.bulk_action.rotate_right':
$this->processPhotoBulkActionMessage($queueItem);
break;
default:
$this->output->writeln(sprintf('Action %s is not recognised, skipping', $queueItem->action_type));
return;
}
$queueItem->completed_at = new \DateTime();
$queueItem->save();
}
catch (\Exception $ex)
{
$this->output->error($ex->getMessage());
$queueItem->error_message = $ex->getMessage();
}
finally
{
$queueItem->completed_at = new \DateTime();
$queueItem->save();
$msg->delivery_info['channel']->basic_ack($msg->delivery_info['delivery_tag']);
}
}
private function createActivityRecord(Photo $photo, $type, $userID, $activityDateTime = null)
{
if (is_null($activityDateTime))
{
$activityDateTime = new \DateTime();
}
$userActivity = new UserActivity();
$userActivity->user_id = $userID;
$userActivity->activity_at = $activityDateTime;
$userActivity->type = $type;
$userActivity->photo_id = $photo->id;
$userActivity->save();
}
private function processPhotoAnalyseMessage(QueueItem $queueItem)
{
$this->output->writeln(sprintf('Analysing photo ID %l (batch: %s)', $queueItem->photo_id, $queueItem->batch_reference));
/** @var Photo $photo */
$photo = $queueItem->photo;
if (is_null($photo))
{
$this->output->writeln('Photo does not exist; skipping');
return;
}
/* IF CHANGING THIS LOGIC, ALSO CHECK PhotoController::analyse */
$photoService = new PhotoService($photo);
$photoService->analyse($queueItem->batch_reference);
// Log an activity record for the user's feed (remove an existing one as the date may have changed)
$this->removeExistingActivityRecords($photo, 'photo.taken');
if (!is_null($photo->taken_at))
{
// Log an activity record for the user's feed
$this->createActivityRecord($photo, 'photo.taken', $queueItem->user_id, $photo->taken_at);
}
$queueItem->is_successful = true;
}
private function processPhotoBulkActionMessage(QueueItem $queueItem)
{
$action = str_replace('photo.bulk_action.', '', $queueItem->action_type);
$this->output->writeln(sprintf('Apply action \'%s\' to photo ID %l (batch: %s)', $action, $queueItem->photo_id, $queueItem->batch_reference));
/** @var Photo $photo */
$photo = $queueItem->photo;
if (is_null($photo))
{
$this->output->writeln('Photo does not exist; skipping');
return;
}
/** @var User $user */
$user = $queueItem->user;
if (is_null($user))
{
$this->output->writeln('User does not exist; skipping');
return;
}
$photoService = new PhotoService($photo);
switch (strtolower($action))
{
/* This needs a bit more work - we need to also store the new album ID in the queue_items table */
case 'change_album':
if ($user->can('change-metadata', $photo))
{
$newAlbum = Album::where('id', intval($queueItem->new_album_id))->first();
if (is_null($newAlbum) || !$user->can('upload-photos', $newAlbum))
{
$this->output->writeln('Target album does not exist or user does not have permission.');
return;
}
$this->output->writeln(sprintf('Moving photo to album \'%s\'', $newAlbum->name));
$photoService->changeAlbum($newAlbum);
}
break;
case 'delete':
if ($user->can('delete', $photo))
{
$photoService->delete();
}
break;
case 'flip_both':
if ($user->can('manipulate', $photo))
{
$photoService->flip(true, true);
}
break;
case 'flip_horizontal':
if ($user->can('manipulate', $photo))
{
$photoService->flip(true, false);
}
break;
case 'flip_vertical':
if ($user->can('manipulate', $photo))
{
$photoService->flip(false, true);
}
break;
case 'refresh_thumbnails':
if ($user->can('change-metadata', $photo))
{
$photoService->regenerateThumbnails();
}
break;
case 'rotate_left':
if ($user->can('manipulate', $photo))
{
$photoService->rotate(90);
}
break;
case 'rotate_right':
if ($user->can('manipulate', $photo))
{
$photoService->rotate(270);
}
break;
default:
$this->output->writeln(sprintf('Action \'%s\' not recognised; skipping'));
return;
}
}
private function removeExistingActivityRecords(Photo $photo, $type)
{
$existingFeedRecords = UserActivity::where([
'user_id' => $photo->user_id,
'photo_id' => $photo->id,
'type' => $type
])->get();
foreach ($existingFeedRecords as $existingFeedRecord)
{
$existingFeedRecord->delete();
}
}
}

View File

@ -0,0 +1,143 @@
<?php
namespace App\Console\Commands;
use App\EmailLog;
use App\Facade\UserConfig;
use App\Http\Middleware\GlobalConfiguration;
use Illuminate\Console\Command;
use Illuminate\Mail\Message;
class SendEmailsCommand extends Command
{
private $maxPerBatch;
private $numberOfAttempts;
private $secondsBetweenPolls;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bt-queue:send-emails {--poll}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sends e-mails queued in the database.';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
$this->maxPerBatch = config('mail.queue.emails_per_batch');
$this->numberOfAttempts = config('mail.queue.max_attempts');
$this->secondsBetweenPolls = config('mail.queue.seconds_between_polls');
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
if (!UserConfig::get('queue_emails'))
{
$this->output->error('E-mail queueing is not enabled. E-mails are being sent immediately.');
}
$this->output->writeln('Setting mail configuration');
GlobalConfiguration::updateMailConfig();
$this->output->writeln('E-mail queue runner started');
while (true)
{
$emailsToSend = EmailLog::where([
['sent_at', null],
['number_attempts', '<', $this->numberOfAttempts]
])->limit($this->maxPerBatch)->get();
$this->output->writeln(sprintf(
'%d e-mail%s to send',
$emailsToSend->count(),
$emailsToSend->count() == 1 ? '' : 's'
));
/** @var EmailLog $emailToSend */
foreach ($emailsToSend as $emailToSend)
{
$this->sendEmail($emailToSend);
}
if (!$this->option('poll'))
{
exit();
}
sleep($this->secondsBetweenPolls);
}
}
private function sendEmail(EmailLog $emailLog)
{
$this->output->writeln(sprintf('Sending message with subject \'%s\'', $emailLog->subject));
try
{
app('mailer')->send(
[],
[],
function (Message $message) use ($emailLog)
{
$message->setFrom($emailLog->sender_address, $emailLog->sender_name);
$message->setSubject($emailLog->subject);
$this->addAddresses($emailLog->to_addresses, $message, 'To');
$this->addAddresses($emailLog->cc_addresses, $message, 'Cc');
$this->addAddresses($emailLog->bcc_addresses, $message, 'Bcc');
$message->addPart($emailLog->body_plain, 'text/plain');
$message->setBody($emailLog->body_html, 'text/html');
}
);
$emailLog->sent_at = new \DateTime();
$this->output->writeln('Send completed');
}
catch (\Exception $ex)
{
$this->output->error(sprintf('Send failed: %s', $ex->getMessage()));
}
finally
{
$emailLog->number_attempts++;
$emailLog->save();
}
}
private function addAddresses($dbFieldData, Message $message, $property)
{
$decoded = json_decode($dbFieldData);
if (is_array($decoded))
{
foreach ($decoded as $addressInfo)
{
$this->output->writeln(sprintf('Adding %s address: \'"%s" <%s>\'', $property, $addressInfo->name, $addressInfo->address));
$message->{"set{$property}"}($addressInfo->address, $addressInfo->name);
}
}
}
}

View File

@ -6,6 +6,7 @@ use App\Console\Commands\ProcessUploadCommand;
use App\Console\Commands\RegenerateThumbnailsCommand;
use App\Upload;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
@ -36,5 +37,11 @@ class Kernel extends ConsoleKernel
protected function commands()
{
require base_path('routes/console.php');
// We can only auto-load commands for Laravel 5.5.0 or above
if (version_compare(Application::VERSION, '5.5.0') >= 0)
{
$this->load(__DIR__.'/Commands');
}
}
}

26
app/EmailLog.php Normal file
View File

@ -0,0 +1,26 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class EmailLog extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'sender_user_id',
'queued_at',
'sent_at',
'sender_name',
'sender_address',
'to_addresses',
'cc_addresses',
'subject',
'body_plain',
'body_html'
];
}

View File

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

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;
}
}

18
app/Facade/Misc.php Normal file
View File

@ -0,0 +1,18 @@
<?php
namespace App\Facade;
use Illuminate\Support\Facades\Facade;
class Misc extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return 'misc';
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Helpers;
use App\AlbumSources\IAnalysisQueueSource;
use App\Facade\UserConfig;
use App\Storage;
class AnalysisQueueHelper
{
/**
* Gets the storage queue source in use.
* @return IAnalysisQueueSource
*/
public static function getStorageQueueSource()
{
$queueStorage = Storage::find(UserConfig::get('analysis_queue_storage_location'));
$queueSource = self::createStorageSource($queueStorage);
if (is_null($queueSource))
{
throw new \Exception(sprintf('Queue storage \'%s\' does not support the analysis queue', $queueStorage->name));
}
return $queueSource;
}
/**
* Gets a list of compatible storage sources for the analysis queue.
* @return array
*/
public static function getCompatibleStorages()
{
$storageSources = [];
foreach (Storage::where('is_active', true)->orderBy('name')->get() as $storage)
{
$queueSource = self::createStorageSource($storage);
if (is_null($queueSource))
{
continue;
}
$storageSources[$storage->id] = $storage->name;
}
return $storageSources;
}
private static function createStorageSource(Storage $queueStorage)
{
$fullClassName = sprintf('App\AlbumSources\%s', $queueStorage->source);
/** @var IAnalysisQueueSource $source */
$source = new $fullClassName;
if (!$source instanceof IAnalysisQueueSource)
{
return null;
}
$source->setConfiguration($queueStorage);
return $source;
}
}

View File

@ -2,17 +2,21 @@
namespace App\Helpers;
use App\Album;
use App\AlbumSources\AmazonS3Source;
use App\AlbumSources\BackblazeB2Source;
use App\AlbumSources\DropboxSource;
use App\AlbumSources\IAlbumSource;
use App\AlbumSources\LocalFilesystemSource;
use App\AlbumSources\OpenStackSource;
use App\AlbumSources\OpenStackV1Source;
use App\AlbumSources\RackspaceSource;
use App\Configuration;
use App\Storage;
class ConfigHelper
{
/** @var mixed Cache of configuration values */
private $cache;
public function allowedAlbumViews()
{
return ['default', 'slideshow'];
@ -23,8 +27,17 @@ class ConfigHelper
return [
'Y-m-d - H:i',
'd/m/Y - H:i',
'd-m-Y - H:i',
'd.m.Y - H:i',
'd/M/Y - H:i',
'd-M-Y - H:i',
'm/d/Y - H:i',
'm-d-Y - H:i',
'm.d.Y - H:i',
'jS F Y - H:i',
'jS M. Y - H:i',
'F jS Y - H:i',
'M. jS Y - H:i'
];
}
@ -35,6 +48,8 @@ class ConfigHelper
$classes = [
LocalFilesystemSource::class,
AmazonS3Source::class,
BackblazeB2Source::class,
DropboxSource::class,
OpenStackSource::class,
RackspaceSource::class
];
@ -87,15 +102,35 @@ class ConfigHelper
$currentAppName = $this->get('app_name', false);
return array(
'albums_menu_parents_only' => false,
'albums_menu_number_items' => 10,
'allow_photo_comments' => false,
'allow_photo_comments_anonymous' => true,
'allow_self_registration' => true,
'analysis_queue_storage_location' => Storage::where('is_default', true)->first()->id,
'analytics_code' => '',
'app_name' => trans('global.app_name'),
'date_format' => $this->allowedDateFormats()[0],
'default_album_view' => $this->allowedAlbumViews()[0],
'enable_visitor_hits' => false,
'facebook_external_service_id' => 0,
'google_external_service_id' => 0,
'hotlink_protection' => false,
'items_per_page' => 12,
'items_per_page_admin' => 10,
'moderate_anonymous_users' => true,
'moderate_known_users' => true,
'photo_comments_allowed_html' => 'p,div,span,a,b,i,u',
'photo_comments_thread_depth' => 3,
'public_statistics' => true,
'queue_emails' => false,
'rabbitmq_enabled' => false,
'rabbitmq_server' => 'localhost',
'rabbitmq_password' => encrypt('guest'),
'rabbitmq_port' => 5672,
'rabbitmq_queue' => 'blue_twilight',
'rabbitmq_username' => 'guest',
'rabbitmq_vhost' => '/',
'recaptcha_enabled_registration' => false,
'recaptcha_secret_key' => '',
'recaptcha_site_key' => '',
@ -108,13 +143,24 @@ class ConfigHelper
'smtp_password' => '',
'smtp_port' => 25,
'smtp_username' => '',
'theme' => 'default'
'social_facebook_login' => false,
'social_google_login' => false,
'social_twitter_login' => false,
'social_user_feeds' => false,
'social_user_profiles' => false,
'theme' => 'default',
'twitter_external_service_id' => 0
);
}
public function get($key, $defaultIfUnset = true)
{
$config = Configuration::where('key', $key)->first();
if (is_null($this->cache))
{
$this->loadCache();
}
$config = isset($this->cache[$key]) ? $this->cache[$key] : null;
if (is_null($config))
{
@ -134,6 +180,7 @@ class ConfigHelper
{
$results = array();
/** @var Configuration $config */
foreach (Configuration::all() as $config)
{
$results[$config->key] = $config->value;
@ -144,15 +191,64 @@ class ConfigHelper
public function getOrCreateModel($key)
{
$config = Configuration::where('key', $key)->first();
$config = isset($this->cache[$key]) ? $this->cache[$key] : null;
if (is_null($config) || $config === false)
{
$config = new Configuration();
$config->key = $key;
$config->value = '';
$config->save();
$this->loadCache();
}
return $config;
}
public function isImageProcessingQueueEnabled()
{
return $this->get('rabbitmq_enabled') &&
!empty($this->get('rabbitmq_server')) &&
!empty($this->get('rabbitmq_port')) &&
!empty($this->get('rabbitmq_username')) &&
!empty($this->get('rabbitmq_password')) &&
!empty($this->get('rabbitmq_queue')) &&
!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->isLoginWithFacebookEnabled() ||
$this->isLoginWithGoogleEnabled() ||
$this->isLoginWithTwitterEnabled();
}
private function loadCache()
{
$this->cache = null;
/** @var Configuration $config */
foreach (Configuration::all() as $config)
{
$this->cache[$config->key] = $config;
}
}
}

View File

@ -9,19 +9,36 @@ use Illuminate\Support\Facades\Auth;
class DbHelper
{
private static $allowedAlbumIDs = null;
public static function getAlbumIDsForCurrentUser()
{
if (is_null(self::$allowedAlbumIDs))
{
$query = self::getAlbumsForCurrentUser_NonPaged();
$query->select('albums.id');
$ids = [];
foreach ($query->get() as $album)
{
$ids[] = $album->id;
}
self::$allowedAlbumIDs = $ids;
}
return self::$allowedAlbumIDs;
}
public static function getAlbumsForCurrentUser($parentID = -1)
{
$query = self::getAlbumsForCurrentUser_NonPaged();
if ($parentID == 0)
{
$query = $query->where('albums.parent_album_id', null);
}
$query = self::getAlbumsForCurrentUser_NonPaged('list', $parentID);
return $query->paginate(UserConfig::get('items_per_page'));
}
public static function getAlbumsForCurrentUser_NonPaged()
public static function getAlbumsForCurrentUser_NonPaged($permission = 'list', $parentAlbumID = -1)
{
$albumsQuery = Album::query();
$user = Auth::user();
@ -30,40 +47,23 @@ class DbHelper
{
/* Admin users always get everything, therefore no filters are necessary */
}
else if (is_null($user))
{
/* Anonymous users need to check the album_anonymous_permissions table. If not in this table, you're not allowed! */
$albumsQuery = Album::join('album_anonymous_permissions', 'album_anonymous_permissions.album_id', '=', 'albums.id')
->join('permissions', 'permissions.id', '=', 'album_anonymous_permissions.permission_id')
->where([
['permissions.section', 'album'],
['permissions.description', 'list']
]);
}
else
{
/*
Other users need to check either the album_group_permissions or album_user_permissions table. If not in either of these tables,
you're not allowed!
*/
$helper = new PermissionsHelper();
$albumIDs = $helper->getAlbumIDs($permission, $user);
//dd($albumIDs->toArray());
$albumsQuery->whereIn('albums.id', $albumIDs);
//
}
$albumsQuery = Album::leftJoin('album_group_permissions', 'album_group_permissions.album_id', '=', 'albums.id')
->leftJoin('album_user_permissions', 'album_user_permissions.album_id', '=', 'albums.id')
->leftJoin('permissions AS group_permissions', 'group_permissions.id', '=', 'album_group_permissions.permission_id')
->leftJoin('permissions AS user_permissions', 'user_permissions.id', '=', 'album_user_permissions.permission_id')
->leftJoin('user_groups', 'user_groups.group_id', '=', 'album_group_permissions.group_id')
->where('albums.user_id', $user->id)
->orWhere([
['group_permissions.section', 'album'],
['group_permissions.description', 'list'],
['user_groups.user_id', $user->id]
])
->orWhere([
['user_permissions.section', 'album'],
['user_permissions.description', 'list'],
['album_user_permissions.user_id', $user->id]
]);
$parentAlbumID = intval($parentAlbumID);
if ($parentAlbumID == 0)
{
$albumsQuery->where('albums.parent_album_id', null);
}
else if ($parentAlbumID > 0)
{
$albumsQuery->where('albums.parent_album_id', $parentAlbumID);
}
return $albumsQuery->select('albums.*')
@ -81,4 +81,14 @@ class DbHelper
{
return Album::where('url_path', $urlPath)->first();
}
public static function getChildAlbumsCount(Album $album)
{
return self::getAlbumsForCurrentUser_NonPaged('list', $album->id)->count();
}
public static function getChildAlbums(Album $album)
{
return self::getAlbumsForCurrentUser_NonPaged('list', $album->id)->get();
}
}

View File

@ -46,7 +46,7 @@ class FileHelper
if (!file_exists($path))
{
mkdir($path, 0755, true);
@mkdir($path, 0755, true);
}
return $path;

View File

@ -52,14 +52,68 @@ class MiscHelper
return (int) $val;
}
/**
* Convert a decimal (e.g. 3.5) to a fraction (e.g. 7/2).
* Adapted from: http://jonisalonen.com/2012/converting-decimal-numbers-to-ratios/
*
* @param float $decimal the decimal number.
*
* @return array|bool a 1/2 would be [1, 2] array (this can be imploded with '/' to form a string)
*/
public static function decimalToFraction($decimal)
{
if ($decimal < 0 || !is_numeric($decimal)) {
// Negative digits need to be passed in as positive numbers
// and prefixed as negative once the response is imploded.
return false;
}
if ($decimal == 0) {
return [0, 0];
}
$tolerance = 1.e-4;
$numerator = 1;
$h2 = 0;
$denominator = 0;
$k2 = 1;
$b = 1 / $decimal;
do {
$b = 1 / $b;
$a = floor($b);
$aux = $numerator;
$numerator = $a * $numerator + $h2;
$h2 = $aux;
$aux = $denominator;
$denominator = $a * $denominator + $k2;
$k2 = $aux;
$b = $b - $a;
} while (abs($decimal - $numerator / $denominator) > $decimal * $tolerance);
return [
$numerator,
$denominator
];
}
public static function ensureHasTrailingSlash($string)
{
if (strlen($string) > 0 && substr($string, strlen($string) - 1, 1) != '/')
{
$string .= '/';
}
return $string;
}
public static function getEnvironmentFilePath()
{
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))
{
@ -80,6 +134,12 @@ class MiscHelper
return MiscHelper::getEnvironmentSetting('APP_INSTALLED');
}
public static function isExecEnabled()
{
$disabled = explode(',', ini_get('disable_functions'));
return !in_array('exec', $disabled);
}
/**
* Tests whether the provided URL belongs to the current application (i.e. both scheme and hostname match.)
* @param $url

View File

@ -0,0 +1,258 @@
<?php
namespace App\Helpers;
use App\Album;
use App\AlbumDefaultAnonymousPermission;
use App\AlbumDefaultGroupPermission;
use App\AlbumDefaultUserPermission;
use App\Permission;
use App\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class PermissionsHelper
{
public function getAlbumIDs($permission = 'list', User $user = null)
{
$result = [];
// First check if the anonymous user can do what is being requested - if so, the permission would also inherit
// to logged-in users
$anonymousUsersCan = DB::table('album_permissions_cache')
->join('permissions', 'permissions.id', '=', 'album_permissions_cache.permission_id')
->where([
['album_permissions_cache.user_id', null],
['permissions.section', 'album'],
['permissions.description', $permission]
])
->select('album_permissions_cache.album_id')
->distinct()
->get();
foreach ($anonymousUsersCan as $item)
{
$result[] = $item->album_id;
}
$query = DB::table('album_permissions_cache')
->join('permissions', 'permissions.id', '=', 'album_permissions_cache.permission_id')
->where([
['album_permissions_cache.user_id', (is_null($user) || $user->isAnonymous() ? null : $user->id)],
['permissions.section', 'album'],
['permissions.description', $permission]
])
->select('album_permissions_cache.album_id')
->distinct()
->get();
foreach ($query as $item)
{
if (!in_array($item->album_id, $result))
{
$result[] = $item->album_id;
}
}
return $result;
}
public function rebuildCache()
{
$this->rebuildAlbumCache();
}
public function userCan_Album(Album $album, User $user, $permission)
{
// First check if the anonymous user can do what is being requested - if so, the permission would also inherit
// to logged-in users
$anonymousUsersCan = DB::table('album_permissions_cache')
->join('permissions', 'permissions.id', '=', 'album_permissions_cache.permission_id')
->where([
['album_permissions_cache.album_id', $album->id],
['album_permissions_cache.user_id', null],
['permissions.section', 'album'],
['permissions.description', $permission]
])
->count() > 0;
if ($anonymousUsersCan)
{
return true;
}
return DB::table('album_permissions_cache')
->join('permissions', 'permissions.id', '=', 'album_permissions_cache.permission_id')
->where([
['album_permissions_cache.album_id', $album->id],
['album_permissions_cache.user_id', (is_null($user) || $user->isAnonymous() ? null : $user->id)],
['permissions.section', 'album'],
['permissions.description', $permission]
])
->count() > 0;
}
public function usersWhoCan_Album(Album $album, $permission)
{
$users = DB::table('album_permissions_cache')
->join('permissions', 'permissions.id', '=', 'album_permissions_cache.permission_id')
->where([
['album_permissions_cache.album_id', $album->id],
['permissions.section', 'album'],
['permissions.description', $permission]
])
->get();
// Include the album's owner (who can do everything)
$users->push($album->user);
return $users;
}
private function rebuildAlbumCache()
{
// Get a list of albums
$albums = Album::all();
// Get a list of all configured permissions
$albumUserPermissions = DB::table('album_user_permissions')->get();
$albumGroupPermissions = DB::table('album_group_permissions')->get();
$albumAnonPermissions = DB::table('album_anonymous_permissions')->get();
$defaultAlbumUserPermissions = AlbumDefaultUserPermission::all();
$defaultAlbumGroupPermissions = AlbumDefaultGroupPermission::all();
$defaultAnonPermissions = AlbumDefaultAnonymousPermission::all();
// Get a list of all user->group memberships
$userGroups = DB::table('user_groups')->get();
// Build a matrix of new permissions
$permissionsCache = [];
/** @var Album $album */
foreach ($albums as $album)
{
$effectiveAlbumID = $album->effectiveAlbumIDForPermissions();
if ($effectiveAlbumID === 0)
{
/* Use the default permissions list */
foreach ($defaultAnonPermissions as $anonymousPermission)
{
$permissionsCache[] = [
'album_id' => $album->id,
'permission_id' => $anonymousPermission->permission_id,
'created_at' => new \DateTime(),
'updated_at' => new \DateTime()
];
}
foreach ($defaultAlbumUserPermissions as $userPermission)
{
$permissionsCache[] = [
'user_id' => $userPermission->user_id,
'album_id' => $album->id,
'permission_id' => $userPermission->permission_id,
'created_at' => new \DateTime(),
'updated_at' => new \DateTime()
];
}
foreach ($defaultAlbumGroupPermissions as $groupPermission)
{
// Get a list of users in this group, and add one per user
$usersInGroup = array_filter($userGroups->toArray(), function ($item) use ($groupPermission)
{
return $item->group_id = $groupPermission->group_id;
});
foreach ($usersInGroup as $userGroup)
{
$permissionsCache[] = [
'user_id' => $userGroup->user_id,
'album_id' => $album->id,
'permission_id' => $groupPermission->permission_id,
'created_at' => new \DateTime(),
'updated_at' => new \DateTime()
];
}
}
}
else
{
/* Use the specified album-specific permissions */
$anonymousPermissions = array_filter($albumAnonPermissions->toArray(), function ($item) use ($effectiveAlbumID)
{
return ($item->album_id == $effectiveAlbumID);
});
foreach ($anonymousPermissions as $anonymousPermission)
{
$permissionsCache[] = [
'album_id' => $album->id,
'permission_id' => $anonymousPermission->permission_id,
'created_at' => new \DateTime(),
'updated_at' => new \DateTime()
];
}
$userPermissions = array_filter($albumUserPermissions->toArray(), function ($item) use ($effectiveAlbumID)
{
return ($item->album_id == $effectiveAlbumID);
});
foreach ($userPermissions as $userPermission)
{
$permissionsCache[] = [
'user_id' => $userPermission->user_id,
'album_id' => $album->id,
'permission_id' => $userPermission->permission_id,
'created_at' => new \DateTime(),
'updated_at' => new \DateTime()
];
}
$groupPermissions = array_filter($albumGroupPermissions->toArray(), function ($item) use ($effectiveAlbumID)
{
return ($item->album_id == $effectiveAlbumID);
});
foreach ($groupPermissions as $groupPermission)
{
// Get a list of users in this group, and add one per user
$usersInGroup = array_filter($userGroups->toArray(), function ($item) use ($groupPermission)
{
return $item->group_id = $groupPermission->group_id;
});
foreach ($usersInGroup as $userGroup)
{
$permissionsCache[] = [
'user_id' => $userGroup->user_id,
'album_id' => $album->id,
'permission_id' => $groupPermission->permission_id,
'created_at' => new \DateTime(),
'updated_at' => new \DateTime()
];
}
}
}
}
$this->savePermissionsCache($permissionsCache);
}
private function savePermissionsCache(array $cacheToSave)
{
DB::transaction(function() use ($cacheToSave)
{
DB::table('album_permissions_cache')->truncate();
foreach ($cacheToSave as $cacheItem)
{
DB::table('album_permissions_cache')->insert($cacheItem);
}
});
}
}

View File

@ -2,8 +2,37 @@
namespace App\Helpers;
use Illuminate\Support\Facades\DB;
class ValidationHelper
{
public function albumPathUnique($attribute, $value, $parameters, $validator)
{
$data = $validator->getData();
$parentID = intval($data['parent_album_id']);
$name = $data['name'];
if ($parentID === 0)
{
$parentID = null;
}
$queryParams = [
['name', $name],
['parent_album_id', $parentID]
];
if (count($parameters) > 0)
{
$existingAlbumID = intval($parameters[0]);
$queryParams[] = ['id', '<>', $existingAlbumID];
}
$count = DB::table('albums')->where($queryParams)->count();
return ($count == 0);
}
public function directoryExists($attribute, $value, $parameters, $validator)
{
return file_exists($value) && is_dir($value);

View File

@ -3,20 +3,26 @@
namespace App\Http\Controllers\Admin;
use App\Album;
use App\AlbumDefaultAnonymousPermission;
use App\AlbumDefaultGroupPermission;
use App\AlbumDefaultUserPermission;
use App\AlbumRedirect;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Group;
use App\Helpers\DbHelper;
use App\Helpers\MiscHelper;
use App\Helpers\PermissionsHelper;
use App\Http\Controllers\Controller;
use App\Http\Requests;
use App\Label;
use App\Permission;
use App\Photo;
use App\Services\AlbumService;
use App\Services\PhotoService;
use App\Storage;
use App\User;
use App\UserActivity;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
@ -25,6 +31,30 @@ use Illuminate\Support\Facades\View;
class AlbumController extends Controller
{
public static function doesGroupHaveDefaultPermission(Group $group, Permission $permission)
{
return AlbumDefaultGroupPermission::where([
'group_id' => $group->id,
'permission_id' => $permission->id
])->count() > 0;
}
public static function doesUserHaveDefaultPermission($user, Permission $permission)
{
// User will be null for anonymous users
if (is_null($user))
{
return AlbumDefaultAnonymousPermission::where(['permission_id' => $permission->id])->count() > 0;
}
else
{
return AlbumDefaultUserPermission::where([
'user_id' => $user->id,
'permission_id' => $permission->id
])->count() > 0;
}
}
public function __construct()
{
$this->middleware('auth');
@ -43,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]);
@ -80,6 +110,41 @@ class AlbumController extends Controller
]);
}
public function defaultPermissions()
{
$this->authorizeAccessToAdminPanel('admin:manage-albums');
$addNewGroups = [];
$existingGroups = [];
foreach (Group::orderBy('name')->get() as $group)
{
if (AlbumDefaultGroupPermission::where('group_id', $group->id)->count() == 0)
{
$addNewGroups[] = $group;
}
else
{
$existingGroups[] = $group;
}
}
$existingUsers = [];
foreach (User::orderBy('name')->get() as $user)
{
if (AlbumDefaultUserPermission::where('user_id', $user->id)->count() > 0)
{
$existingUsers[] = $user;
}
}
return Theme::render('admin.album_default_permissions', [
'add_new_groups' => $addNewGroups,
'all_permissions' => Permission::where('section', 'album')->get(),
'existing_groups' => $existingGroups,
'existing_users' => $existingUsers
]);
}
public function delete($id)
{
$this->authorizeAccessToAdminPanel('admin:manage-albums');
@ -105,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']));
}
/**
@ -120,6 +185,12 @@ class AlbumController extends Controller
$album = $this->loadAlbum($id, 'delete');
if ($album->children()->count() > 0)
{
$request->session()->flash('error', trans('admin.delete_album_failed_children', ['album' => $album->name]));
return redirect(route('albums.index'));
}
// Delete all the photo files
/** @var Photo $photo */
foreach ($album->photos as $photo)
@ -172,6 +243,10 @@ class AlbumController extends Controller
// Only get top-level albums
$albums = DbHelper::getAlbumsForCurrentUser(0);
foreach ($albums as $album)
{
$this->loadChildAlbums($album);
}
return Theme::render('admin.list_albums', [
'albums' => $albums,
@ -179,6 +254,153 @@ class AlbumController extends Controller
]);
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function metadata(Request $request, $id)
{
$this->authorizeAccessToAdminPanel('admin:manage-albums');
/** @var Album $album */
$album = $this->loadAlbum($id);
$photosNeededToUpdate = $album->photos()->where('metadata_version', '<', PhotoService::METADATA_VERSION)->get();
return Theme::render('admin.album_metadata', [
'album' => $album,
'current_metadata' => PhotoService::METADATA_VERSION,
'photos' => $photosNeededToUpdate,
'queue_token' => MiscHelper::randomString()
]);
}
public function setDefaultGroupPermissions(Request $request)
{
$this->authorizeAccessToAdminPanel('admin:manage-albums');
if ($request->get('action') == 'add_group' && $request->has('group_id'))
{
/* Add a new group to the default permission list */
/** @var Group $group */
$group = Group::where('id', $request->get('group_id'))->first();
if (is_null($group))
{
App::abort(404);
}
// Link all default permissions to the group
/** @var Permission $permission */
foreach (Permission::where(['section' => 'album', 'is_default' => true])->get() as $permission)
{
$defaultPermission = new AlbumDefaultGroupPermission();
$defaultPermission->group_id = $group->id;
$defaultPermission->permission_id = $permission->id;
$defaultPermission->save();
}
}
else if ($request->get('action') == 'update_group_permissions')
{
/* Update existing group permissions for this album */
AlbumDefaultGroupPermission::truncate();
$permissions = $request->get('permissions');
if (is_array($permissions))
{
foreach ($permissions as $groupID => $permissionIDs)
{
foreach ($permissionIDs as $permissionID)
{
$defaultPermission = new AlbumDefaultGroupPermission();
$defaultPermission->group_id = $groupID;
$defaultPermission->permission_id = $permissionID;
$defaultPermission->save();
}
}
}
}
// Rebuild the permissions cache
$helper = new PermissionsHelper();
$helper->rebuildCache();
return redirect(route('albums.defaultPermissions'));
}
public function setDefaultUserPermissions(Request $request)
{
$this->authorizeAccessToAdminPanel('admin:manage-albums');
if ($request->get('action') == 'add_user' && $request->has('user_id'))
{
/* Add a new user to the permission list for this album */
/** @var User $user */
$user = User::where('id', $request->get('user_id'))->first();
if (is_null($user))
{
App::abort(404);
}
// Link all default permissions to the group
/** @var Permission $permission */
foreach (Permission::where(['section' => 'album', 'is_default' => true])->get() as $permission)
{
$defaultPermission = new AlbumDefaultUserPermission();
$defaultPermission->user_id = $user->id;
$defaultPermission->permission_id = $permission->id;
$defaultPermission->save();
}
}
else if ($request->get('action') == 'update_user_permissions')
{
/* Update existing user and anonymous permissions for this album */
AlbumDefaultAnonymousPermission::truncate();
AlbumDefaultUserPermission::truncate();
$permissions = $request->get('permissions');
if (is_array($permissions))
{
if (isset($permissions['anonymous']))
{
foreach ($permissions['anonymous'] as $permissionID)
{
$defaultPermission = new AlbumDefaultAnonymousPermission();
$defaultPermission->permission_id = $permissionID;
$defaultPermission->save();
}
}
foreach ($permissions as $key => $value)
{
$userID = intval($key);
if ($userID == 0)
{
// Skip non-numeric IDs (e.g. anonymous)
continue;
}
foreach ($value as $permissionID)
{
$defaultPermission = new AlbumDefaultUserPermission();
$defaultPermission->user_id = $userID;
$defaultPermission->permission_id = $permissionID;
$defaultPermission->save();
}
}
}
}
// Rebuild the permissions cache
$helper = new PermissionsHelper();
$helper->rebuildCache();
return redirect(route('albums.defaultPermissions'));
}
public function setGroupPermissions(Request $request, $id)
{
$this->authorizeAccessToAdminPanel('admin:manage-albums');
@ -232,6 +454,10 @@ class AlbumController extends Controller
$album->save();
// Rebuild the permissions cache
$helper = new PermissionsHelper();
$helper->rebuildCache();
return redirect(route('albums.show', [$album->id, 'tab' => 'permissions']));
}
@ -307,6 +533,10 @@ class AlbumController extends Controller
$album->save();
// Rebuild the permissions cache
$helper = new PermissionsHelper();
$helper->rebuildCache();
return redirect(route('albums.show', [$album->id, 'tab' => 'permissions']));
}
@ -394,6 +624,7 @@ class AlbumController extends Controller
'existing_users' => $existingUsers,
'file_upload_limit' => $fileUploadLimit,
'is_upload_enabled' => $isUploadEnabled,
'labels' => Label::all(),
'max_post_limit' => $postLimit,
'max_post_limit_bulk' => $fileUploadOrPostLowerLimit,
'photos' => $photos,
@ -415,6 +646,7 @@ class AlbumController extends Controller
$album = new Album();
$album->fill($request->only(['name', 'description', 'storage_id', 'parent_album_id']));
$album->is_permissions_inherited = (strtolower($request->get('is_permissions_inherited')) == 'on');
if (strlen($album->parent_album_id) == 0)
{
@ -428,22 +660,52 @@ class AlbumController extends Controller
$album->generateUrlPath();
$album->save();
// Link all default permissions to anonymous users (if a public album)
// Link the default permissions (if a public album)
$isPrivate = (strtolower($request->get('is_private')) == 'on');
if (!$isPrivate)
if (!$album->is_permissions_inherited && !$isPrivate)
{
/** @var Permission $permission */
foreach (Permission::where(['section' => 'album', 'is_default' => true])->get() as $permission)
$defaultAlbumUserPermissions = AlbumDefaultUserPermission::all();
$defaultAlbumGroupPermissions = AlbumDefaultGroupPermission::all();
$defaultAnonPermissions = AlbumDefaultAnonymousPermission::all();
/** @var AlbumDefaultAnonymousPermission $permission */
foreach ($defaultAnonPermissions as $permission)
{
$album->anonymousPermissions()->attach($permission->id, [
$album->anonymousPermissions()->attach($permission->permission_id, [
'created_at' => new \DateTime(),
'updated_at' => new \DateTime()
]);
}
/** @var AlbumDefaultGroupPermission $permission */
foreach ($defaultAlbumGroupPermissions as $permission)
{
$album->groupPermissions()->attach($permission->permission_id, [
'group_id' => $permission->group_id,
'created_at' => new \DateTime(),
'updated_at' => new \DateTime()
]);
}
/** @var AlbumDefaultUserPermission $permission */
foreach ($defaultAlbumUserPermissions as $permission)
{
$album->userPermissions()->attach($permission->permission_id, [
'user_id' => $permission->user_id,
'created_at' => new \DateTime(),
'updated_at' => new \DateTime()
]);
}
}
return redirect(route('albums.show', ['id' => $album->id]));
// Add an activity record
$this->createActivityRecord($album, 'album.created');
// Rebuild the permissions cache
$helper = new PermissionsHelper();
$helper->rebuildCache();
return redirect(route('albums.show', ['album' => $album->id]));
}
public function storeRedirect(Requests\StoreAlbumRedirectRequest $request, $id)
@ -458,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']));
}
/**
@ -476,6 +738,7 @@ class AlbumController extends Controller
$currentParentID = $album->parent_album_id;
$album->fill($request->only(['name', 'description', 'parent_album_id']));
$album->is_permissions_inherited = (strtolower($request->get('is_permissions_inherited')) == 'on');
if (strlen($album->parent_album_id) == 0)
{
@ -512,9 +775,29 @@ class AlbumController extends Controller
}
$album->save();
// Rebuild the permissions cache
$helper = new PermissionsHelper();
$helper->rebuildCache();
$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)
{
if (is_null($activityDateTime))
{
$activityDateTime = new \DateTime();
}
$userActivity = new UserActivity();
$userActivity->user_id = $this->getUser()->id;
$userActivity->activity_at = $activityDateTime;
$userActivity->type = $type;
$userActivity->album_id = $album->id;
$userActivity->save();
}
/**
@ -534,4 +817,13 @@ class AlbumController extends Controller
return $album;
}
private function loadChildAlbums(Album $album)
{
$album->child_albums = DbHelper::getChildAlbums($album);
foreach ($album->child_albums as $childAlbum)
{
$this->loadChildAlbums($childAlbum);
}
}
}

View File

@ -3,19 +3,25 @@
namespace App\Http\Controllers\Admin;
use App\Album;
use App\Configuration;
use App\ExternalService;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Group;
use App\Helpers\ConfigHelper;
use App\Helpers\AnalysisQueueHelper;
use App\Helpers\DbHelper;
use App\Helpers\MiscHelper;
use App\Helpers\PermissionsHelper;
use App\Http\Controllers\Controller;
use App\Http\Requests\SaveSettingsRequest;
use App\Label;
use App\Mail\TestMailConfig;
use App\Photo;
use App\PhotoComment;
use App\Services\GiteaService;
use App\Services\PhotoService;
use App\Storage;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
@ -23,29 +29,143 @@ use Illuminate\Support\Facades\View;
class DefaultController extends Controller
{
private $passwordSettingKeys;
public function __construct()
{
$this->middleware('auth');
View::share('is_admin', true);
$this->passwordSettingKeys = [
'rabbitmq_password',
'smtp_password',
'facebook_app_secret',
'google_app_secret',
'twitter_app_secret'
];
}
public function about()
{
return Theme::render('admin.about', [
'current_version' => config('app.version'),
'licence_text' => file_get_contents(sprintf('%s/LICENSE', dirname(dirname(dirname(dirname(__DIR__))))))
]);
}
public function aboutLatestRelease()
{
try
{
$giteaService = new GiteaService();
$releaseInfo = $giteaService->checkForLatestRelease();
// Convert the publish date so we can re-format it with the user's settings
$publishDate = \DateTime::createFromFormat('Y-m-d\TH:i:sP', $releaseInfo->published_at);
// HTML-ify the body text
$body = nl2br($releaseInfo->body);
$body = preg_replace('/\*\*(.+)\*\*/', '<b>$1</b>', $body);
// Remove the "v" from the release name
$version = substr($releaseInfo->tag_name, 1);
// Determine if we can upgrade
$canUpgrade = version_compare($version, config('app.version')) > 0;
return response()->json([
'can_upgrade' => $canUpgrade,
'body' => $body,
'name' => $version,
'publish_date' => $publishDate->format(UserConfig::get('date_format')),
'url' => $releaseInfo->html_url
]);
}
catch (\Exception $ex)
{
return response()->json(['error' => $ex->getMessage()]);
}
}
public function metadataUpgrade()
{
$albumIDs = DbHelper::getAlbumIDsForCurrentUser();
$photoMetadata = DB::table('photos')
->whereIn('album_id', $albumIDs)
->select([
'album_id',
DB::raw('MIN(metadata_version) AS min_metadata_version')
])
->groupBy('album_id')
->get();
$resultingAlbumIDs = [];
foreach ($photoMetadata as $metadata)
{
if (isset($metadata->min_metadata_version) && $metadata->min_metadata_version > 0)
{
$resultingAlbumIDs[$metadata->album_id] = $metadata->min_metadata_version;
}
}
// Now load the full album definitions
$albumsQuery = DbHelper::getAlbumsForCurrentUser_NonPaged();
$albumsQuery->whereIn('id', array_keys($resultingAlbumIDs));
$albums = $albumsQuery->paginate(UserConfig::get('items_per_page'));
/** @var Album $album */
foreach ($resultingAlbumIDs as $albumID => $metadataMinVersion)
{
foreach ($albums as $album)
{
if ($album->id == $albumID)
{
$album->min_metadata_version = $metadataMinVersion;
}
}
}
return Theme::render('admin.metadata_upgrade', [
'albums' => $albums,
'current_metadata_version' => PhotoService::METADATA_VERSION
]);
}
public function index()
{
$this->authorizeAccessToAdminPanel();
$albumCount = DbHelper::getAlbumsForCurrentUser()->count();
$albumCount = count(DbHelper::getAlbumIDsForCurrentUser());
$photoCount = Photo::all()->count();
$groupCount = Group::all()->count();
$labelCount = Label::all()->count();
$commentCount = PhotoComment::whereNotNull('approved_at')->count();
$userCount = User::where('is_activated', true)->count();
$minMetadataVersion = Photo::min('metadata_version');
$metadataUpgradeNeeded = $minMetadataVersion > 0 && $minMetadataVersion < PhotoService::METADATA_VERSION;
// Default to a supported function call to get the OS version
$osVersion = sprintf('%s %s', php_uname('s'), php_uname('r'));
// If the exec() function is enabled, we can do a bit better
if (MiscHelper::isExecEnabled())
{
$osVersion = exec('lsb_release -ds 2>/dev/null || cat /etc/*release 2>/dev/null | head -n1 || uname -om');
}
return Theme::render('admin.index', [
'album_count' => $albumCount,
'app_version' => config('app.version'),
'comment_count' => $commentCount,
'group_count' => $groupCount,
'label_count' => $labelCount,
'memory_limit' => ini_get('memory_limit'),
'metadata_upgrade_needed' => $metadataUpgradeNeeded,
'photo_count' => $photoCount,
'php_version' => phpversion(),
'os_version' => exec('lsb_release -ds 2>/dev/null || cat /etc/*release 2>/dev/null | head -n1 || uname -om'),
'os_version' => $osVersion,
'server_name' => gethostname(),
'upload_file_size' => ini_get('upload_max_filesize'),
'upload_max_limit' => ini_get('post_max_size'),
@ -53,26 +173,104 @@ class DefaultController extends Controller
]);
}
public function quickUpload(Request $request)
{
$this->authorizeAccessToAdminPanel('admin:manage-albums');
$returnUrl = $request->headers->get('referer');
if (!MiscHelper::isSafeUrl($returnUrl))
{
$returnUrl = route('home');
}
// Pre-validate the upload before passing to the Photos controller
$files = $request->files->get('photo');
if (!is_array($files) || count($files) == 0)
{
$request->session()->flash('error', trans('admin.quick_upload.no_image_provided'));
return redirect($returnUrl);
}
$albumID = $request->get('album_id');
if (intval($albumID) == 0)
{
$albumName = trim($request->get('album_name'));
if (strlen($albumName) == 0)
{
$request->session()->flash('error', trans('admin.quick_upload.no_album_selected'));
return redirect($returnUrl);
}
$album = new Album();
$album->storage_id = Storage::where('is_default', true)->first()->id;
$album->user_id = Auth::user()->id;
$album->default_view = UserConfig::get('default_album_view');
$album->name = $albumName;
$album->description = '';
$album->is_permissions_inherited = true;
$album->save();
// Rebuild the permissions cache
$helper = new PermissionsHelper();
$helper->rebuildCache();
$request->request->set('album_id', $album->id);
}
/** @var PhotoController $photoController */
$photoController = app(PhotoController::class);
return $photoController->store($request);
}
public function rebuildPermissionsCache()
{
$helper = new PermissionsHelper();
$helper->rebuildCache();
return response()->json(true);
}
public function saveSettings(SaveSettingsRequest $request)
{
$this->authorizeAccessToAdminPanel('admin:configure');
$passwordKeys = [
'smtp_password'
];
$checkboxKeys = [
'albums_menu_parents_only',
'allow_photo_comments',
'allow_photo_comments_anonymous',
'allow_self_registration',
'enable_visitor_hits',
'hotlink_protection',
'moderate_anonymous_users',
'moderate_known_users',
'queue_emails',
'rabbitmq_enabled',
'recaptcha_enabled_registration',
'remove_copyright',
'require_email_verification',
'restrict_original_download',
'smtp_encryption',
'social_facebook_login',
'social_google_login',
'social_twitter_login',
'social_user_feeds',
'social_user_profiles'
];
$updateKeys = [
'albums_menu_number_items',
'analysis_queue_storage_location',
'app_name',
'date_format',
'facebook_external_service_id',
'google_external_service_id',
'photo_comments_allowed_html',
'photo_comments_thread_depth',
'rabbitmq_server',
'rabbitmq_port',
'rabbitmq_username',
'rabbitmq_password',
'rabbitmq_queue',
'rabbitmq_vhost',
'sender_address',
'sender_name',
'smtp_server',
@ -80,6 +278,7 @@ class DefaultController extends Controller
'smtp_username',
'smtp_password',
'theme',
'twitter_external_service_id',
'recaptcha_site_key',
'recaptcha_secret_key',
'analytics_code'
@ -94,17 +293,24 @@ class DefaultController extends Controller
// Bit of a hack when the browser returns an empty password field - meaning the user didn't change it
// - don't touch it!
if (
$key == 'smtp_password' &&
strlen($config->value) > 0 &&
strlen($request->request->get($key)) == 0 &&
strlen($request->request->get('smtp_username')) > 0
(
$key == 'smtp_password' &&
strlen($config->value) > 0 &&
strlen($request->request->get($key)) == 0 &&
strlen($request->request->get('smtp_username')) > 0
) || (
$key == 'rabbitmq_password' &&
strlen($config->value) > 0 &&
strlen($request->request->get($key)) == 0 &&
strlen($request->request->get('rabbitmq_username')) > 0
)
)
{
continue;
}
$config->value = $request->request->get($key);
if (in_array($key, $passwordKeys) && strlen($config->value) > 0)
if (in_array($key, $this->passwordSettingKeys) && strlen($config->value) > 0)
{
$config->value = encrypt($config->value);
}
@ -153,13 +359,43 @@ class DefaultController extends Controller
$dateFormatsLookup[$dateFormat] = date($dateFormat);
}
foreach ($this->passwordSettingKeys as $passwordSettingKey)
{
if (isset($config[$passwordSettingKey]) && !empty($config[$passwordSettingKey]))
{
$config[$passwordSettingKey] = decrypt($config[$passwordSettingKey]);
}
}
$themeNamesLookup = UserConfig::allowedThemeNames();
// 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

@ -34,7 +34,7 @@ class GroupController extends Controller
public function delete($id)
{
$this->authorizeAccessToAdminPanel();
$this->authorizeAccessToAdminPanel('admin:manage-groups');
$group = Group::where('id', intval($id))->first();
if (is_null($group))

View File

@ -0,0 +1,125 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Facade\Theme;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreLabelRequest;
use App\Label;
use App\Photo;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Request;
class LabelController extends Controller
{
public function __construct()
{
$this->middleware(['auth', 'max_post_size_exceeded']);
View::share('is_admin', true);
}
/**
* Applies a comma-separated string of label IDs and/or new label texts to a photo. This is called from the
* PhotoController - not directly via a route.
* @param Photo $photo Photo to apply the labels to
* @param string $labelString CSV string of label IDs and new labels to create (e.g. "1,2,Florida,nature" would
* link label IDs 1 and 2, and create 2 new labels called Florida and nature.)
*/
public function applyLabelsToPhoto(Photo $photo, $labelString)
{
foreach (explode(',', $labelString) as $labelText)
{
$labelID = intval($labelText);
if (intval($labelID) == 0)
{
// Check if the label already exists
$labelToUse = Label::where('name', $labelText)->first();
if (is_null($labelToUse))
{
// Create new label
$labelToUse = new Label();
$labelToUse->name = $labelText;
$labelToUse->save();
}
$labelID = $labelToUse->id;
}
$photo->labels()->attach(intval($labelID));
}
}
public function delete($id)
{
$this->authorizeAccessToAdminPanel();
$label = $this->loadLabel($id);
return Theme::render('admin.delete_label', ['label' => $label]);
}
/**
* Remove the specified resource from storage.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy(Request $request, $id)
{
$this->authorizeAccessToAdminPanel('admin:manage-labels');
$label = $this->loadLabel($id);
$label->delete();
$request->session()->flash('success', trans('admin.delete_label_success_message', ['name' => $label->name]));
return redirect(route('labels.index'));
}
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
$labels = Label::withCount('photos')->get();
return Theme::render('admin.list_labels', [
'labels' => $labels
]);
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(StoreLabelRequest $request)
{
$this->authorizeAccessToAdminPanel('admin:manage-labels');
$label = new Label();
$label->fill($request->only(['name']));
$label->save();
return redirect(route('labels.index'));
}
/**
* @param $id
* @return Album
*/
private function loadLabel($id)
{
$label = Label::where('id', intval($id))->first();
if (is_null($label))
{
App::abort(404);
return null;
}
return $label;
}
}

View File

@ -0,0 +1,375 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Album;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Http\Controllers\Controller;
use App\Notifications\PhotoCommentApproved;
use App\Notifications\PhotoCommentApprovedUser;
use App\Notifications\PhotoCommentRepliedTo;
use App\Photo;
use App\PhotoComment;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
class PhotoCommentController extends Controller
{
public function __construct()
{
$this->middleware('auth');
View::share('is_admin', true);
}
public function applyBulkAction(Request $request)
{
$this->authorizeAccessToAdminPanel('admin:manage-comments');
$commentIDs = $request->get('comment_ids');
if (is_null($commentIDs) || !is_array($commentIDs) || count($commentIDs) == 0)
{
$request->session()->flash('warning', trans('admin.no_comments_selected_message'));
return redirect(route('comments.index'));
}
$comments = PhotoComment::whereIn('id', $commentIDs)->get();
$commentsActioned = 0;
if ($request->has('bulk_delete'))
{
/** @var PhotoComment $comment */
foreach ($comments as $comment)
{
$comment->delete();
$commentsActioned++;
}
$request->session()->flash('success', trans_choice('admin.bulk_comments_deleted', $commentsActioned, ['number' => $commentsActioned]));
}
else if ($request->has('bulk_approve'))
{
/** @var PhotoComment $comment */
foreach ($comments as $comment)
{
if ($comment->isApproved())
{
// Don't make changes if already approved
continue;
}
// Mark as approved
$comment->approved_at = new \DateTime();
$comment->approved_user_id = $this->getUser()->id;
// The comment may have already been rejected - remove the data if so
$comment->rejected_at = null;
$comment->rejected_user_id = null;
// Send the notification e-mail to the owner
$comment->save();
$commentsActioned++;
// Send e-mail notification
$photo = $comment->photo;
$album = $photo->album;
$this->notifyAlbumOwnerAndPoster($album, $photo, $comment);
}
$request->session()->flash('success', trans_choice('admin.bulk_comments_approved', $commentsActioned, ['number' => $commentsActioned]));
}
else if ($request->has('bulk_reject'))
{
/** @var PhotoComment $comment */
foreach ($comments as $comment)
{
if ($comment->isRejected())
{
// Don't make changes if already rejected
continue;
}
// Mark as rejected
$comment->rejected_at = new \DateTime();
$comment->rejected_user_id = $this->getUser()->id;
// The comment may have already been approved - remove the data if so
$comment->approved_at = null;
$comment->approved_user_id = null;
$comment->save();
$commentsActioned++;
}
$request->session()->flash('success', trans_choice('admin.bulk_comments_approved', $commentsActioned, ['number' => $commentsActioned]));
}
return redirect(route('comments.index'));
}
public function approve($id)
{
$this->authorizeAccessToAdminPanel('admin:manage-comments');
$comment = $this->loadCommentByID($id);
return Theme::render('admin.approve_comment', ['comment' => $comment]);
}
public function bulkAction(Request $request)
{
$this->authorizeAccessToAdminPanel('admin:manage-comments');
$commentIDs = $request->get('comment_ids');
if (is_null($commentIDs) || !is_array($commentIDs) || count($commentIDs) == 0)
{
$request->session()->flash('warning', trans('admin.no_comments_selected_message'));
return redirect(route('comments.index'));
}
if ($request->has('bulk_delete'))
{
if (count($commentIDs) == 1)
{
// Single comment selected - redirect to the single delete page
return redirect(route('comments.delete', ['comment' => $commentIDs[0]]));
}
// Show the view to confirm the delete
return Theme::render('admin.bulk_delete_comments', [
'comment_count' => count($commentIDs),
'comment_ids' => $commentIDs
]);
}
else if ($request->has('bulk_approve'))
{
if (count($commentIDs) == 1)
{
// Single comment selected - redirect to the single approve page
return redirect(route('comments.approve', ['comment' => $commentIDs[0]]));
}
// Show the view to confirm the approval
return Theme::render('admin.bulk_approve_comments', [
'comment_count' => count($commentIDs),
'comment_ids' => $commentIDs
]);
}
else if ($request->has('bulk_reject'))
{
if (count($commentIDs) == 1)
{
// Single comment selected - redirect to the single reject page
return redirect(route('comments.reject', ['comment' => $commentIDs[0]]));
}
// Show the view to confirm the rejection
return Theme::render('admin.bulk_reject_comments', [
'comment_count' => count($commentIDs),
'comment_ids' => $commentIDs
]);
}
// Unrecognised action - simply redirect back to the index page
return redirect(route('comments.index'));
}
public function confirmApprove(Request $request, $id)
{
$this->authorizeAccessToAdminPanel('admin:manage-comments');
$comment = $this->loadCommentByID($id);
if ($comment->isApproved())
{
// Comment has already been approved
return redirect(route('comments.index'));
}
// Mark as approved
$comment->approved_at = new \DateTime();
$comment->approved_user_id = $this->getUser()->id;
// The comment may have already been rejected - remove the data if so
$comment->rejected_at = null;
$comment->rejected_user_id = null;
$comment->save();
$request->session()->flash('success', trans('admin.comment_approval_successful', [
'author_name' => $comment->authorDisplayName()
]));
// Send e-mail notification
$photo = $comment->photo;
$album = $photo->album;
$this->notifyAlbumOwnerAndPoster($album, $photo, $comment);
return redirect(route('comments.index'));
}
public function confirmReject(Request $request, $id)
{
$this->authorizeAccessToAdminPanel('admin:manage-comments');
$comment = $this->loadCommentByID($id);
if ($comment->isRejected())
{
// Comment has already been rejected
return redirect(route('comments.index'));
}
// Mark as rejected
$comment->rejected_at = new \DateTime();
$comment->rejected_user_id = $this->getUser()->id;
// The comment may have already been approved - remove the data if so
$comment->approved_at = null;
$comment->approved_user_id = null;
$comment->save();
$request->session()->flash('success', trans('admin.comment_rejection_successful', [
'author_name' => $comment->authorDisplayName()
]));
return redirect(route('comments.index'));
}
public function delete($id)
{
$this->authorizeAccessToAdminPanel('admin:manage-comments');
$comment = $this->loadCommentByID($id);
return Theme::render('admin.delete_comment', ['comment' => $comment]);
}
public function destroy(Request $request, $id)
{
$this->authorizeAccessToAdminPanel('admin:manage-comments');
/** @var PhotoComment $comment */
$comment = $this->loadCommentByID($id);
$comment->delete();
$request->session()->flash('success', trans('admin.comment_deletion_successful', [
'author_name' => $comment->authorDisplayName()
]));
return redirect(route('comments.index'));
}
public function index(Request $request)
{
$this->authorizeAccessToAdminPanel('admin:manage-comments');
$validStatusList = [
'all',
'pending',
'approved',
'rejected'
];
$filterStatus = $request->get('status', 'all');
if (!in_array($filterStatus, $validStatusList))
{
$filterStatus = $validStatusList[0];
}
$comments = PhotoComment::with('photo')
->with('photo.album')
->orderBy('created_at', 'desc');
switch (strtolower($filterStatus))
{
case 'approved':
$comments->whereNotNull('approved_at')
->whereNull('rejected_at');
break;
case 'pending':
$comments->whereNull('approved_at')
->whereNull('rejected_at');
break;
case 'rejected':
$comments->whereNull('approved_at')
->whereNotNull('rejected_at');
break;
}
return Theme::render('admin.list_comments', [
'comments' => $comments->paginate(UserConfig::get('items_per_page')),
'filter_status' => $filterStatus,
'success' => $request->session()->get('success'),
'warning' => $request->session()->get('warning')
]);
}
public function reject($id)
{
$this->authorizeAccessToAdminPanel('admin:manage-comments');
$comment = $this->loadCommentByID($id);
return Theme::render('admin.reject_comment', ['comment' => $comment]);
}
/**
* Loads a given comment by its ID.
* @param $id
* @return PhotoComment
*/
private function loadCommentByID($id)
{
$comment = PhotoComment::where('id', intval($id))->first();
if (is_null($comment))
{
App::abort(404);
}
return $comment;
}
/**
* Sends an e-mail notification to an album's owned that a comment has been posted/approved.
* @param Album $album
* @param Photo $photo
* @param PhotoComment $comment
*/
private function notifyAlbumOwnerAndPoster(Album $album, Photo $photo, PhotoComment $comment)
{
/** @var User $owner */
$owner = $album->user;
$owner->notify(new PhotoCommentApproved($album, $photo, $comment));
// Also send a notification to the comment poster
$poster = new User();
$poster->name = $comment->authorDisplayName();
$poster->email = $comment->authorEmail();
$poster->notify(new PhotoCommentApprovedUser($album, $photo, $comment));
// Send notification to the parent comment owner (if this is a reply)
if (!is_null($comment->parent_comment_id))
{
$parentComment = $this->loadCommentByID($comment->parent_comment_id);
if (is_null($parentComment))
{
return;
}
$parentPoster = new User();
$parentPoster->name = $parentComment->authorDisplayName();
$parentPoster->email = $parentComment->authorEmail();
$parentPoster->notify(new PhotoCommentRepliedTo($album, $photo, $comment));
}
}
}

View File

@ -3,25 +3,25 @@
namespace App\Http\Controllers\Admin;
use App\Album;
use App\AlbumSources\IAlbumSource;
use App\Facade\Image;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Helpers\AnalysisQueueHelper;
use App\Helpers\FileHelper;
use App\Helpers\ImageHelper;
use App\Helpers\MiscHelper;
use App\Http\Controllers\Controller;
use App\Http\Requests\UpdatePhotosBulkRequest;
use App\Photo;
use App\QueueItem;
use App\Services\PhotoService;
use App\Services\RabbitMQService;
use App\Upload;
use App\UploadPhoto;
use App\UserActivity;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\View;
use Symfony\Component\Finder\Iterator\RecursiveDirectoryIterator;
use Symfony\Component\HttpFoundation\File\File;
class PhotoController extends Controller
@ -48,17 +48,76 @@ class PhotoController extends Controller
try
{
$photoService = new PhotoService($photo);
$photoService->analyse($queue_token);
if (UserConfig::isImageProcessingQueueEnabled())
{
// Find the last record that is analysing this photo
$photoQueueItem = QueueItem::where('photo_id', $photo->id)
->orderBy('queued_at', 'desc')
->limit(1)
->first();
$result['is_successful'] = true;
$timeToWait = 60;
$timeWaited = 0;
$continueToMonitor = true;
while ($continueToMonitor && $timeWaited < $timeToWait)
{
$continueToMonitor = is_null($photoQueueItem->completed_at);
if ($continueToMonitor)
{
sleep(1);
$timeWaited++;
$photoQueueItem = QueueItem::where('id', $photoQueueItem->id)->first();
$continueToMonitor = is_null($photoQueueItem->completed_at);
}
}
$didComplete = !is_null($photoQueueItem->completed_at);
if (!$didComplete)
{
$result['message'] = 'Timed out waiting for queue processing.';
}
else if (!$photoQueueItem->is_successful)
{
$result['is_successful'] = false;
$result['message'] = $photoQueueItem->error_message;
// Remove the photo from the album if it was newly-uploaded and couldn't be processed
if (intval($photo->metadata_version) === 0)
{
$photo->delete();
}
}
else
{
$result['is_successful'] = true;
}
}
else
{
/* IF CHANGING THIS LOGIC, ALSO CHECK ProcessQueueCommand::processPhotoAnalyseMessage */
$photoService = new PhotoService($photo);
$photoService->analyse($queue_token);
// Log an activity record for the user's feed (remove an existing one as the date may have changed)
$this->removeExistingActivityRecords($photo, 'photo.taken');
if (!is_null($photo->taken_at))
{
// Log an activity record for the user's feed
$this->createActivityRecord($photo, 'photo.taken', $photo->taken_at);
}
$result['is_successful'] = true;
}
}
catch (\Exception $ex)
{
$result['is_successful'] = false;
$result['message'] = $ex->getMessage();
// Remove the photo if it cannot be analysed
// Remove the photo if it cannot be analysed (only if there isn't currently a version of metadata)
$photo->delete();
}
@ -103,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();
@ -114,6 +173,11 @@ class PhotoController extends Controller
$photoService = new PhotoService($photo);
$photoService->flip($horizontal, $vertical);
// 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)
@ -143,13 +207,49 @@ class PhotoController extends Controller
}
}
public function regenerateThumbnails($photoId)
public function reAnalyse($id, $queue_token)
{
$this->authorizeAccessToAdminPanel();
/** @var Photo $photo */
$photo = $this->loadPhoto($id);
$result = ['is_successful' => false, 'message' => ''];
try
{
$photoService = new PhotoService($photo);
$photoService->downloadOriginalToFolder(FileHelper::getQueuePath($queue_token));
$photoService->analyse($queue_token);
// Log an activity record for the user's feed (remove an existing one as the date may have changed)
$this->removeExistingActivityRecords($photo, 'photo.taken');
if (!is_null($photo->taken_at))
{
// Log an activity record for the user's feed
$this->createActivityRecord($photo, 'photo.taken', $photo->taken_at);
}
$result['is_successful'] = true;
}
catch (\Exception $ex)
{
$result['is_successful'] = false;
$result['message'] = $ex->getMessage();
// Unlike the analyse method, we don't remove the photo if it cannot be analysed
}
return response()->json($result);
}
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
{
@ -157,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)
{
@ -167,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();
@ -175,12 +276,17 @@ class PhotoController extends Controller
if ($angle != 90 && $angle != 180 && $angle != 270)
{
App::aport(400);
App::abort(400);
return null;
}
$photoService = new PhotoService($photo);
$photoService->rotate($angle);
// Log an activity record for the user's feed
$this->createActivityRecord($photo, 'photo.edited');
return $photo->thumbnailUrl($request->get('t', 'admin-preview'));
}
/**
@ -206,7 +312,7 @@ class PhotoController extends Controller
throw new \Exception('No queue_token value was provided!');
}
$queueFolder = FileHelper::getQueuePath($queueUid);
$queueStorage = AnalysisQueueHelper::getStorageQueueSource();
foreach ($photoFiles as $photoFile)
{
@ -217,21 +323,70 @@ class PhotoController extends Controller
}
else
{
/** @var File $savedFile */
$savedFile = FileHelper::saveUploadedFile($photoFile, $queueFolder);
try
{
if ($request->has('photo_id'))
{
// Photo ID provided (using the Replace Photo function) - use that record
$photo = Photo::where('id', intval($request->get('photo_id')))->first();
$photo->raw_exif_data = null;
$photo = new Photo();
$photo->album_id = $album->id;
$photo->user_id = Auth::user()->id;
$photo->name = pathinfo($photoFile->getClientOriginalName(), PATHINFO_FILENAME);
$photo->file_name = $photoFile->getClientOriginalName();
$photo->storage_file_name = $savedFile->getFilename();
$photo->mime_type = $savedFile->getMimeType();
$photo->file_size = $savedFile->getSize();
$photo->is_analysed = false;
$photo->save();
$queuedFileName = $queueStorage->uploadToAnalysisQueue($photoFile, $queueUid, $photo->storage_file_name);
$uploadedTempFile = new File($photoFile);
$isSuccessful = true;
$this->removeExistingActivityRecords($photo, 'photo.uploaded');
$this->removeExistingActivityRecords($photo, 'photo.taken');
$photo->file_name = $photoFile->getClientOriginalName();
$photo->mime_type = $uploadedTempFile->getMimeType();
$photo->file_size = $uploadedTempFile->getSize();
$photo->storage_file_name = basename($queuedFileName);
}
else
{
$queuedFileName = $queueStorage->uploadToAnalysisQueue($photoFile, $queueUid);
$uploadedTempFile = new File($photoFile);
$photo = new Photo();
$photo->album_id = $album->id;
$photo->user_id = Auth::user()->id;
$photo->name = pathinfo($photoFile->getClientOriginalName(), PATHINFO_FILENAME);
$photo->file_name = $photoFile->getClientOriginalName();
$photo->mime_type = $uploadedTempFile->getMimeType();
$photo->file_size = $uploadedTempFile->getSize();
$photo->storage_file_name = basename($queuedFileName);
}
$photo->is_analysed = false;
$photo->save();
// Log an activity record for the user's feed
$this->createActivityRecord($photo, 'photo.uploaded');
// If queueing is enabled, store the photo in the queue now
if (UserConfig::isImageProcessingQueueEnabled())
{
$queueItem = new QueueItem([
'batch_reference' => $queueUid,
'action_type' => 'photo.analyse',
'album_id' => $photo->album_id,
'photo_id' => $photo->id,
'user_id' => $this->getUser()->id,
'queued_at' => new \DateTime()
]);
$queueItem->save();
$rabbitmq = new RabbitMQService();
$rabbitmq->queueItem($queueItem);
}
$isSuccessful = true;
}
finally
{
@unlink($photoFile->getRealPath());
}
}
}
@ -242,7 +397,7 @@ class PhotoController extends Controller
else
{
return redirect(route('albums.analyse', [
'id' => $album->id,
'album' => $album->id,
'queue_token' => $queueUid
]));
}
@ -255,12 +410,18 @@ class PhotoController extends Controller
// Load the linked album
$album = $this->loadAlbum($request->get('album_id'));
if (is_null($request->files->get('archive')))
{
$request->session()->flash('error', trans('admin.upload_bulk_no_file'));
return redirect(route('albums.show', ['album' => $album->id]));
}
$archiveFile = UploadedFile::createFromBase($request->files->get('archive'));
if ($archiveFile->getError() != UPLOAD_ERR_OK)
{
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
@ -270,74 +431,104 @@ class PhotoController extends Controller
throw new \Exception('No queue_token value was provided!');
}
$queueFolder = FileHelper::getQueuePath($queueUid);
$temporaryFolder = sprintf('%s/%s', sys_get_temp_dir(), MiscHelper::randomString());
@mkdir($temporaryFolder);
$mimeType = strtolower($archiveFile->getMimeType());
switch ($mimeType)
try
{
case 'application/zip':
$zip = new \ZipArchive();
$zip->open($archiveFile->getPathname());
$zip->extractTo($queueFolder);
$zip->close();
@unlink($archiveFile->getPathname());
break;
$queueStorage = AnalysisQueueHelper::getStorageQueueSource();
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]));
}
$di = new \RecursiveDirectoryIterator($queueFolder, \RecursiveDirectoryIterator::SKIP_DOTS);
$recursive = new \RecursiveIteratorIterator($di);
/** @var \SplFileInfo $fileInfo */
foreach ($recursive as $fileInfo)
{
if ($fileInfo->isDir())
$mimeType = strtolower($archiveFile->getMimeType());
switch ($mimeType)
{
if ($fileInfo->getFilename() == '__MACOSX' || substr($fileInfo->getFilename(), 0, 1) == '.')
case 'application/zip':
$zip = new \ZipArchive();
$zip->open($archiveFile->getPathname());
$zip->extractTo($temporaryFolder);
$zip->close();
@unlink($archiveFile->getPathname());
break;
default:
$request->session()->flash('error', sprintf('The file type "%s" is not supported for bulk uploads.', $mimeType));
return redirect(route('albums.show', ['album' => $album->id]));
}
$di = new \RecursiveDirectoryIterator($temporaryFolder, \RecursiveDirectoryIterator::SKIP_DOTS);
$recursive = new \RecursiveIteratorIterator($di);
/** @var \SplFileInfo $fileInfo */
foreach ($recursive as $fileInfo)
{
if ($fileInfo->isDir())
{
@rmdir($fileInfo->getPathname());
if ($fileInfo->getFilename() == '__MACOSX' || substr($fileInfo->getFilename(), 0, 1) == '.')
{
@rmdir($fileInfo->getRealPath());
}
continue;
}
continue;
if (substr($fileInfo->getFilename(), 0, 1) == '.')
{
// Temporary/hidden file - skip
@unlink($fileInfo->getRealPath());
continue;
}
$result = getimagesize($fileInfo->getRealPath());
if ($result === false)
{
// Not an image file - skip
@unlink($fileInfo->getRealPath());
continue;
}
$photoFile = new File($fileInfo->getRealPath());
$queuedFileName = $queueStorage->uploadToAnalysisQueue($photoFile, $queueUid);
$photo = new Photo();
$photo->album_id = $album->id;
$photo->user_id = Auth::user()->id;
$photo->name = pathinfo($photoFile->getFilename(), PATHINFO_FILENAME);
$photo->file_name = $photoFile->getFilename();
$photo->mime_type = $photoFile->getMimeType();
$photo->file_size = $photoFile->getSize();
$photo->is_analysed = false;
$photo->storage_file_name = basename($queuedFileName);
$photo->save();
// Log an activity record for the user's feed
$this->createActivityRecord($photo, 'photo.uploaded');
// If queueing is enabled, store the photo in the queue now
if (UserConfig::isImageProcessingQueueEnabled())
{
$queueItem = new QueueItem([
'batch_reference' => $queueUid,
'action_type' => 'photo.analyse',
'album_id' => $photo->album_id,
'photo_id' => $photo->id,
'user_id' => $this->getUser()->id,
'queued_at' => new \DateTime()
]);
$queueItem->save();
$rabbitmq = new RabbitMQService();
$rabbitmq->queueItem($queueItem);
}
@unlink($fileInfo->getRealPath());
}
if (substr($fileInfo->getFilename(), 0, 1) == '.')
{
// Temporary/hidden file - skip
@unlink($fileInfo->getPathname());
continue;
}
$result = getimagesize($fileInfo->getPathname());
if ($result === false)
{
// Not an image file - skip
@unlink($fileInfo->getPathname());
continue;
}
$photoFile = new File($fileInfo->getPathname());
/** @var File $savedFile */
$savedFile = FileHelper::saveExtractedFile($photoFile, $queueFolder);
$photo = new Photo();
$photo->album_id = $album->id;
$photo->user_id = Auth::user()->id;
$photo->name = pathinfo($photoFile->getFilename(), PATHINFO_FILENAME);
$photo->file_name = $photoFile->getFilename();
$photo->storage_file_name = $savedFile->getFilename();
$photo->mime_type = $savedFile->getMimeType();
$photo->file_size = $savedFile->getSize();
$photo->is_analysed = false;
$photo->save();
}
finally
{
@rmdir($temporaryFolder);
}
return redirect(route('albums.analyse', [
'id' => $album->id,
'album' => $album->id,
'queue_token' => $queueUid
]));
}
@ -391,9 +582,16 @@ class PhotoController extends Controller
$numberChanged = $this->updatePhotoDetails($request, $album);
}
$request->session()->flash('success', trans_choice('admin.bulk_photos_changed', $numberChanged, ['number' => $numberChanged]));
$request->session()->flash(
'success',
trans_choice(
UserConfig::isImageProcessingQueueEnabled() ? 'admin.bulk_photos_changed_queued' : 'admin.bulk_photos_changed',
$numberChanged,
['number' => $numberChanged]
)
);
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)
@ -433,101 +631,161 @@ class PhotoController extends Controller
$action = $request->get('bulk-action');
$numberChanged = 0;
foreach ($photosToProcess as $photo)
if (UserConfig::isImageProcessingQueueEnabled())
{
$changed = false;
$photoService = new PhotoService($photo);
$doNotSave = false;
switch (strtolower($action))
$queueUid = MiscHelper::randomString();
foreach ($photosToProcess as $photo)
{
case 'change_album':
if (Auth::user()->can('change-metadata', $photo))
$queueItem = new QueueItem([
'batch_reference' => $queueUid,
'action_type' => sprintf('photo.bulk_action.%s', strtolower($action)),
'album_id' => $photo->album_id,
'photo_id' => $photo->id,
'user_id' => $this->getUser()->id,
'queued_at' => new \DateTime()
]);
if (strtolower($action) == 'change_album')
{
$queueItem->new_album_id = intval($request->get('new-album-id'));
$newAlbumId = intval($request->get('new-album-id'));
if ($newAlbumId == $photo->album_id)
{
$newAlbumId = intval($request->get('new-album-id'));
if ($newAlbumId == $photo->album_id)
{
// Photo already belongs to this album, don't move
continue;
}
$newAlbum = $this->loadAlbum($newAlbumId, 'upload-photos');
$photoService->changeAlbum($newAlbum);
$changed = true;
// Photo already belongs to this album, don't move
continue;
}
break;
}
case 'delete':
if (Auth::user()->can('delete', $photo))
{
$photoService->delete();
$doNotSave = true;
$changed = true;
}
break;
$queueItem->save();
case 'flip_both':
if (Auth::user()->can('manipulate', $photo))
{
$photoService->flip(true, true);
$changed = true;
}
break;
$rabbitmq = new RabbitMQService();
$rabbitmq->queueItem($queueItem);
case 'flip_horizontal':
if (Auth::user()->can('manipulate', $photo))
{
$photoService->flip(true, false);
$changed = true;
}
break;
case 'flip_vertical':
if (Auth::user()->can('manipulate', $photo))
{
$photoService->flip(false, true);
$changed = true;
}
break;
case 'refresh_thumbnails':
if (Auth::user()->can('change-metadata', $photo))
{
$photoService->regenerateThumbnails();
$changed = true;
}
break;
case 'rotate_left':
if (Auth::user()->can('manipulate', $photo))
{
$photoService->rotate(90);
$changed = true;
}
break;
case 'rotate_right':
if (Auth::user()->can('manipulate', $photo))
{
$photoService->rotate(270);
$changed = true;
}
break;
}
if (!$doNotSave)
{
$photo->save();
}
if ($changed)
{
$numberChanged++;
}
}
else
{
foreach ($photosToProcess as $photo)
{
$changed = false;
$photoService = new PhotoService($photo);
$doNotSave = false;
/* IF CHANGING THIS LOGIC OR ADDING EXTRA case OPTIONS, ALSO CHECK ProcessQueueCommand::processQueueItem AND ProcessQueueCommand::processPhotoBulkActionMessage */
switch (strtolower($action))
{
case 'change_album':
if (Auth::user()->can('change-metadata', $photo))
{
$newAlbumId = intval($request->get('new-album-id'));
if ($newAlbumId == $photo->album_id)
{
// Photo already belongs to this album, don't move
continue 2;
}
$newAlbum = $this->loadAlbum($newAlbumId, 'upload-photos');
$photoService->changeAlbum($newAlbum);
$changed = true;
}
break;
case 'delete':
if (Auth::user()->can('delete', $photo))
{
$photoService->delete();
$doNotSave = true;
$changed = true;
}
break;
case 'flip_both':
if (Auth::user()->can('manipulate', $photo))
{
$photoService->flip(true, true);
$changed = true;
}
break;
case 'flip_horizontal':
if (Auth::user()->can('manipulate', $photo))
{
$photoService->flip(true, false);
$changed = true;
}
break;
case 'flip_vertical':
if (Auth::user()->can('manipulate', $photo))
{
$photoService->flip(false, true);
$changed = true;
}
break;
case 'refresh_thumbnails':
if (Auth::user()->can('change-metadata', $photo))
{
$photoService->regenerateThumbnails();
$changed = true;
}
break;
case 'rotate_left':
if (Auth::user()->can('manipulate', $photo))
{
$photoService->rotate(90);
$changed = true;
}
break;
case 'rotate_right':
if (Auth::user()->can('manipulate', $photo))
{
$photoService->rotate(270);
$changed = true;
}
break;
}
if (!$doNotSave)
{
$photo->save();
}
if (!in_array(strtolower($action), ['delete', 'refresh_thumbnails', 'change_album']))
{
// Log an activity record for the user's feed
$this->createActivityRecord($photo, 'photo.edited');
}
if ($changed)
{
$numberChanged++;
}
}
}
return $numberChanged;
}
private function createActivityRecord(Photo $photo, $type, $activityDateTime = null)
{
if (is_null($activityDateTime))
{
$activityDateTime = new \DateTime();
}
$userActivity = new UserActivity();
$userActivity->user_id = $this->getUser()->id;
$userActivity->activity_at = $activityDateTime;
$userActivity->type = $type;
$userActivity->photo_id = $photo->id;
$userActivity->save();
}
/**
* @param $id
* @return Album
@ -568,6 +826,20 @@ class PhotoController extends Controller
return $photo;
}
private function removeExistingActivityRecords(Photo $photo, $type)
{
$existingFeedRecords = UserActivity::where([
'user_id' => $this->getUser()->id,
'photo_id' => $photo->id,
'type' => $type
])->get();
foreach ($existingFeedRecords as $existingFeedRecord)
{
$existingFeedRecord->delete();
}
}
private function updatePhotoDetails(Request $request, Album $album)
{
$numberChanged = 0;
@ -583,6 +855,17 @@ class PhotoController extends Controller
}
$photo->fill($value);
// Update the photo labels
$labelString = trim($value['labels']);
$photo->labels()->detach();
if (strlen($labelString) > 0)
{
app(LabelController::class)->applyLabelsToPhoto($photo, $labelString);
}
// Save all changes
$photo->save();
$numberChanged++;
}

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

@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Configuration;
use App\Facade\UserConfig;
use App\Http\Controllers\Controller;
use Symfony\Component\HttpFoundation\Request;
class StatisticsController extends Controller
{
public function save(Request $request)
{
$isPublicStatsEnabled = strtolower($request->get('enable_public_statistics')) == 'on';
/** @var Configuration $config */
$config = UserConfig::getOrCreateModel('public_statistics');
$config->value = $isPublicStatsEnabled;
$config->save();
$request->session()->flash('success', trans('admin.statistics_prefs_saved_message'));
return redirect(route('statistics.index'));
}
}

View File

@ -2,13 +2,14 @@
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 App\Http\Requests;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\View;
@ -24,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'));
}
}
/**
@ -56,11 +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')
'info' => $request->session()->get('info'),
'storage' => $storage
]);
}
@ -88,11 +125,14 @@ class StorageController extends Controller
'container_name',
'cdn_url',
'access_key',
'secret_key'
'secret_key',
'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))
{
@ -114,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'));
}
@ -186,12 +237,10 @@ class StorageController extends Controller
}
}
if (!$request->session()->has('_old_input'))
{
$request->session()->flash('_old_input', $storage->toArray());
}
return Theme::render('admin.edit_storage', ['storage' => $storage]);
return Theme::render('admin.edit_storage', [
'dropbox_services' => ExternalService::getForService(ExternalService::DROPBOX),
'storage' => $storage
]);
}
/**
@ -222,10 +271,13 @@ class StorageController extends Controller
'container_name',
'cdn_url',
'access_key',
'secret_key'
'secret_key',
'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)
{
@ -246,6 +298,10 @@ class StorageController extends Controller
{
$this->unsetIsDefaultFromOthers($storage);
}
else
{
$this->setIsDefaultForFirstStorage();
}
return redirect(route('storage.index'));
}
@ -285,6 +341,32 @@ 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();
if ($count == 0)
{
$storage = Storage::where('is_active', true)->first();
if (!is_null($storage))
{
$storage->is_default = true;
$storage->save();
}
}
}
private function unsetIsDefaultFromOthers(Storage $storage)
{
// If this storage is flagged as default, remove all others

View File

@ -5,10 +5,10 @@ namespace App\Http\Controllers\Admin;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Group;
use App\User;
use App\Http\Requests;
use App\Helpers\PermissionsHelper;
use App\Http\Controllers\Controller;
use App\Http\Requests;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
@ -88,6 +88,7 @@ class UserController extends Controller
$user->password = bcrypt($user->password);
$user->is_activated = true;
$user->is_admin = (strtolower($request->get('is_admin')) == 'on');
$user->enable_profile_page = UserConfig::get('social_user_profiles');
$user->save();
return redirect(route('users.index'));
@ -200,6 +201,10 @@ class UserController extends Controller
$user->save();
// Rebuild the permissions cache
$helper = new PermissionsHelper();
$helper->rebuildCache();
return redirect(route('users.index'));
}

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Traits\ActivatesUsers;
use App\User;
use Illuminate\Foundation\Auth\RedirectsUsers;
use Illuminate\Http\Request;
@ -10,7 +11,7 @@ use Illuminate\Support\Facades\App;
class ActivateController extends Controller
{
use RedirectsUsers;
use RedirectsUsers, ActivatesUsers;
/**
* Where to redirect users after activation.
@ -46,6 +47,9 @@ class ActivateController extends Controller
$request->session()->flash('info', trans('auth.account_activated_message'));
$this->logActivatedActivity($user);
$this->sendUserActivatedEmails($user);
return redirect($this->redirectPath());
}
}

View File

@ -2,10 +2,20 @@
namespace App\Http\Controllers\Auth;
use App\ExternalService;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Helpers\MiscHelper;
use App\Http\Controllers\Controller;
use App\User;
use Illuminate\Contracts\Routing\UrlGenerator;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
use Laravel\Socialite\One\TwitterProvider;
use Laravel\Socialite\Two\FacebookProvider;
use Laravel\Socialite\Two\GoogleProvider;
use League\OAuth1\Client\Server\Twitter as TwitterServer;
use Socialite;
class LoginController extends Controller
{
@ -22,21 +32,61 @@ class LoginController extends Controller
use AuthenticatesUsers;
/**
* @var UrlGenerator
*/
protected $generator;
/**
* Where to redirect users after login / registration.
*
* @var string
*/
protected $redirectTo = '/';
protected $redirectTo = '/me';
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
public function __construct(UrlGenerator $generator)
{
$this->middleware('guest', ['except' => 'logout']);
$this->generator = $generator;
}
public function logout(Request $request)
{
$this->guard()->logout();
$request->session()->invalidate();
return redirect()->back();
}
protected function attemptLogin(Request $request)
{
$isSuccessful = $this->guard()->attempt($this->credentials($request));
if ($isSuccessful)
{
/** @var User $user */
$user = $this->guard()->user();
// Update the social media ID if successful login and it was referred by the SSO provider
$loginData = $request->getSession()->get('ssoLoginData');
if (!is_null($loginData))
{
unset($loginData['name']);
unset($loginData['email']);
$user->fill($loginData);
$user->save();
$request->getSession()->remove('ssoLoginData');
}
}
return $isSuccessful;
}
protected function credentials(Request $request)
@ -56,9 +106,285 @@ class LoginController extends Controller
*/
public function showLoginForm(Request $request)
{
$previousUrl = MiscHelper::ensureHasTrailingSlash($this->generator->previous(false));
$homeUrl = MiscHelper::ensureHasTrailingSlash(route('home'));
if (UserConfig::get('social_user_feeds') && (empty($previousUrl) || $previousUrl == $homeUrl))
{
$previousUrl = route('userActivityFeed');
}
$request->getSession()->put('url.intended', $previousUrl);
return Theme::render('auth.v2_unified', [
'active_tab' => 'login',
'info' => $request->session()->get('info')
'info' => $request->session()->get('info'),
'is_sso' => false
]);
}
/**
* Show the application's login form (for a social media-linked account).
*
* @return \Illuminate\Http\Response
*/
public function showLoginFormSso(Request $request)
{
// Social media login info
$loginData = $request->getSession()->get('ssoLoginData');
if (is_null($loginData))
{
// No SSO data in session, use the normal login screen
return redirect(route('login'));
}
return Theme::render('auth.v2_unified', [
'active_tab' => 'login',
'info' => $request->session()->get('info'),
'is_sso' => true,
'login_data' => $loginData
]);
}
/**
* Redirect the user to the Facebook authentication page.
*
* @return \Illuminate\Http\Response
*/
public function redirectToFacebook()
{
$socialite = $this->setSocialiteConfigForFacebook();
if (is_null($socialite))
{
return redirect(route('login'));
}
return $socialite->driver('facebook')->redirect();
}
/**
* Redirect the user to the Google authentication page.
*
* @return \Illuminate\Http\Response
*/
public function redirectToGoogle()
{
$socialite = $this->setSocialiteConfigForGoogle();
if (is_null($socialite))
{
return redirect(route('login'));
}
return $socialite->driver('google')->redirect();
}
/**
* Redirect the user to the Twitter authentication page.
*
* @return \Illuminate\Http\Response
*/
public function redirectToTwitter()
{
$socialite = $this->setSocialiteConfigForTwitter();
if (is_null($socialite))
{
return redirect(route('login'));
}
return $socialite->driver('twitter')->redirect();
}
/**
* Obtain the user information from Facebook.
*
* @return \Illuminate\Http\Response
*/
public function handleFacebookCallback(Request $request)
{
$socialite = $this->setSocialiteConfigForFacebook();
if (is_null($socialite))
{
return redirect(route('login'));
}
$facebookUser = $socialite->driver('facebook')->user();
return $this->processSocialMediaLogin($request, 'facebook_id', $facebookUser);
}
/**
* Obtain the user information from Google.
*
* @return \Illuminate\Http\Response
*/
public function handleGoogleCallback(Request $request)
{
$socialite = $this->setSocialiteConfigForGoogle();
if (is_null($socialite))
{
return redirect(route('login'));
}
$googleUser = $socialite->driver('google')->user();
return $this->processSocialMediaLogin($request, 'google_id', $googleUser);
}
/**
* Obtain the user information from Twitter.
*
* @return \Illuminate\Http\Response
*/
public function handleTwitterCallback(Request $request)
{
$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();
if (!is_null($userBySocialMediaId))
{
// We have an existing user for this Facebook ID - log them in
$this->guard()->login($userBySocialMediaId);
return redirect(route('home'));
}
// Some providers (*cough*Twitter*cough*) don't give e-mail addresses without explicit permission/additional
// verification
if (!is_null($socialMediaUser->email))
{
$userByEmailAddress = User::where('email', $socialMediaUser->getEmail())->first();
if (!is_null($userByEmailAddress))
{
// We have an existing user with the e-mail address associated with the Facebook account
// Prompt for the password for that account
$request->getSession()->put('ssoLoginData', [
'name' => $socialMediaUser->getName(),
'email' => $socialMediaUser->getEmail(),
$socialMediaIdField => $socialMediaUser->getId(),
'is_activated' => true
]);
return redirect(route('auth.login_sso'));
}
}
// We don't have an existing user - prompt for registration
$request->getSession()->put('ssoRegisterData', [
'name' => $socialMediaUser->getName(),
'email' => $socialMediaUser->getEmail(),
$socialMediaIdField => $socialMediaUser->getId(),
'is_activated' => true
]);
return redirect(route('auth.register_sso'));
}
private function setSocialiteConfigForFacebook()
{
$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, $facebookConfig) {
$config = [
'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, $googleConfig) {
$config = [
'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, $twitterConfig) {
$config = [
'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));
}
);
return $socialite;
}
}

View File

@ -6,12 +6,12 @@ use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Helpers\MiscHelper;
use App\Helpers\RecaptchaHelper;
use App\Mail\UserActivationRequired;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use App\Http\Controllers\Controller;
use App\Notifications\UserActivationRequired;
use App\Traits\ActivatesUsers;
use App\User;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class RegisterController extends Controller
@ -27,7 +27,7 @@ class RegisterController extends Controller
|
*/
use RegistersUsers;
use RegistersUsers, ActivatesUsers;
/**
* Where to redirect users after login / registration.
@ -85,25 +85,23 @@ class RegisterController extends Controller
*/
protected function create(array $data)
{
$activationData = [
'is_activated' => true
];
if (UserConfig::get('require_email_verification'))
if (!isset($data['is_activated']))
{
$activationData['is_activated'] = false;
$activationData['activation_token'] = MiscHelper::randomString();
$data['is_activated'] = true;
if (UserConfig::get('require_email_verification'))
{
$data['is_activated'] = false;
$data['activation_token'] = MiscHelper::randomString();
}
}
return User::create(array_merge(
[
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
'is_admin' => false
],
$activationData
));
$data['password'] = bcrypt($data['password']);
$data['is_admin'] = false;
$data['enable_profile_page'] = UserConfig::get('social_user_profiles');
unset($data['password_confirmation']);
return User::create($data);
}
public function register(Request $request)
@ -115,17 +113,29 @@ class RegisterController extends Controller
$this->validator($request)->validate();
$userData = $request->all();
// Social media login info
$registerData = $request->getSession()->get('ssoRegisterData');
if (!is_null($registerData))
{
$userData = array_merge($registerData, $userData);
$request->getSession()->remove('ssoRegisterData');
}
/** @var User $user */
$user = $this->create($request->all());
$user = $this->create($userData);
if ($user->is_activated)
{
$this->logActivatedActivity($user);
$this->sendUserActivatedEmails($user);
$this->guard()->login($user);
}
else
{
// Send activation e-mail
Mail::to($user)->send(new UserActivationRequired($user));
$user->notify(new UserActivationRequired());
$request->session()->flash('info', trans('auth.activation_required_message'));
}
@ -137,7 +147,7 @@ class RegisterController extends Controller
*
* @return \Illuminate\Http\Response
*/
public function showRegistrationForm()
public function showRegistrationForm(Request $request)
{
if (!UserConfig::get('allow_self_registration'))
{
@ -145,7 +155,35 @@ class RegisterController extends Controller
}
return Theme::render('auth.v2_unified', [
'active_tab' => 'register'
'active_tab' => 'register',
'is_sso' => false
]);
}
/**
* Show the application registration form (for a social media-linked account).
*
* @return \Illuminate\Http\Response
*/
public function showRegistrationFormSso(Request $request)
{
if (!UserConfig::get('allow_self_registration'))
{
return redirect(route('home'));
}
// Social media login info
$registerData = $request->getSession()->get('ssoRegisterData');
if (is_null($registerData))
{
// No SSO data in session, use the normal registration screen
return redirect(route('register'));
}
return Theme::render('auth.v2_unified', [
'active_tab' => 'register',
'is_sso' => true,
'register_data' => $registerData
]);
}
}

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);
@ -43,7 +40,7 @@ class AlbumController extends Controller
$requestedView = strtolower($request->get('view'));
if (!in_array($requestedView, $validViews))
{
$requestedView = $album->default_view;
$requestedView = strtolower($album->default_view);
if (!in_array($requestedView, $validViews))
{
@ -71,20 +68,28 @@ class AlbumController extends Controller
else if ($requestedView != 'slideshow')
{
$photos = $album->photos()
->orderBy(DB::raw('COALESCE(taken_at, created_at)'))
->orderBy(DB::raw('COALESCE(taken_at, created_at), name, id'))
->paginate(UserConfig::get('items_per_page'));
}
else
{
// The slideshow view needs access to all photos, not paged
$photos = $album->photos()
->orderBy(DB::raw('COALESCE(taken_at, created_at)'))
->orderBy(DB::raw('COALESCE(taken_at, created_at), name, id'))
->get();
}
// Load child albums and their available children
$childAlbums = DbHelper::getChildAlbums($album);
foreach ($childAlbums as $childAlbum)
{
$childAlbum->children_count = DbHelper::getChildAlbumsCount($childAlbum);
}
return Theme::render(sprintf('gallery.album_%s', $requestedView), [
'album' => $album,
'allowed_views' => $validViews,
'child_albums' => $childAlbums,
'current_view' => $requestedView,
'photos' => $photos
]);

View File

@ -7,6 +7,7 @@ use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Helpers\DbHelper;
use App\Http\Controllers\Controller;
use App\Label;
use App\Photo;
use App\VisitorHit;
use Illuminate\Http\Request;
@ -18,6 +19,13 @@ class DefaultController extends Controller
public function index(Request $request)
{
$albums = DbHelper::getAlbumsForCurrentUser(0);
/** @var Album $album */
foreach ($albums as $album)
{
$album->children_count = DbHelper::getChildAlbumsCount($album);
}
$resetStatus = $request->session()->get('status');
// Record the visit to the index (no album or photo to record a hit against though)
@ -51,20 +59,35 @@ class DefaultController extends Controller
$lastModifiedPhoto = Photo::orderBy('updated_at', 'desc')->first();
$this->createSitemapNode($xml, $root, route('home'), (is_null($lastModifiedPhoto) ? '' : $lastModifiedPhoto->updated_at), '1.0');
// Albums the current user is allowed to access
$albumIDs = DbHelper::getAlbumIDsForCurrentUser();
// Add each label
$labels = Label::orderBy('name');
$labels->chunk(100, function($labelsChunk) use ($xml, $root)
{
/** @var Label $label */
foreach ($labelsChunk as $label)
{
$lastModifiedPhoto = $label->photos()->orderBy('updated_at', 'desc')->first();
$this->createSitemapNode($xml, $root, $label->url(), (is_null($lastModifiedPhoto) ? $label->updated_at : $lastModifiedPhoto->updated_at), '0.9');
}
});
// Add each album URL
$albums = Album::orderBy('name');
$albums = Album::whereIn('id', $albumIDs)->orderBy('name');
$albums->chunk(100, function($albumsChunk) use ($xml, $root)
{
/** @var Album $album */
foreach ($albumsChunk as $album)
{
$lastModifiedPhoto = Photo::where('album_id', $album->id)->orderBy('updated_at', 'desc')->first();
$this->createSitemapNode($xml, $root, $album->url(), (is_null($lastModifiedPhoto) ? $album->updated_at : $lastModifiedPhoto->updated_at), '0.9');
$this->createSitemapNode($xml, $root, $album->url(), (is_null($lastModifiedPhoto) ? $album->updated_at : $lastModifiedPhoto->updated_at), '0.8');
}
});
// Add each photo URL
$photos = Photo::orderBy('name');
$photos = Photo::whereIn('album_id', $albumIDs)->orderBy('name');
$photos->chunk(100, function($tempPhotos) use ($xml, $root)
{
/** @var Photo $photo */
@ -81,7 +104,7 @@ class DefaultController extends Controller
$root,
$photo->url(),
$photo->updated_at,
'0.8',
'0.7',
$photo->thumbnailUrl('fullsize', false),
join(' - ', $photoMeta)
);

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Gallery;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Http\Controllers\Controller;
use App\User;
use App\UserFollower;
class ExploreController extends Controller
{
public function users()
{
if (!UserConfig::get('social_user_profiles'))
{
return redirect(route('home'));
}
$users = User::where([
'is_activated' => true,
'enable_profile_page' => true
])
->orderBy('name')
->paginate(UserConfig::get('items_per_page'));
$usersFollowing = UserFollower::where('user_id', $this->getUser()->id)
->select('following_user_id')
->get()
->map(function($f)
{
return $f->following_user_id;
})
->toArray();
return Theme::render('gallery.explore_users', [
'users' => $users,
'users_following' => $usersFollowing
]);
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Gallery;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Helpers\DbHelper;
use App\Http\Controllers\Controller;
use App\Label;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Request;
class LabelController extends Controller
{
public function index(Request $request)
{
$labels = Label::orderBy('name')->get();
/** @var Label $label */
foreach ($labels as $label)
{
$label->photos_count = $label->photoCount();
}
return Theme::render('gallery.labels', ['labels' => $labels]);
}
public function show(Request $request, $labelAlias)
{
$label = Label::where('url_alias', $labelAlias)->first();
if (is_null($label))
{
App::abort(404);
}
$validViews = UserConfig::allowedAlbumViews();
$requestedView = strtolower($request->get('view'));
if (!in_array($requestedView, $validViews))
{
$requestedView = $validViews[0];
}
$allowedAlbumIDs = DbHelper::getAlbumIDsForCurrentUser();
if ($label->photos()->count() == 0)
{
$requestedView = 'empty';
$photos = [];
}
else if ($requestedView != 'slideshow')
{
$photos = $label->photos()
->whereIn('album_id', $allowedAlbumIDs)
->orderBy(DB::raw('COALESCE(photos.taken_at, photos.created_at)'))
->paginate(UserConfig::get('items_per_page'));
}
else
{
// The slideshow view needs access to all photos, not paged
$photos = $label->photos()
->whereIn('album_id', $allowedAlbumIDs)
->orderBy(DB::raw('COALESCE(photos.taken_at, photos.created_at)'))
->get();
}
if (count($photos) == 0)
{
$requestedView = 'empty';
}
return Theme::render(sprintf('gallery.label_%s', $requestedView), [
'allowed_views' => $validViews,
'current_view' => $requestedView,
'label' => $label,
'photos' => $photos
]);
}
}

View File

@ -0,0 +1,411 @@
<?php
namespace App\Http\Controllers\Gallery;
use App\Album;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Helpers\DbHelper;
use App\Helpers\PermissionsHelper;
use App\Http\Controllers\Controller;
use App\Notifications\ModeratePhotoComment;
use App\Notifications\PhotoCommentApproved;
use App\Notifications\PhotoCommentApprovedUser;
use App\Notifications\PhotoCommentRepliedTo;
use App\Photo;
use App\PhotoComment;
use App\User;
use App\UserActivity;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Validation\ValidationException;
class PhotoCommentController extends Controller
{
public function moderate(Request $request, $albumUrlAlias, $photoFilename, $commentID)
{
$album = null;
/** @var Photo $photo */
$photo = null;
/** @var PhotoComment $comment */
$comment = null;
if (!$this->loadAlbumPhotoComment($albumUrlAlias, $photoFilename, $commentID, $album, $photo, $comment))
{
return null;
}
if (!User::currentOrAnonymous()->can('moderate-comments', $photo))
{
App::abort(403);
return null;
}
if (!$comment->isModerated())
{
if ($request->has('approve'))
{
$comment->approved_at = new \DateTime();
$comment->approved_user_id = $this->getUser()->id;
$comment->save();
$this->createUserActivityRecord($comment);
$this->notifyAlbumOwnerAndPoster($album, $photo, $comment);
$request->getSession()->flash('success', trans('gallery.photo_comment_approved_successfully'));
}
else if ($request->has('reject'))
{
$comment->rejected_at = new \DateTime();
$comment->rejected_user_id = $this->getUser()->id;
$comment->save();
$request->getSession()->flash('success', trans('gallery.photo_comment_rejected_successfully'));
}
}
return redirect($photo->url());
}
public function reply(Request $request, $albumUrlAlias, $photoFilename, $commentID)
{
$album = null;
/** @var Photo $photo */
$photo = null;
/** @var PhotoComment $comment */
$comment = null;
if (!$this->loadAlbumPhotoComment($albumUrlAlias, $photoFilename, $commentID, $album, $photo, $comment))
{
return null;
}
if (!User::currentOrAnonymous()->can('post-comment', $photo))
{
App::abort(403);
return null;
}
return Theme::render('partials.photo_comments_reply_form', [
'photo' => $photo,
'reply_comment' => $comment
]);
}
public function store(Request $request, $albumUrlAlias, $photoFilename)
{
$album = null;
/** @var Photo $photo */
$photo = null;
/** @var PhotoComment $comment */
$comment = null;
if (!$this->loadAlbumPhotoComment($albumUrlAlias, $photoFilename, 0, $album, $photo, $comment))
{
return null;
}
if (!User::currentOrAnonymous()->can('post-comment', $photo))
{
App::abort(403);
return null;
}
// Validate and link the parent comment, if provided
// We do this here so if the validation fails, we still have the parent comment available in the catch block
$parentComment = null;
if ($request->has('parent_comment_id'))
{
$parentComment = $photo->comments()->where('id', intval($request->get('parent_comment_id')))->first();
if (is_null($parentComment))
{
return redirect($photo->url());
}
}
try
{
$this->validate($request, [
'name' => 'required|max:255',
'email' => 'sometimes|max:255|email',
'comment' => 'required'
]);
$commentText = $this->stripDisallowedHtmlTags($request->get('comment'));
$comment = new PhotoComment();
$comment->photo_id = $photo->id;
$comment->fill($request->only(['name', 'email']));
$comment->comment = $commentText;
if (!is_null($parentComment))
{
$comment->parent_comment_id = $parentComment->id;
}
// Set the created user ID if we're logged in
$user = $this->getUser();
if (!is_null($user) && !$user->isAnonymous())
{
$comment->created_user_id = $user->id;
}
// Auto-approve the comment if we're allowed to moderate comments
$isAutoApproved = false;
if (User::currentOrAnonymous()->can('moderate-comments', $photo))
{
$comment->approved_at = new \DateTime();
$comment->approved_user_id = $user->id;
$isAutoApproved = true;
}
// Auto-approve the comment if settings allow
if ($user->isAnonymous() && !UserConfig::get('moderate_anonymous_users'))
{
$comment->approved_at = new \DateTime();
$comment->approved_user_id = null; // we don't have a user ID to set!
$isAutoApproved = true;
}
else if (!$user->isAnonymous() && !UserConfig::get('moderate_known_users'))
{
$comment->approved_at = new \DateTime();
$comment->approved_user_id = $user->id;
$isAutoApproved = true;
}
$comment->save();
// Send notification e-mails to moderators or album owner
if (!$isAutoApproved)
{
$this->notifyAlbumModerators($album, $photo, $comment);
$request->getSession()->flash('success', trans('gallery.photo_comment_posted_successfully_pending_moderation'));
}
else
{
// Log an activity record for the user's feed
$this->createUserActivityRecord($comment);
$this->notifyAlbumOwnerAndPoster($album, $photo, $comment);
$request->getSession()->flash('success', trans('gallery.photo_comment_posted_successfully'));
}
if ($request->isXmlHttpRequest())
{
return response()->json(['redirect_url' => $photo->url()]);
}
else
{
return redirect($photo->url());
}
}
catch (ValidationException $e)
{
if (!is_null($parentComment))
{
return redirect()
->to($photo->replyToCommentFormUrl($parentComment->id))
->withErrors($e->errors())
->withInput($request->all());
}
else
{
return redirect()
->back()
->withErrors($e->errors())
->withInput($request->all());
}
}
}
private function createUserActivityRecord(PhotoComment $comment)
{
if (!is_null($comment->created_user_id))
{
$userActivity = new UserActivity();
$userActivity->user_id = $comment->created_user_id;
$userActivity->activity_at = $comment->created_at;
if (is_null($comment->parent_comment_id))
{
$userActivity->type = 'photo.commented';
}
else
{
$userActivity->type = 'photo.comment_replied';
}
$userActivity->photo_id = $comment->photo_id;
$userActivity->photo_comment_id = $comment->id;
$userActivity->save();
}
}
private function loadAlbumPhotoComment($albumUrlAlias, $photoFilename, $commentID, &$album, &$photo, &$comment)
{
$album = DbHelper::getAlbumByPath($albumUrlAlias);
if (is_null($album))
{
App::abort(404);
return false;
}
$this->authorizeForUser($this->getUser(), 'view', $album);
$photo = PhotoController::loadPhotoByAlbumAndFilename($album, $photoFilename);
if (!UserConfig::get('allow_photo_comments'))
{
// Not allowed to post comments
App::abort(404);
return false;
}
if (intval($commentID > 0))
{
$comment = $photo->comments()->where('id', $commentID)->first();
if (is_null($comment))
{
App::abort(404);
return false;
}
}
return true;
}
/**
* Loads a given comment by its ID.
* @param $id
* @return PhotoComment
*/
private function loadCommentByID($id)
{
$comment = PhotoComment::where('id', intval($id))->first();
if (is_null($comment))
{
App::abort(404);
}
return $comment;
}
/**
* Sends an e-mail notification to an album's moderators that a comment is available to moderate.
* @param Album $album
* @param Photo $photo
* @param PhotoComment $comment
*/
private function notifyAlbumModerators(Album $album, Photo $photo, PhotoComment $comment)
{
// Get all users from the cache
$helper = new PermissionsHelper();
$moderators = $helper->usersWhoCan_Album($album, 'moderate-comments');
/** @var User $moderator */
foreach ($moderators as $moderator)
{
$moderator->notify(new ModeratePhotoComment($album, $photo, $comment));
}
}
/**
* Sends an e-mail notification to an album's owned that a comment has been posted/approved.
* @param Album $album
* @param Photo $photo
* @param PhotoComment $comment
*/
private function notifyAlbumOwnerAndPoster(Album $album, Photo $photo, PhotoComment $comment)
{
/** @var User $owner */
$owner = $album->user;
$owner->notify(new PhotoCommentApproved($album, $photo, $comment));
// Also send a notification to the comment poster
$poster = new User();
$poster->name = $comment->authorDisplayName();
$poster->email = $comment->authorEmail();
$poster->notify(new PhotoCommentApprovedUser($album, $photo, $comment));
// Send notification to the parent comment owner (if this is a reply)
if (!is_null($comment->parent_comment_id))
{
$parentComment = $this->loadCommentByID($comment->parent_comment_id);
if (is_null($parentComment))
{
return;
}
$parentPoster = new User();
$parentPoster->name = $parentComment->authorDisplayName();
$parentPoster->email = $parentComment->authorEmail();
$parentPoster->notify(new PhotoCommentRepliedTo($album, $photo, $comment));
}
}
private function stripDisallowedHtmlTags($commentText)
{
$allowedHtmlTags = explode(',', UserConfig::get('photo_comments_allowed_html'));
$allowedHtmlTagsCleaned = [];
foreach ($allowedHtmlTags as $tag)
{
$allowedHtmlTagsCleaned[] = trim($tag);
}
// Match any starting HTML tags
$regexMatchString = '/<(?!\/)([a-z]+)(?:\s.*)*>/Us';
$htmlTagMatches = [];
preg_match_all($regexMatchString, $commentText, $htmlTagMatches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
for ($index = 0; $index < count($htmlTagMatches); $index++)
{
$htmlTagMatch = $htmlTagMatches[$index];
$htmlTag = $htmlTagMatch[1][0]; // e.g. "p" for <p>
if (in_array($htmlTag, $allowedHtmlTagsCleaned))
{
// This tag is allowed - carry on
continue;
}
/* This tag is not allowed - remove it from the string */
// Find the closing tag
$disallowedStringOffset = $htmlTagMatch[0][1];
$endingTagMatches = [];
preg_match(sprintf('/(<%1$s.*>)(.+)<\/%1$s>/Us', $htmlTag), $commentText, $endingTagMatches, 0, $disallowedStringOffset);
// Replace the matched string with the inner string
$commentText = substr_replace($commentText, $endingTagMatches[2], $disallowedStringOffset, strlen($endingTagMatches[0]));
// Adjust the offsets for strings after the one we're processing, so the offsets match up with the string correctly
for ($index2 = $index + 1; $index2 < count($htmlTagMatches); $index2++)
{
// If this string appears entirely BEFORE the next one starts, we need to subtract the entire length.
// Otherwise, we only need to substract the length of the start tag, as the next one starts within it.
$differenceAfterReplacement = strlen($endingTagMatches[1]);
if ($htmlTagMatch[0][1] + strlen($endingTagMatches[0]) < $htmlTagMatches[$index2][0][1])
{
$differenceAfterReplacement = strlen($endingTagMatches[0]) - strlen($endingTagMatches[2]);
}
$htmlTagMatches[$index2][0][1] -= $differenceAfterReplacement;
$htmlTagMatches[$index2][1][1] -= $differenceAfterReplacement;
}
}
return $commentText;
}
}

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
]
);
@ -102,11 +101,42 @@ class PhotoController extends Controller
$isOriginalAllowed = Gate::forUser($this->getUser())->allows('photo.download_original', $photo);
$returnAlbumUrl = $album->url();
$referer = $request->headers->get('Referer');
if (strlen($referer) > 0 && MiscHelper::isSafeUrl($referer))
// Load the Next/Previous buttons
$thisPhotoDate = is_null($photo->taken_at) ? $photo->created_at : $photo->taken_at;
// I don't like the idea of using a totally raw SQL query, but it's the only sure-fire way to number the rows
// so we can get the previous/next photos accurately - and we don't have to load all data for the photo objects
$previousPhoto = null;
$nextPhoto = null;
$allAlbumPhotos = DB::select(
DB::raw(
'SELECT p.id, (@row_number:=@row_number + 1) AS row_number
FROM photos p, (SELECT @row_number:=0) AS t
WHERE p.album_id = :album_id
ORDER BY COALESCE(p.taken_at, p.created_at), p.name, p.id;'
),
[
'album_id' => $album->id
]
);
for ($i = 0; $i < count($allAlbumPhotos); $i++)
{
$returnAlbumUrl = $referer;
if ($allAlbumPhotos[$i]->id === $photo->id)
{
if ($i > 0)
{
$previousPhoto = Photo::where('id', $allAlbumPhotos[$i - 1]->id)->first();
}
if ($i + 1 < count($allAlbumPhotos))
{
$nextPhoto = Photo::where('id', $allAlbumPhotos[$i + 1]->id)->first();
}
break;
}
}
// Record the visit to the photo
@ -124,8 +154,33 @@ class PhotoController extends Controller
return Theme::render('gallery.photo', [
'album' => $album,
'is_original_allowed' => $isOriginalAllowed,
'next_photo' => $nextPhoto,
'photo' => $photo,
'return_album_url' => $returnAlbumUrl
'previous_photo' => $previousPhoto,
'success' => $request->getSession()->get('success')
]);
}
public function showExifData(Request $request, $albumUrlAlias, $photoFilename)
{
$album = DbHelper::getAlbumByPath($albumUrlAlias);
if (is_null($album))
{
App::abort(404);
return null;
}
$this->authorizeForUser($this->getUser(), 'view', $album);
$photo = PhotoController::loadPhotoByAlbumAndFilename($album, $photoFilename);
$this->authorizeForUser($this->getUser(), 'changeMetadata', $photo);
$exifData = print_r(unserialize(base64_decode($photo->raw_exif_data)), true);
return Theme::render('gallery.photo_exif', [
'album' => $album,
'exif_data' => $exifData,
'photo' => $photo
]);
}

View File

@ -0,0 +1,313 @@
<?php
namespace App\Http\Controllers\Gallery;
use App\Facade\Theme;
use App\Helpers\DbHelper;
use App\Http\Controllers\Controller;
use App\Label;
use App\Photo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class StatisticsController extends Controller
{
public function albumSizeByPhotosChart(Request $request)
{
$this->authorizeForUser($this->getUser(), 'statistics.public-access');
$stats = DB::table('photos')
->whereIn('photos.album_id', DbHelper::getAlbumIDsForCurrentUser())
->join('albums', 'albums.id', '=', 'photos.album_id')
->groupBy('albums.name')
->select('albums.name', DB::raw('count(photos.id) as photo_count'))
->orderBy('photo_count', 'desc')
->limit(10)
->get();
$labels = [];
$data = [];
foreach ($stats as $stat)
{
$labels[] = $stat->name;
$data[] = $stat->photo_count;
}
return response()->json([
'labels' => $labels,
'backgrounds' => $this->rotateColoursForData($data),
'data' => $data
]);
}
public function albumSizeByPhotoSizeChart(Request $request)
{
$this->authorizeForUser($this->getUser(), 'statistics.public-access');
$stats = DB::table('photos')
->whereIn('photos.album_id', DbHelper::getAlbumIDsForCurrentUser())
->join('albums', 'albums.id', '=', 'photos.album_id')
->groupBy('albums.name')
->select('albums.name', DB::raw('sum(photos.file_size) as photo_size'))
->orderBy('photo_size', 'desc')
->limit(10)
->get();
$labels = [];
$data = [];
foreach ($stats as $stat)
{
$labels[] = $stat->name;
$data[] = ceil($stat->photo_size / 1024 / 1024);
}
return response()->json([
'labels' => $labels,
'backgrounds' => $this->rotateColoursForData($data),
'data' => $data
]);
}
public function camerasChart(Request $request)
{
$this->authorizeForUser($this->getUser(), 'statistics.public-access');
$stats = DB::table('photos')
->where([
['camera_make', '!=', ''],
['camera_model', '!=', '']
])
->whereIn('album_id', DbHelper::getAlbumIDsForCurrentUser())
->groupBy('camera_make', 'camera_model')
->select('camera_make', 'camera_model', DB::raw('count(*) as photo_count'))
->orderBy('photo_count', 'desc')
->get();
$labels = [];
$data = [];
foreach ($stats as $stat)
{
// Remove the model from the make if it starts with it
// E.g. CANON - CANON EOS 1200D becomes just CANON EOS 1200D
if (substr($stat->camera_model, 0, strlen($stat->camera_make)) == $stat->camera_make)
{
$stat->camera_make = trim(substr($stat->camera_make, strlen($stat->camera_make)));
}
$labels[] = sprintf('%s %s', $stat->camera_make, $stat->camera_model);
$data[] = $stat->photo_count;
}
return response()->json([
'labels' => $labels,
'backgrounds' => $this->rotateColoursForData($data),
'data' => $data
]);
}
public function fileSizeChart(Request $request)
{
$this->authorizeForUser($this->getUser(), 'statistics.public-access');
$labels = [
trans('gallery.statistics.file_sizes_legend.small'),
trans('gallery.statistics.file_sizes_legend.medium'),
trans('gallery.statistics.file_sizes_legend.large'),
trans('gallery.statistics.file_sizes_legend.huge')
];
$data = [0, 0, 0, 0];
$stats = DB::table('photos')->whereIn('album_id', DbHelper::getAlbumIDsForCurrentUser())->orderBy('id');
$stats->chunk(100, function($photos) use (&$data)
{
foreach ($photos as $photo)
{
if ($photo->file_size < (1 * 1024 * 1024))
{
$data[0]++;
}
else if ($photo->file_size < (3 * 1024 * 1024))
{
$data[1]++;
}
else if ($photo->file_size < (5 * 1024 * 1024))
{
$data[2]++;
}
else if ($photo->file_size >= (5 * 1024 * 1024))
{
$data[3]++;
}
}
});
return response()->json([
'labels' => $labels,
'backgrounds' => $this->rotateColoursForData($data),
'data' => $data
]);
}
public function index(Request $request)
{
$this->authorizeForUser($this->getUser(), 'statistics.public-access');
// Numbers for at-a-glance
$albumIDs = DbHelper::getAlbumIDsForCurrentUser();
$albumCount = count($albumIDs);
$labelCount = Label::all()->count();
$photoCount = Photo::whereIn('album_id', $albumIDs)->count();
return Theme::render('gallery.statistics', [
'album_count' => $albumCount,
'label_count' => $labelCount,
'photo_count' => $photoCount
]);
}
public function photosCombined(Request $request)
{
$this->authorizeForUser($this->getUser(), 'statistics.public-access');
$labels = [];
$data = [
['label' => trans('gallery.statistics.photos_combined.taken'), 'values' => []],
['label' => trans('gallery.statistics.photos_combined.uploaded'), 'values' => []]
];
foreach ($this->lastXMonthsDates(18) as $date)
{
$fromDate = sprintf('%04d-%02d-01 00:00:00', $date[0], $date[1]);
$toDate = sprintf('%04d-%02d-%02d 23:59:59', $date[0], $date[1], cal_days_in_month(CAL_GREGORIAN, $date[1], $date[0]));
$photoCountTaken = Photo::whereBetween('taken_at', array($fromDate, $toDate))->count();
$photoCountUploaded = Photo::whereBetween('created_at', array($fromDate, $toDate))->count();
$labels[] = date('M Y', strtotime($fromDate));
$data[0]['values'][] = $photoCountTaken;
$data[1]['values'][] = $photoCountUploaded;
}
$data[0]['values'] = array_reverse($data[0]['values']);
$data[1]['values'] = array_reverse($data[1]['values']);
return response()->json([
'labels' => array_reverse($labels),
'data' => $data
]);
}
public function photosTaken12Months(Request $request)
{
$this->authorizeForUser($this->getUser(), 'statistics.public-access');
$labels = [];
$data = [];
foreach ($this->lastXMonthsDates() as $date)
{
$fromDate = sprintf('%04d-%02d-01 00:00:00', $date[0], $date[1]);
$toDate = sprintf('%04d-%02d-%02d 23:59:59', $date[0], $date[1], cal_days_in_month(CAL_GREGORIAN, $date[1], $date[0]));
$photoCount = Photo::whereBetween('taken_at', array($fromDate, $toDate))->count();
$labels[] = date('M Y', strtotime($fromDate));
$data[] = $photoCount;
}
return response()->json([
'labels' => array_reverse($labels),
'data' => array_reverse($data)
]);
}
public function photosUploaded12Months(Request $request)
{
$this->authorizeForUser($this->getUser(), 'statistics.public-access');
$labels = [];
$data = [];
foreach ($this->lastXMonthsDates() as $date)
{
$fromDate = sprintf('%04d-%02d-01 00:00:00', $date[0], $date[1]);
$toDate = sprintf('%04d-%02d-%02d 23:59:59', $date[0], $date[1], cal_days_in_month(CAL_GREGORIAN, $date[1], $date[0]));
$photoCount = Photo::whereBetween('created_at', array($fromDate, $toDate))->count();
$labels[] = date('M Y', strtotime($fromDate));
$data[] = $photoCount;
}
return response()->json([
'labels' => array_reverse($labels),
'data' => array_reverse($data)
]);
}
private function lastXMonthsDates($x = 12)
{
$year = intval(date('Y'));
$month = intval(date('m'));
$datesNeeded = [];
while (count($datesNeeded) < $x)
{
$datesNeeded[] = [$year, $month];
$month--;
if ($month == 0)
{
$month = 12;
$year--;
}
}
return $datesNeeded;
}
private function rotateColoursForData(array $data = [])
{
$colours = [
'#d54d36',
'#59c669',
'#aa5ccf',
'#85b83a',
'#5f6cd9',
'#bbb248',
'#ca49a1',
'#479341',
'#d94b70',
'#52b395',
'#7b589e',
'#da8f32',
'#6e8bd0',
'#8a722c',
'#46aed7',
'#aa5839',
'#d48cca',
'#64803f',
'#a5506d',
'#e19774'
];
$result = [];
$lastIndex = 0;
for ($i = 0; $i < count($data); $i++)
{
$result[] = $colours[$lastIndex];
$lastIndex++;
if ($lastIndex >= count($colours))
{
$lastIndex = 0;
}
}
return $result;
}
}

View File

@ -0,0 +1,531 @@
<?php
namespace App\Http\Controllers\Gallery;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Helpers\DbHelper;
use App\Http\Controllers\Controller;
use App\Http\Requests\SaveUserSettingsRequest;
use App\Notifications\UserChangeEmailRequired;
use App\User;
use App\UserActivity;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Request;
class UserController extends Controller
{
public function activityFeed()
{
if (!UserConfig::get('social_user_feeds'))
{
return redirect(route('home'));
}
return Theme::render('gallery.user_activity_feed', [
'user' => $this->getUser()
]);
}
public function activityFeedJson()
{
if (!UserConfig::get('social_user_feeds'))
{
return response()->json(['message' => 'Activity feeds not enabled']);
}
$user = $this->getUser();
$result = [];
$activities = UserActivity::with('photo')
->with('photoComment')
->with('user')
->join('user_followers', 'user_followers.following_user_id', '=', 'user_activity.user_id')
->where([
'user_followers.user_id' => $user->id
])
->orderBy('activity_at', 'desc')
->limit(100) // TODO: make this configurable
->select('user_activity.*')
->get();
/** @var UserActivity $activity */
foreach ($activities as $activity)
{
$userName = $activity->user->name;
$userProfileUrl = $activity->user->profileUrl();
$userAvatar = Theme::gravatarUrl($activity->user->email, 32);
$newItem = [
'activity_at' => date(UserConfig::get('date_format'), strtotime($activity->activity_at)),
'avatar' => $userAvatar,
'description' => trans(sprintf('gallery.user_feed_type.%s', $activity->type))
];
$params = [];
$params['user_name'] = $userName;
$params['user_url'] = $userProfileUrl;
if (!is_null($activity->photo))
{
// Check the user has access
if (!$this->getUser()->can('view', $activity->photo))
{
continue;
}
$params['photo_name'] = $activity->photo->name;
$params['photo_url'] = $activity->photo->url();
}
if (!is_null($activity->album))
{
// Check the user has access
if (!$this->getUser()->can('view', $activity->album))
{
continue;
}
$params['album_name'] = $activity->album->name;
$params['album_url'] = $activity->album->url();
}
// Other activity-specific parameters
switch (strtolower($activity->type))
{
case 'user.created':
$params['app_name'] = UserConfig::get('app_name');
$params['app_url'] = route('home');
break;
}
$newItem['params'] = $params;
$result[] = $newItem;
}
return response()->json($result);
}
public function confirmEmailChangeState(Request $request)
{
$user = $this->getUser();
if (!$user->is_email_change_in_progress)
{
return redirect(route('userSettings'));
}
// Update the e-mail address
$user->email = $user->new_email_address;
// Reset the e-mail change state
$user->is_email_change_in_progress = false;
$user->new_email_address = null;
$user->save();
$request->session()->flash('success', trans('auth.change_email_success_message'));
return redirect(route('userSettings'));
}
public function followUser($idOrAlias)
{
$user = $this->loadUserProfilePage($idOrAlias);
$isFollowing = $this->getUser()->following()->where('following_user_id', $user->id)->count() > 0;
if (!$isFollowing)
{
$this->getUser()->following()->attach(
$user->id,
[
'created_at' => new \DateTime(),
'updated_at' => new \DateTime()
]
);
}
return response()->json(true);
}
public function resetEmailChangeState(Request $request)
{
$user = $this->getUser();
if (!$user->is_email_change_in_progress)
{
return redirect(route('userSettings'));
}
$data = $request->all();
if (isset($data['resend_email']))
{
$this->sendEmailChangeConfirmationEmail($user, $user->new_email_address);
$request->session()->flash('info', trans('auth.change_email_required_message'));
}
if (isset($data['cancel_change']))
{
$user->is_email_change_in_progress = false;
$user->new_email_address = null;
$user->save();
}
return redirect(route('userSettings'));
}
public function saveSettings(SaveUserSettingsRequest $request)
{
$data = $request->only(['name', 'email', 'profile_alias', 'enable_profile_page']);
$user = $this->getUser();
if (
UserConfig::get('require_email_verification') &&
isset($data['email']) &&
$data['email'] != $user->email &&
!$user->is_email_change_in_progress
)
{
// Can't update the e-mail directly until the new e-mail address has been verified.
// TODO - send e-mail and handle response, flag e-mail as being "change in-progress"
// Send activation e-mail
$this->sendEmailChangeConfirmationEmail($user, $data['email']);
$request->session()->flash('info', trans('auth.change_email_required_message'));
// Flag the user as a change e-mail in progress
$user->new_email_address = $data['email'];
$user->is_email_change_in_progress = true;
$user->save();
unset($data['email']);
$request->session()->flash('info', trans('auth.change_email_required_message'));
}
// Don't allow e-mail address to be changed if a change is in progress
if ($user->is_email_change_in_progress)
{
unset($data['email']);
}
$user->fill($data);
$user->enable_profile_page = (isset($data['enable_profile_page']) && strtolower($data['enable_profile_page']) == 'on');
$user->save();
$request->session()->flash('success', trans('gallery.user_settings.settings_saved'));
return redirect(route('userSettings'));
}
public function settings(Request $request)
{
return Theme::render('gallery.user_settings', [
'info' => $request->session()->get('info'),
'success' => $request->session()->get('success'),
'user' => $this->getUser()
]);
}
public function show(Request $request, $idOrAlias)
{
$user = $this->loadUserProfilePage($idOrAlias);
$albums = $this->getAlbumsForUser($user);
$albumIDs = $this->getAlbumIDsForUser($user);
$cameras = $this->getCamerasUsedInAlbums($albumIDs);
$activity = $this->getActivityDatesInAlbums($albumIDs);
$daysInMonth = $this->getDaysInMonths();
// Only logged-in users can follow other users (and if it's not their own page!)
$canFollow = !$this->getUser()->isAnonymous() && $this->getUser()->id != $user->id;
$isFollowing = false;
if ($canFollow)
{
// Is the current user following this user?
$isFollowing = $this->getUser()->following()->where('following_user_id', $user->id)->count() > 0;
}
return Theme::render('gallery.user_profile', [
'active_tab' => $request->get('tab'),
'activity_taken' => $this->constructActivityGrid($activity['taken']),
'activity_uploaded' => $this->constructActivityGrid($activity['uploaded']),
'albums' => $albums,
'cameras' => $cameras,
'can_follow' => $canFollow,
'is_following' => $isFollowing,
'month_days' => $daysInMonth,
'user' => $user
]);
}
public function showFeedJson(Request $request, $idOrAlias)
{
$user = $this->loadUserProfilePage($idOrAlias);
$result = [];
$activities = UserActivity::with('photo')
->with('photoComment')
->with('album')
->where([
'user_id' => $user->id
])
->orderBy('activity_at', 'desc')
->limit(100) // TODO: make this configurable
->get();
$userName = $user->name;
$userProfileUrl = $user->profileUrl();
$userAvatar = Theme::gravatarUrl($user->email, 32);
/** @var UserActivity $activity */
foreach ($activities as $activity)
{
$newItem = [
'activity_at' => date(UserConfig::get('date_format'), strtotime($activity->activity_at)),
'avatar' => $userAvatar,
'description' => trans(sprintf('gallery.user_feed_type.%s', $activity->type))
];
$params = [];
$params['user_name'] = $userName;
$params['user_url'] = $userProfileUrl;
if (!is_null($activity->photo))
{
// Check the user has access
if (!$this->getUser()->can('view', $activity->photo))
{
continue;
}
$params['photo_name'] = $activity->photo->name;
$params['photo_url'] = $activity->photo->url();
}
if (!is_null($activity->album))
{
// Check the user has access
if (!$this->getUser()->can('view', $activity->album))
{
continue;
}
$params['album_name'] = $activity->album->name;
$params['album_url'] = $activity->album->url();
}
// Other activity-specific parameters
switch (strtolower($activity->type))
{
case 'user.created':
$params['app_name'] = UserConfig::get('app_name');
$params['app_url'] = route('home');
break;
}
$newItem['params'] = $params;
$result[] = $newItem;
}
return response()->json($result);
}
public function unFollowUser($idOrAlias)
{
$user = $this->loadUserProfilePage($idOrAlias);
$isFollowing = $this->getUser()->following()->where('following_user_id', $user->id)->count() > 0;
if ($isFollowing)
{
$this->getUser()->following()->detach($user->id);
}
return response()->json(true);
}
private function constructActivityGrid(Collection $collection)
{
$results = [];
$lastYearFrom = new \DateTime();
$lastYearFrom->sub(new \DateInterval('P1Y'));
$lastYearFrom->add(new \DateInterval('P1D'));
$today = new \DateTime();
$current = clone $lastYearFrom;
while ($current < $today)
{
$year = intval($current->format('Y'));
$month = intval($current->format('m'));
$date = intval($current->format('d'));
if (!isset($results[$year]))
{
$results[$year] = [];
}
if (!isset($results[$year][$month]))
{
$results[$year][$month] = [];
}
if (!isset($results[$year][$month][$date]))
{
$results[$year][$month][$date] = 0;
}
$current->add(new \DateInterval('P1D'));
}
// Now update the totals from the collection
foreach ($collection as $photoInfo)
{
$date = \DateTime::createFromFormat('Y-m-d', $photoInfo->the_date);
$year = intval($date->format('Y'));
$month = intval($date->format('m'));
$date = intval($date->format('d'));
$results[$year][$month][$date] = $photoInfo->photos_count;
}
// Replace the month names
foreach ($results as $year => &$months)
{
foreach ($months as $month => $dates)
{
$monthDate = \DateTime::createFromFormat('m', $month);
$months[$monthDate->format('M')] = $dates;
unset($months[$month]);
}
}
return $results;
}
private function getActivityDatesInAlbums(array $albumIDs)
{
$createdAt = DB::table('photos')
->whereIn('album_id', $albumIDs)
->whereRaw(DB::raw('DATE(created_at) > DATE(DATE_SUB(NOW(), INTERVAL 1 year))'))
->select([
DB::raw('DATE(created_at) AS the_date'),
DB::raw('COUNT(photos.id) AS photos_count')
])
->groupBy(DB::raw('DATE(created_at)'))
->orderBy(DB::raw('DATE(created_at)'))
->get();
$takenAt = DB::table('photos')
->whereIn('album_id', $albumIDs)
->whereRaw(DB::raw('DATE(taken_at) > DATE(DATE_SUB(NOW(), INTERVAL 1 year))'))
->select([
DB::raw('DATE(taken_at) AS the_date'),
DB::raw('COUNT(photos.id) AS photos_count')
])
->groupBy(DB::raw('DATE(taken_at)'))
->orderBy(DB::raw('DATE(taken_at)'))
->get();
return ['uploaded' => $createdAt, 'taken' => $takenAt];
}
private function getAlbumsForUser(User $user)
{
return DbHelper::getAlbumsForCurrentUser_NonPaged()
->where('user_id', $user->id)
->paginate(UserConfig::get('items_per_page'));
}
private function getAlbumIDsForUser(User $user)
{
$results = [];
$albums = DbHelper::getAlbumsForCurrentUser_NonPaged()
->where('user_id', $user->id)
->select('albums.id')
->get();
foreach ($albums as $album)
{
$results[] = intval($album->id);
}
return $results;
}
private function getCamerasUsedInAlbums(array $albumIDs)
{
return DB::table('photos')
->whereIn('album_id', $albumIDs)
->where([
['camera_make', '!=', ''],
['camera_model', '!=', '']
])
->groupBy('camera_make', 'camera_model', 'camera_software')
->select('camera_make', 'camera_model', 'camera_software', DB::raw('count(*) as photo_count'))
->orderBy('photo_count', 'desc')
->orderBy('camera_make')
->orderBy('camera_model')
->orderBy('camera_software')
->get();
}
private function getDaysInMonths()
{
$results = [];
$lastYearFrom = new \DateTime();
$lastYearFrom->sub(new \DateInterval('P1Y'));
$lastYearFrom->sub(new \DateInterval(sprintf('P%dD', $lastYearFrom->format('d') - 1)));
$today = new \DateTime();
$current = clone $lastYearFrom;
while ($current < $today)
{
$year = intval($current->format('Y'));
$month = intval($current->format('m'));
$daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year);
$results[$year][$current->format('M')] = $daysInMonth;
$current->add(new \DateInterval('P1M'));
}
return $results;
}
/**
* @param $idOrAlias
* @return User
*/
private function loadUserProfilePage($idOrAlias)
{
// If a user has a profile alias set, their profile page cannot be accessed by the ID
$user = User::where(DB::raw('COALESCE(NULLIF(profile_alias, \'\'), id)'), strtolower($idOrAlias))->first();
if (is_null($user))
{
App::abort(404);
return null;
}
$this->authorizeForUser($this->getUser(), 'view', $user);
return $user;
}
private function sendEmailChangeConfirmationEmail(User $user, $newEmailAddress)
{
$oldEmailAddress = $user->email;
$user->email = $newEmailAddress;
$user->notify(new UserChangeEmailRequired());
$user->email = $oldEmailAddress;
}
}

View File

@ -2,10 +2,12 @@
namespace App\Http\Controllers;
use App\AlbumDefaultAnonymousPermission;
use App\Configuration;
use App\Facade\UserConfig;
use App\Helpers\MiscHelper;
use App\Http\Requests\StoreUserRequest;
use App\Permission;
use App\Storage;
use App\User;
use Illuminate\Http\Request;
@ -17,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
@ -39,6 +41,7 @@ class InstallController extends Controller
$user->password = bcrypt($request->get('password'));
$user->is_admin = true;
$user->is_activated = true;
$user->enable_profile_page = true;
$user->save();
return $this->completeSetup();
@ -49,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()->set('install_stage', 2);
return redirect(route('install.database'));
}
$canContinue = true;
$runtimeMinimum = '5.6.4'; // this minimum is imposed by Laravel 5.3
$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')
{
@ -150,12 +92,16 @@ class InstallController extends Controller
Artisan::call('cache:clear');
Artisan::call('migrate', ['--force' => true]);
Artisan::call('db:seed', ['--force' => true]);
$versionNumber = UserConfig::getOrCreateModel('app_version');
$versionNumber->value = config('app.version');
$versionNumber->save();
$request->session()->set('install_stage', 3);
// Default settings
$this->setConfigurationForNewSystems();
$request->session()->put('install_stage', 2);
return redirect(route('install.administrator'));
}
catch (\Exception $ex)
@ -209,4 +155,47 @@ class InstallController extends Controller
}
}
}
private function setDefaultAnonymousPermission($section, $permission)
{
$permission = Permission::where([
['section', $section],
['description', $permission]
])->first();
if (is_null($permission))
{
return;
}
$adap = new AlbumDefaultAnonymousPermission();
$adap->permission_id = $permission->id;
$adap->save();
}
private function setConfigurationForNewSystems()
{
/** @var Configuration $socialFeeds */
$socialFeeds = UserConfig::getOrCreateModel('social_user_feeds');
$socialFeeds->value = true;
$socialFeeds->save();
/** @var Configuration $socialProfiles */
$socialProfiles = UserConfig::getOrCreateModel('social_user_profiles');
$socialProfiles->value = true;
$socialProfiles->save();
/** @var Configuration $photoComments */
$photoComments = UserConfig::getOrCreateModel('allow_photo_comments');
$photoComments->value = true;
$photoComments->save();
$defaultPermissions = ['album.list', 'album.view', 'album.post-comment'];
foreach ($defaultPermissions as $defaultPermission)
{
$permissionParts = explode('.', $defaultPermission);
$this->setDefaultAnonymousPermission($permissionParts[0], $permissionParts[1]);
}
}
}

View File

@ -5,6 +5,7 @@ namespace App\Http\Middleware;
use App\DataMigration;
use App\Facade\UserConfig;
use App\Helpers\MiscHelper;
use App\Helpers\PermissionsHelper;
use Closure;
use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
@ -13,9 +14,6 @@ use Illuminate\Support\Facades\Log;
class AppInstallation
{
private $baseDirectory;
private $environmentFilePath;
/**
* The application instance.
*
@ -32,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)
@ -50,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
@ -65,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');
@ -94,7 +112,9 @@ class AppInstallation
{
Log::info('Upgrading database', ['new_version' => $appVersionNumber]);
Artisan::call('config:cache');
Artisan::call('cache:clear');
Artisan::call('view:clear');
Artisan::call('migrate', ['--force' => true]);
Artisan::call('db:seed', ['--force' => true]);
@ -131,6 +151,14 @@ class AppInstallation
// Save the new version number
$versionNumber->value = $appVersionNumber;
$versionNumber->save();
// Rebuild the permissions cache
$helper = new PermissionsHelper();
$helper->rebuildCache();
return true;
}
return false;
}
}

View File

@ -19,6 +19,7 @@ class CheckMaxPostSizeExceeded
protected $exclude = [
'/admin/photos/analyse/*',
'/admin/photos/flip/*',
'/admin/photos/reanalyse/*',
'/admin/photos/regenerate-thumbnails/*',
'/admin/photos/rotate/*'
];

View File

@ -6,6 +6,7 @@ use App\Album;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\Helpers\DbHelper;
use App\Label;
use Closure;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Foundation\Application;
@ -22,73 +23,10 @@ class GlobalConfiguration
*/
protected $app;
/**
* Create a new middleware instance.
*
* @param \Illuminate\Foundation\Application $app
* @return void
*/
public function __construct(Application $app)
{
$this->app = $app;
}
public function handle(Request $request, Closure $next)
{
// We can always add the version number
$this->addVersionNumberToView();
// If running the installer, chances are our database isn't running yet
if ($request->is('install/*'))
{
return $next($request);
}
// When running migrations, CLI tasks or the installer, don't need to add things to the view
if (php_sapi_name() != 'cli')
{
$this->addThemeInfoToView();
$this->addAlbumsToView();
}
// Set the default mail configuration as per user's requirements
$this->updateMailConfig();
return $next($request);
}
private function addAlbumsToView()
{
$albums = DbHelper::getAlbumsForCurrentUser_NonPaged()->get();
View::share('albums', $albums);
}
private function addThemeInfoToView()
{
$themeInfo = Theme::info();
// Add each theme info element to the view - prefixing with theme_
// e.g. $themeInfo['name'] becomes $theme_name in the view
foreach ($themeInfo as $key => $value)
{
View::share('theme_' . $key, $value);
}
// Also add a theme_url key
View::share('theme_url', sprintf('themes/%s', Theme::current()));
}
private function addVersionNumberToView()
{
$version = config('app.version');
View::share('app_version', $version);
View::share('app_version_url', $version);
}
private function updateMailConfig()
public static function updateMailConfig()
{
/** @var Mailer $mailer */
$mailer = $this->app->mailer;
$mailer = app('mailer');
$swiftMailer = $mailer->getSwiftMailer();
/** @var \Swift_SmtpTransport $transport */
@ -122,4 +60,152 @@ class GlobalConfiguration
$mailer->alwaysFrom(UserConfig::get('sender_address'), UserConfig::get('sender_name'));
}
/**
* Create a new middleware instance.
*
* @param \Illuminate\Foundation\Application $app
* @return void
*/
public function __construct(Application $app)
{
$this->app = $app;
}
public function handle(Request $request, Closure $next)
{
// We can always add the version number
$this->addVersionNumberToView();
// If running the installer, chances are our database isn't running yet
if ($request->is('install/*'))
{
return $next($request);
}
// When running migrations, CLI tasks or the installer, don't need to add things to the view
if (php_sapi_name() != 'cli')
{
$this->addThemeInfoToView();
$this->addAlbumsToView();
$this->addLabelsToView();
$this->addFlashMessages();
}
// Set the default mail configuration as per user's requirements
$this->updateMailConfig();
return $next($request);
}
private function addAlbumsToView()
{
$albums = DbHelper::getAlbumsForCurrentUser_NonPaged()->get();
View::share('g_albums', $albums);
if (UserConfig::get('albums_menu_parents_only'))
{
// Only show top-level albums in the nav bar
$navbarAlbums = $albums->filter(function($value, $key)
{
return is_null($value->parent_album_id);
});
}
else
{
// If not just showing top-level albums, we can show all
$navbarAlbums = $albums;
}
$navbarAlbumsToDisplay = UserConfig::get('albums_menu_number_items');
View::share('g_albums_menu', $navbarAlbums->take($navbarAlbumsToDisplay));
View::share('g_more_albums', $navbarAlbums->count() - $navbarAlbumsToDisplay);
$albumsToUpload = DbHelper::getAlbumsForCurrentUser_NonPaged('upload-photos')->get();
View::share('g_albums_upload', $albumsToUpload);
}
private function addFlashMessages()
{
/** @var Request $request */
$request = app('request');
if ($request->session()->has('error'))
{
View::share('error', $request->session()->get('error'));
}
if ($request->session()->has('success'))
{
View::share('success', $request->session()->get('success'));
}
}
private function addLabelsToView()
{
$NUMBER_TO_SHOW_IN_NAVBAR = 5;
$labelCount = Label::count();
$labels = Label::all();
$labelsToAdd = [];
/** @var Label $label */
foreach ($labels as $label)
{
$label->photos_count = $label->photoCount();
$labelsToAdd[] = $label;
}
// Sort my photo count, then name
usort($labelsToAdd, function(Label $a, Label $b)
{
if ($a->photos_count == $b->photos_count)
{
if ($a->name == $b->name)
{
return 0;
}
else if ($a->name < $b->name)
{
return -1;
}
else if ($a->name > $b->name)
{
return 1;
}
}
else if ($a->photos_count < $b->photos_count)
{
return -1;
}
else if ($a->photos_count > $b->photos_count)
{
return 1;
}
});
$labelsToAdd = array_slice(array_reverse($labelsToAdd), 0, $NUMBER_TO_SHOW_IN_NAVBAR);
View::share('g_labels', $labelsToAdd);
View::share('g_more_labels', $labelCount - $NUMBER_TO_SHOW_IN_NAVBAR);
}
private function addThemeInfoToView()
{
$themeInfo = Theme::info();
// Add each theme info element to the view - prefixing with theme_
// e.g. $themeInfo['name'] becomes $theme_name in the view
foreach ($themeInfo as $key => $value)
{
View::share('theme_' . $key, $value);
}
// Also add a theme_url key
View::share('theme_url', sprintf('themes/%s', Theme::current()));
}
private function addVersionNumberToView()
{
$version = config('app.version');
View::share('app_version', $version);
View::share('app_version_url', $version);
}
}

View File

@ -24,10 +24,11 @@ class SaveSettingsRequest extends FormRequest
public function rules()
{
return [
'albums_menu_number_items' => 'required|integer|min:1',
'app_name' => 'required|max:255',
'date_format' => 'required',
'smtp_server' => 'required',
'smtp_port' => 'required:integer'
'smtp_port' => 'required|integer'
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
class SaveUserSettingsRequest 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()
{
return [
'name' => 'required|max:255',
'email' => 'required|email|max:255|unique:users,email,' . Auth::user()->id,
'profile_alias' => 'sometimes|max:255|unique:users,profile_alias,' . Auth::user()->id
];
}
}

View File

@ -28,7 +28,7 @@ class StoreAlbumRequest extends FormRequest
case 'POST':
return [
'description' => '',
'name' => 'required|unique:albums|max:255',
'name' => 'required|album_path_unique|max:255',
'storage_id' => 'required|sometimes'
];
@ -38,7 +38,7 @@ class StoreAlbumRequest extends FormRequest
return [
'description' => 'sometimes',
'name' => 'required|sometimes|max:255|unique:albums,name,' . $albumId,
'name' => 'required|sometimes|max:255|album_path_unique:' . $albumId,
'storage_id' => 'required|sometimes'
];
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreLabelRequest 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()
{
return ['name' => 'required:max:255|unique:labels,name'];
}
}

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;
}

60
app/Label.php Normal file
View File

@ -0,0 +1,60 @@
<?php
namespace App;
use App\Helpers\DbHelper;
use App\Helpers\MiscHelper;
use Illuminate\Database\Eloquent\Model;
class Label extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name', 'url_alias'
];
public function generateAlias()
{
$this->url_alias = preg_replace('/[^a-z0-9\-]/', '-', strtolower($this->name));
}
public function photoCount()
{
return $this->photos()->whereIn('album_id', DbHelper::getAlbumIDsForCurrentUser())->count();
}
public function photos()
{
return $this->belongsToMany(Photo::class, 'photo_labels');
}
public function thumbnailUrl($thumbnailName)
{
$photo = $this->photos()
->whereIn('album_id', DbHelper::getAlbumIDsForCurrentUser())
->inRandomOrder()
->first();
if (!is_null($photo))
{
return $photo->album->getAlbumSource()->getUrlToPhoto($photo, $thumbnailName);
}
// Rotate standard images
$images = [
asset('themes/base/images/empty-album-1.jpg'),
asset('themes/base/images/empty-album-2.jpg'),
asset('themes/base/images/empty-album-3.jpg')
];
return $images[rand(0, count($images) - 1)];
}
public function url()
{
return route('viewLabel', $this->url_alias);
}
}

41
app/Mail/MailableBase.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace App\Mail;
use App\EmailLog;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\HtmlString;
abstract class MailableBase extends Mailable
{
public function buildEmailLog()
{
// Build the e-mail
$this->build();
// Get the current user for the ID
$currentUser = Auth::user();
// Build the body so we can use it as a string
$bodies = $this->buildView();
/** @var HtmlString $html */
$html = $bodies['html'];
/** @var HtmlString $text */
$text = $bodies['text'];
return new EmailLog([
'sender_user_id' => !is_null($currentUser) ? $currentUser->id : null,
'sender_name' => $this->from[0]['name'],
'sender_address' => $this->from[0]['address'],
'to_addresses' => json_encode($this->to),
'cc_addresses' => json_encode($this->cc),
'bcc_addresses' => json_encode($this->bcc),
'subject' => $this->subject,
'body_plain' => $text->toHtml(),
'body_html' => $html->toHtml()
]);
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Mail;
use App\Album;
use App\Facade\Theme;
use App\Photo;
use App\PhotoComment;
use App\User;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
class ModeratePhotoComment extends MailableBase
{
use Queueable, SerializesModels;
private $album;
private $comment;
private $photo;
private $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(User $user, Album $album, Photo $photo, PhotoComment $comment)
{
$this->user = $user;
$this->album = $album;
$this->photo = $photo;
$this->comment = $comment;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$subject = trans('email.moderate_photo_comment_subject', ['album_name' => $this->album->name]);
return $this
->subject($subject)
->markdown(Theme::viewName('email.moderate_photo_comment'))
->with([
'album' => $this->album,
'comment' => $this->comment,
'photo' => $this->photo,
'subject' => $subject,
'user' => $this->user
]);
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Mail;
use App\Album;
use App\Facade\Theme;
use App\Photo;
use App\PhotoComment;
use App\User;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
/**
* E-mail notification to the owner of an album that is sent when a new comment has been approved in their album.
* @package App\Mail
*/
class PhotoCommentApproved extends MailableBase
{
use Queueable, SerializesModels;
private $album;
private $comment;
private $photo;
private $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(User $user, Album $album, Photo $photo, PhotoComment $comment)
{
$this->user = $user;
$this->album = $album;
$this->photo = $photo;
$this->comment = $comment;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$subject = trans('email.photo_comment_approved_subject', ['album_name' => $this->album->name]);
return $this
->subject($subject)
->markdown(Theme::viewName('email.photo_comment_approved'))
->with([
'album' => $this->album,
'comment' => $this->comment,
'photo' => $this->photo,
'subject' => $subject,
'user' => $this->user
]);
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Mail;
use App\Album;
use App\Facade\Theme;
use App\Photo;
use App\PhotoComment;
use App\User;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
/**
* E-mail notification to the poster of a comment that is sent when it has been approved.
* @package App\Mail
*/
class PhotoCommentApprovedUser extends MailableBase
{
use Queueable, SerializesModels;
private $album;
private $comment;
private $photo;
private $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(User $user, Album $album, Photo $photo, PhotoComment $comment)
{
$this->user = $user;
$this->album = $album;
$this->photo = $photo;
$this->comment = $comment;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$subject = trans('email.photo_comment_approved_user_subject', ['album_name' => $this->album->name]);
return $this
->subject($subject)
->markdown(Theme::viewName('email.photo_comment_approved_user'))
->with([
'album' => $this->album,
'comment' => $this->comment,
'photo' => $this->photo,
'subject' => $subject,
'user' => $this->user
]);
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace App\Mail;
use App\Album;
use App\Facade\Theme;
use App\Photo;
use App\PhotoComment;
use App\User;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
/**
* E-mail notification to the poster of a comment that is sent when it has been approved.
* @package App\Mail
*/
class PhotoCommentRepliedTo extends MailableBase
{
use Queueable, SerializesModels;
private $album;
private $comment;
private $photo;
private $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(User $user, Album $album, Photo $photo, PhotoComment $comment)
{
$this->user = $user;
$this->album = $album;
$this->photo = $photo;
$this->comment = $comment;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$subject = trans('email.photo_comment_replied_to_subject', ['album_name' => $this->album->name]);
return $this
->subject($subject)
->markdown(Theme::viewName('email.photo_comment_replied_to'))
->with([
'album' => $this->album,
'comment' => $this->comment,
'photo' => $this->photo,
'subject' => $subject,
'user' => $this->user
]);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Mail;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class ResetMyPassword extends MailableBase
{
use Queueable, SerializesModels;
private $token;
private $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(User $user, $token)
{
$this->user = $user;
$this->token = $token;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$subject = trans('email.reset_my_password_subject', ['app_name' => UserConfig::get('app_name')]);
return $this
->subject($subject)
->markdown(Theme::viewName('email.reset_my_password'))
->with([
'subject' => $subject,
'token' => $this->token,
'user' => $this->user
]);
}
}

View File

@ -8,6 +8,9 @@ use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
/**
* NOTE: This does not need converting to a notification. It should always be sent immediately and not queued.
*/
class TestMailConfig extends Mailable
{
use Queueable, SerializesModels;
@ -37,7 +40,7 @@ class TestMailConfig extends Mailable
return $this
->subject($subject)
->view(Theme::viewName('email.test_email'))
->markdown(Theme::viewName('email.test_email'))
->with(['subject' => $subject]);
}
}

View File

@ -6,11 +6,9 @@ use App\Facade\Theme;
use App\Facade\UserConfig;
use App\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Contracts\Queue\ShouldQueue;
class UserActivationRequired extends Mailable
class UserActivationRequired extends MailableBase
{
use Queueable, SerializesModels;
@ -37,7 +35,7 @@ class UserActivationRequired extends Mailable
return $this
->subject($subject)
->view(Theme::viewName('email.user_activation_required'))
->markdown(Theme::viewName('email.user_activation_required'))
->with([
'subject' => $subject,
'user' => $this->user

View File

@ -0,0 +1,44 @@
<?php
namespace App\Mail;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\User;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
class UserChangeEmailRequired extends MailableBase
{
use Queueable, SerializesModels;
private $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(User $user)
{
$this->user = $user;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$subject = trans('email.change_email_required_subject', ['app_name' => UserConfig::get('app_name')]);
return $this
->subject($subject)
->markdown(Theme::viewName('email.user_change_email_required'))
->with([
'subject' => $subject,
'user' => $this->user
]);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Mail;
use App\Facade\Theme;
use App\Facade\UserConfig;
use App\User;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
class UserSelfActivated extends MailableBase
{
use Queueable, SerializesModels;
private $createdUser;
private $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(User $user, User $createdUser)
{
$this->user = $user;
$this->createdUser = $createdUser;
}
/**
* Build the message.
*
* @return $this
*/
public function build()
{
$subject = trans('email.user_self_activated_subject', ['app_name' => UserConfig::get('app_name')]);
return $this
->subject($subject)
->markdown(Theme::viewName('email.user_self_activated'))
->with([
'subject' => $subject,
'user' => $this->user,
'created_user' => $this->createdUser
]);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\ModelObservers;
use App\Label;
class LabelObserver
{
public function creating(Label $label)
{
$label->generateAlias();
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace App\Notifications;
use App\EmailLog;
use App\Facade\UserConfig;
use Illuminate\Mail\Mailable;
/**
* Enables a notification to use a Mailable to write to the database.
*/
trait DatabaseEmailNotification
{
/**
* Get the notification's delivery channels.
*
* @param mixed $notifiable
* @return array
*/
public function via($notifiable)
{
$drivers = [];
if (UserConfig::get('queue_emails'))
{
$drivers[] = QueueEmailDatabaseChannel::class;
}
else
{
$drivers[] = 'mail';
$drivers[] = SentEmailDatabaseChannel::class;
}
return $drivers;
}
/**
* Creates the EmailLog entry to write to the database.
* @param $notifiable
* @return EmailLog
*/
public function toEmailDatabase($notifiable)
{
return $this->toMail($notifiable)->buildEmailLog();
}
protected function setPropertiesOnMailable(Mailable $mailable, $notifiable)
{
// Set to and from properties accordingly
$mailable->from(UserConfig::get('sender_address'), UserConfig::get('sender_name'));
$mailable->to($notifiable->email, $notifiable->name);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Notifications;
use App\EmailLog;
class EmailDatabaseWriterChannelBase
{
protected function writeToTable(EmailLog $logEntry, $shouldQueue = false)
{
if ($shouldQueue)
{
$logEntry->queued_at = new \DateTime();
}
else
{
$logEntry->sent_at = new \DateTime();
}
$logEntry->save();
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Notifications;
use App\Album;
use App\Mail\MailableBase;
use App\Mail\ModeratePhotoComment as ModeratePhotoCommentMailable;
use App\Photo;
use App\PhotoComment;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class ModeratePhotoComment extends Notification
{
use Queueable;
use DatabaseEmailNotification;
private $album;
private $comment;
private $photo;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct(Album $album, Photo $photo, PhotoComment $comment)
{
$this->album = $album;
$this->photo = $photo;
$this->comment = $comment;
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage|MailableBase
*/
public function toMail($notifiable)
{
$mailable = new ModeratePhotoCommentMailable($notifiable, $this->album, $this->photo, $this->comment);
$this->setPropertiesOnMailable($mailable, $notifiable);
return $mailable;
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Notifications;
use App\Album;
use App\Mail\MailableBase;
use App\Mail\PhotoCommentApproved as PhotoCommentApprovedMailable;
use App\Photo;
use App\PhotoComment;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class PhotoCommentApproved extends Notification
{
use Queueable;
use DatabaseEmailNotification;
private $album;
private $comment;
private $photo;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct(Album $album, Photo $photo, PhotoComment $comment)
{
$this->album = $album;
$this->photo = $photo;
$this->comment = $comment;
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage|MailableBase
*/
public function toMail($notifiable)
{
$mailable = new PhotoCommentApprovedMailable($notifiable, $this->album, $this->photo, $this->comment);
$this->setPropertiesOnMailable($mailable, $notifiable);
return $mailable;
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Notifications;
use App\Album;
use App\Mail\MailableBase;
use App\Mail\PhotoCommentApprovedUser as PhotoCommentApprovedUserMailable;
use App\Photo;
use App\PhotoComment;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class PhotoCommentApprovedUser extends Notification
{
use Queueable;
use DatabaseEmailNotification;
private $album;
private $comment;
private $photo;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct(Album $album, Photo $photo, PhotoComment $comment)
{
$this->album = $album;
$this->photo = $photo;
$this->comment = $comment;
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage|MailableBase
*/
public function toMail($notifiable)
{
$mailable = new PhotoCommentApprovedUserMailable($notifiable, $this->album, $this->photo, $this->comment);
$this->setPropertiesOnMailable($mailable, $notifiable);
return $mailable;
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Notifications;
use App\Album;
use App\Mail\MailableBase;
use App\Mail\PhotoCommentRepliedTo as PhotoCommentRepliedToMailable;
use App\Photo;
use App\PhotoComment;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class PhotoCommentRepliedTo extends Notification
{
use Queueable;
use DatabaseEmailNotification;
private $album;
private $comment;
private $photo;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct(Album $album, Photo $photo, PhotoComment $comment)
{
$this->album = $album;
$this->photo = $photo;
$this->comment = $comment;
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage|MailableBase
*/
public function toMail($notifiable)
{
$mailable = new PhotoCommentRepliedToMailable($notifiable, $this->album, $this->photo, $this->comment);
$this->setPropertiesOnMailable($mailable, $notifiable);
return $mailable;
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Notifications;
use App\EmailLog;
use Illuminate\Notifications\Notification;
class QueueEmailDatabaseChannel extends EmailDatabaseWriterChannelBase
{
/**
* Send the given notification.
*
* @param mixed $notifiable
* @param \Illuminate\Notifications\Notification $notification
* @return void
*/
public function send($notifiable, Notification $notification)
{
/** @var EmailLog $logEntry */
$logEntry = $notification->toEmailDatabase($notifiable);
$this->writeToTable($logEntry, true);
}
}

View File

@ -0,0 +1,47 @@
<?php
namespace App\Notifications;
use App\Mail\MailableBase;
use App\Mail\ResetMyPassword;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class ResetPassword extends Notification
{
use Queueable;
use DatabaseEmailNotification;
/**
* The password reset token.
*
* @var string
*/
public $token;
/**
* Create a notification instance.
*
* @param string $token
* @return void
*/
public function __construct($token)
{
$this->token = $token;
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage|MailableBase
*/
public function toMail($notifiable)
{
$mailable = new ResetMyPassword($notifiable, $this->token);
$this->setPropertiesOnMailable($mailable, $notifiable);
return $mailable;
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Notifications;
use App\EmailLog;
use Illuminate\Notifications\Notification;
class SentEmailDatabaseChannel extends EmailDatabaseWriterChannelBase
{
/**
* Send the given notification.
*
* @param mixed $notifiable
* @param \Illuminate\Notifications\Notification $notification
* @return void
*/
public function send($notifiable, Notification $notification)
{
/** @var EmailLog $logEntry */
$logEntry = $notification->toEmailDatabase($notifiable);
$this->writeToTable($logEntry, false);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Notifications;
use App\Mail\MailableBase;
use App\Mail\UserActivationRequired as UserActivationRequiredMailable;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class UserActivationRequired extends Notification
{
use Queueable;
use DatabaseEmailNotification;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage|MailableBase
*/
public function toMail($notifiable)
{
$mailable = new UserActivationRequiredMailable($notifiable);
$this->setPropertiesOnMailable($mailable, $notifiable);
return $mailable;
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Notifications;
use App\Mail\MailableBase;
use App\Mail\UserChangeEmailRequired as UserChangeEmailRequiredMailable;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class UserChangeEmailRequired extends Notification
{
use Queueable;
use DatabaseEmailNotification;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct()
{
//
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage|MailableBase
*/
public function toMail($notifiable)
{
$mailable = new UserChangeEmailRequiredMailable($notifiable);
$this->setPropertiesOnMailable($mailable, $notifiable);
return $mailable;
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Notifications;
use App\Mail\MailableBase;
use App\Mail\UserSelfActivated as UserSelfActivatedMailable;
use App\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class UserSelfActivated extends Notification
{
use Queueable;
use DatabaseEmailNotification;
private $createdUser;
/**
* Create a new notification instance.
*
* @return void
*/
public function __construct(User $createdUser)
{
$this->createdUser = $createdUser;
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
* @return \Illuminate\Notifications\Messages\MailMessage|MailableBase
*/
public function toMail($notifiable)
{
$mailable = new UserSelfActivatedMailable($notifiable, $this->createdUser);
$this->setPropertiesOnMailable($mailable, $notifiable);
return $mailable;
}
}

View File

@ -2,6 +2,7 @@
namespace App;
use App\AlbumSources\IAlbumSource;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\Notifiable;
@ -31,6 +32,10 @@ class Photo extends Model
'width',
'height',
'is_analysed',
'raw_exif_data',
'aperture_fnumber',
'iso_number',
'shutter_speed',
'created_at',
'updated_at'
];
@ -48,11 +53,72 @@ class Photo extends Model
return $this->belongsTo(Album::class);
}
public function comments()
{
return $this->hasMany(PhotoComment::class);
}
public function exifUrl()
{
return route('viewExifData', [
'albumUrlAlias' => $this->album->url_path,
'photoFilename' => $this->storage_file_name
]);
}
public function labelIDs()
{
$labelIDs = [];
foreach ($this->labels()->orderBy('name')->get() as $label)
{
$labelIDs[] = $label->id;
}
return implode(',', $labelIDs);
}
public function labels()
{
return $this->belongsToMany(Label::class, 'photo_labels');
}
public function moderateCommentUrl($commentID = -1)
{
return route('moderatePhotoComment', [
'albumUrlAlias' => $this->album->url_path,
'photoFilename' => $this->storage_file_name,
'commentID' => $commentID
]);
}
public function postCommentUrl()
{
return route('postPhotoComment', [
'albumUrlAlias' => $this->album->url_path,
'photoFilename' => $this->storage_file_name
]);
}
public function replyToCommentFormUrl($commentID = -1)
{
return route('replyPhotoComment', [
'albumUrlAlias' => $this->album->url_path,
'photoFilename' => $this->storage_file_name,
'commentID' => $commentID
]);
}
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;

91
app/PhotoComment.php Normal file
View File

@ -0,0 +1,91 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class PhotoComment extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'name',
'email',
'comment'
];
public function authorDisplayName()
{
return is_null($this->createdBy) ? $this->name : $this->createdBy->name;
}
public function authorEmail()
{
return is_null($this->createdBy) ? $this->email : $this->createdBy->email;
}
public function children()
{
return $this->hasMany(PhotoComment::class, 'parent_comment_id');
}
public function createdBy()
{
return $this->belongsTo(User::class, 'created_user_id');
}
public function depth()
{
$depth = 0;
$current = $this;
while (!is_null($current->parent))
{
$current = $current->parent;
$depth++;
}
return $depth;
}
public function isApproved()
{
return (!is_null($this->approved_at) && is_null($this->rejected_at));
}
public function isModerated()
{
return (!is_null($this->approved_at) || !is_null($this->rejected_at));
}
public function isRejected()
{
return (!is_null($this->rejected_at) && is_null($this->approved_at));
}
public function parent()
{
return $this->belongsTo(PhotoComment::class, 'parent_comment_id');
}
public function photo()
{
return $this->belongsTo(Photo::class);
}
public function textAsHtml()
{
$start = '<p>';
$end = '</p>';
$isHtml = (
strlen($this->comment) > (strlen($start) + strlen($end)) && // text contains both our start + end string
strtolower(substr($this->comment, 0, strlen($start))) == strtolower($start) && // text starts with our start string
strtolower(substr($this->comment, strlen($this->comment) - strlen($end))) == strtolower($end) // text ends with our end string
);
return $isHtml ? $this->comment : sprintf('<p>%s</p>', $this->comment);
}
}

View File

@ -3,7 +3,9 @@
namespace App\Policies;
use App\Album;
use App\Facade\UserConfig;
use App\Group;
use App\Helpers\PermissionsHelper;
use App\Permission;
use App\User;
use Illuminate\Auth\Access\HandlesAuthorization;
@ -45,13 +47,18 @@ class AlbumPolicy
return true;
}
// Get the edit permission
$permission = Permission::where([
'section' => 'album',
'description' => 'change-photo-metadata'
])->first();
return $this->userHasPermission($user, $album, 'change-photo-metadata');
}
return $this->userHasPermission($user, $album, $permission);
public function delete(User $user, Album $album)
{
if ($user->id == $album->user_id)
{
// The album's owner and can do everything
return true;
}
return $this->userHasPermission($user, $album, 'delete');
}
public function deletePhotos(User $user, Album $album)
@ -62,13 +69,7 @@ class AlbumPolicy
return true;
}
// Get the edit permission
$permission = Permission::where([
'section' => 'album',
'description' => 'delete-photos'
])->first();
return $this->userHasPermission($user, $album, $permission);
return $this->userHasPermission($user, $album, 'delete-photos');
}
public function edit(User $user, Album $album)
@ -79,13 +80,7 @@ class AlbumPolicy
return true;
}
// Get the edit permission
$permission = Permission::where([
'section' => 'album',
'description' => 'edit'
])->first();
return $this->userHasPermission($user, $album, $permission);
return $this->userHasPermission($user, $album, 'edit');
}
public function manipulatePhotos(User $user, Album $album)
@ -96,13 +91,35 @@ class AlbumPolicy
return true;
}
// Get the edit permission
$permission = Permission::where([
'section' => 'album',
'description' => 'manipulate-photos'
])->first();
return $this->userHasPermission($user, $album, 'manipulate-photos');
}
return $this->userHasPermission($user, $album, $permission);
public function moderateComments(User $user, Album $album)
{
if ($user->id == $album->user_id)
{
// The album's owner and can do everything
return true;
}
return $this->userHasPermission($user, $album, 'moderate-comments');
}
public function postComment(User $user, Album $album)
{
if ($user->id == $album->user_id)
{
// The album's owner and can do everything
return true;
}
// Don't allow comments to be posted if anonymous user, and anonymous comments disabled
if ($user->isAnonymous() && !UserConfig::get('allow_photo_comments_anonymous'))
{
return false;
}
return $this->userHasPermission($user, $album, 'post-comment');
}
public function uploadPhotos(User $user, Album $album)
@ -113,13 +130,7 @@ class AlbumPolicy
return true;
}
// Get the edit permission
$permission = Permission::where([
'section' => 'album',
'description' => 'upload-photos'
])->first();
return $this->userHasPermission($user, $album, $permission);
return $this->userHasPermission($user, $album, 'upload-photos');
}
public function view(User $user, Album $album)
@ -130,53 +141,12 @@ class AlbumPolicy
return true;
}
// Get the edit permission
$permission = Permission::where([
'section' => 'album',
'description' => 'view'
])->first();
return $this->userHasPermission($user, $album, $permission);
return $this->userHasPermission($user, $album, 'view');
}
private function userHasPermission(User $user, Album $album, Permission $permission)
private function userHasPermission(User $user, Album $album, $permission)
{
if ($user->isAnonymous())
{
$query = Album::query()->join('album_anonymous_permissions', 'album_anonymous_permissions.album_id', '=', 'albums.id')
->join('permissions', 'permissions.id', '=', 'album_anonymous_permissions.permission_id')
->where('permissions.id', $permission->id);
return $query->count() > 0;
}
// If any of the user's groups are granted the permission
/** @var Group $group */
foreach ($user->groups as $group)
{
$groupPermission = $album->groupPermissions()->where([
'group_id' => $group->id,
'permission_id' => $permission->id
])->first();
if (!is_null($groupPermission))
{
return true;
}
}
// If the user is directly granted the permission
$userPermission = $album->userPermissions()->where([
'user_id' => $user->id,
'permission_id' => $permission->id
])->first();
if (!is_null($userPermission))
{
return true;
}
// Nope, no permission
return false;
$helper = new PermissionsHelper();
return $helper->userCan_Album($album, $user, $permission);
}
}

View File

@ -61,4 +61,37 @@ class PhotoPolicy
return $user->can('manipulate-photos', $photo->album);
}
public function moderateComments(User $user, Photo $photo)
{
if ($user->id == $photo->user_id)
{
// The photo's owner can do everything
return true;
}
return $user->can('moderate-comments', $photo->album);
}
public function postComment(User $user, Photo $photo)
{
if ($user->id == $photo->user_id)
{
// The photo's owner can do everything
return true;
}
return $user->can('post-comment', $photo->album);
}
public function view(User $user, Photo $photo)
{
if ($user->id == $photo->user_id)
{
// The photo's owner can do everything
return true;
}
return $user->can('view', $photo->album);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Policies;
use App\Facade\UserConfig;
use App\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class UserPolicy
{
use HandlesAuthorization;
/**
* Create a new policy instance.
*
* @return void
*/
public function __construct()
{
//
}
public function before($user, $ability)
{
if (!UserConfig::get('social_user_profiles'))
{
// Social profiles not enabled
return false;
}
}
public function view(User $user, User $userBeingAccessed)
{
return $userBeingAccessed->enable_profile_page;
}
}

View File

@ -8,7 +8,9 @@ use App\Helpers\ImageHelper;
use App\Helpers\MiscHelper;
use App\Helpers\ThemeHelper;
use App\Helpers\ValidationHelper;
use App\Label;
use App\ModelObservers\AlbumObserver;
use App\ModelObservers\LabelObserver;
use Illuminate\Database\QueryException;
use Illuminate\Mail\Mailer;
use Illuminate\Pagination\LengthAwarePaginator;
@ -36,6 +38,10 @@ class AppServiceProvider extends ServiceProvider
{
return $themeHelper;
});
$this->app->singleton('misc', function ($app)
{
return new MiscHelper();
});
$this->app->singleton('user_config', function ($app)
{
return new ConfigHelper();
@ -44,9 +50,11 @@ class AppServiceProvider extends ServiceProvider
Validator::extend('is_dir', (ValidationHelper::class . '@directoryExists'));
Validator::extend('dir_empty', (ValidationHelper::class . '@isDirectoryEmpty'));
Validator::extend('is_writeable', (ValidationHelper::class . '@isPathWriteable'));
Validator::extend('album_path_unique', (ValidationHelper::class . '@albumPathUnique'));
// Model observers
Album::observe(AlbumObserver::class);
Label::observe(LabelObserver::class);
// Configure our default pager
if (MiscHelper::isAppInstalled())

View File

@ -9,10 +9,10 @@ use App\Permission;
use App\Photo;
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
{
@ -28,7 +28,8 @@ class AuthServiceProvider extends ServiceProvider
*/
protected $policies = [
Album::class => AlbumPolicy::class,
Photo::class => PhotoPolicy::class
Photo::class => PhotoPolicy::class,
User::class => UserPolicy::class
];
/**
@ -52,10 +53,22 @@ class AuthServiceProvider extends ServiceProvider
{
return $this->userHasAdminPermission($user, 'manage-albums');
});
Gate::define('admin:manage-comments', function ($user)
{
return $this->userHasAdminPermission($user, 'manage-comments');
});
Gate::define('admin:manage-groups', function ($user)
{
return $this->userHasAdminPermission($user, 'manage-groups');
});
Gate::define('admin:manage-labels', function ($user)
{
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');
@ -74,6 +87,20 @@ class AuthServiceProvider extends ServiceProvider
return ($user->id == $photo->user_id);
});
Gate::define('photo.quick_upload', function($user)
{
$can = true;
$can &= $this->userHasAdminPermission($user, 'access');
$can &= $this->userHasAdminPermission($user, 'manage-albums');
return $can;
});
Gate::define('statistics.public-access', function ($user)
{
return UserConfig::get('public_statistics') || !$user->isAnonymous();
});
}
private function userHasAdminPermission(User $user, $permissionDescription)

37
app/QueueItem.php Normal file
View File

@ -0,0 +1,37 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class QueueItem extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'batch_reference',
'action_type',
'album_id',
'photo_id',
'queued_at',
'user_id'
];
public function album()
{
return $this->belongsTo(Album::class);
}
public function photo()
{
return $this->belongsTo(Photo::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,400 @@
<?php
namespace App\Services;
use App\Exceptions\BackblazeRetryException;
use Illuminate\Support\Facades\Log;
class BackblazeB2Service
{
/**
* The individual URL for the account to use to access the API
* @var string
*/
private $accountApiUrl;
/**
* ID of the account in Backblaze B2.
* @var string
*/
private $accountID;
/**
* The base URL for public access to the account's files.
* @var string
*/
private $downloadUrl;
/**
* Authorisation header for authenticating to the API.
* @var string
*/
private $authHeader;
/**
* Authorisation token for accessing the API post-authentication.
* @var string
*/
private $authToken;
/**
* ID of the bucket.
* @var string
*/
private $bucketId;
/**
* Type of the bucket.
* @var integer
*/
private $bucketType;
/**
* Configuration related to the Backblaze B2 service.
* @var \Illuminate\Config\Repository|mixed
*/
private $config;
/**
* Current file upload token.
* @var string
*/
private $uploadAuthToken;
/**
* Current upload URL.
* @var string
*/
private $uploadUrl;
public function __construct()
{
$this->config = config('services.backblaze_b2');
}
public function authorizeAccount($force = false)
{
if (empty($this->authToken) || $force)
{
$result = $this->sendRequest($this->config['auth_url']);
if (!isset($result->authorizationToken))
{
throw new \Exception('Authorisation to Backblaze failed. Is the API key correct?');
}
$this->authToken = $result->authorizationToken;
$this->accountApiUrl = $result->apiUrl;
$this->accountID = $result->accountId;
$this->downloadUrl = $result->downloadUrl;
}
}
public function deleteFile($fileID, $fileName)
{
$this->sendRequest(
sprintf('%s/b2api/v2/b2_delete_file_version', $this->accountApiUrl),
'POST',
[
'fileId' => $fileID,
'fileName' => $fileName
]
);
}
public function downloadFile($fileID)
{
return $this->sendRequest(
sprintf('%s/b2api/v2/b2_download_file_by_id?fileId=%s', $this->accountApiUrl, urlencode($fileID)),
'GET',
null,
[
'http_headers' => [
sprintf('Authorization: %s', $this->authToken)
],
'response_body_is_json' => false
]
);
}
public function getBucketType()
{
return $this->bucketType;
}
public function getDownloadAuthToken()
{
$result = $this->sendRequest(
sprintf('%s/b2api/v2/b2_get_download_authorization', $this->accountApiUrl),
'POST',
[
'bucketId' => $this->bucketId,
'validDurationInSeconds' => intval($this->config['download_token_lifetime']),
'fileNamePrefix' => ''
]
);
return $result->authorizationToken;
}
public function getDownloadUrl()
{
return $this->downloadUrl;
}
public function setBucketName($bucketName)
{
$bucketDetails = $this->getBucketDetailsFromName($bucketName);
$this->bucketId = $bucketDetails->bucketId;
$this->bucketType = $bucketDetails->bucketType;
}
public function setCredentials($applicationKeyID, $applicationKey)
{
$this->authHeader = sprintf('%s:%s', $applicationKeyID, $applicationKey);
}
public function uploadFile($pathToFileToUpload, $pathToStorage)
{
// Get a URL to upload our file to
list($uploadUrl, $authorizationToken) = $this->getUploadUrl();
if (empty($uploadUrl) || empty($authorizationToken))
{
throw new \Exception('No upload URL/authorization token returned from Backblaze B2.');
}
$exponentialBackoff = 1;
$numberOfRetries = 5; // this effectively gives us 31 seconds of retries (1+2+4+8+16)
$numberOfTimesTried = 0;
while ($numberOfTimesTried < $numberOfRetries)
{
try
{
return $this->uploadFileReal($pathToFileToUpload, $pathToStorage, $uploadUrl, $authorizationToken);
}
catch (BackblazeRetryException $ex)
{
sleep($exponentialBackoff);
// Get a new upload token
$this->uploadAuthToken = null;
$this->uploadUrl = null;
list($uploadUrl, $authorizationToken) = $this->getUploadUrl();
// Keep backing off
$exponentialBackoff *= $exponentialBackoff;
$numberOfTimesTried++;
}
}
}
private function uploadFileReal($pathToFileToUpload, $pathToStorage, $uploadUrl, $authorizationToken)
{
$fileSize = filesize($pathToFileToUpload);
$handle = fopen($pathToFileToUpload, 'r');
$fileContents = fread($handle, $fileSize);
fclose($handle);
$fileContentsSha1 = sha1_file($pathToFileToUpload);
$httpHeaders = [
sprintf('Authorization: %s', $authorizationToken),
'Content-Type: b2/x-auto',
sprintf('X-Bz-Content-Sha1: %s', $fileContentsSha1),
sprintf('X-Bz-File-Name: %s', urlencode($pathToStorage))
];
$result = $this->sendRequestReal(
$uploadUrl,
'POST',
$fileContents,
[
'http_headers' => $httpHeaders,
'post_body_is_json' => false
]
);
return $result->fileId;
}
private function getBucketDetailsFromName($bucketName)
{
$result = $this->sendRequest(
sprintf('%s/b2api/v2/b2_list_buckets', $this->accountApiUrl),
'POST',
[
'accountId' => $this->accountID,
'bucketName' => $bucketName
]
);
if (isset($result->buckets) && is_array($result->buckets) && count($result->buckets) >= 1)
{
return $result->buckets[0];
}
throw new \Exception(sprintf('The bucket \'%s\' was not found or your API key does not have access.', $bucketName));
}
private function getUploadUrl($alwaysGetNewToken = false)
{
if (is_null($this->uploadAuthToken) || $alwaysGetNewToken)
{
$result = $this->sendRequest(
sprintf('%s/b2api/v2/b2_get_upload_url', $this->accountApiUrl),
'POST',
['bucketId' => $this->bucketId]
);
$this->uploadAuthToken = $result->authorizationToken;
$this->uploadUrl = $result->uploadUrl;
}
return [$this->uploadUrl, $this->uploadAuthToken];
}
private function getBasicHttpClient($url, $method = 'GET', array $httpHeaders = [])
{
$httpHeaders = array_merge(
[
'Accept: application/json'
],
$httpHeaders
);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, $httpHeaders);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
switch (strtoupper($method))
{
case 'GET':
curl_setopt($ch, CURLOPT_HTTPGET, true);
break;
case 'POST':
curl_setopt($ch, CURLOPT_POST, true);
break;
}
return $ch;
}
private function sendRequest($url, $method = 'GET', $postData = null, array $postOptions = [])
{
$exponentialBackoff = 1;
$numberOfRetries = 5; // this effectively gives us 31 seconds of retries (1+2+4+8+16)
$numberOfTimesTried = 0;
while ($numberOfTimesTried < $numberOfRetries)
{
try
{
return $this->sendRequestReal($url, $method, $postData, $postOptions);
}
catch (BackblazeRetryException $ex)
{
// Clear the upload token if requested
if (isset($postOptions['clear_upload_token_on_retry']) && $postOptions['clear_upload_token_on_retry'])
{
$this->uploadAuthToken = null;
$this->uploadUrl = null;
}
// Keep backing off
sleep($exponentialBackoff);
$exponentialBackoff *= $exponentialBackoff;
$numberOfTimesTried++;
}
}
}
private function sendRequestReal($url, $method = 'GET', $postData = null, array $postOptions = [])
{
$postOptions = array_merge(
[
'authorization_token' => null,
'http_headers' => [],
'post_body_is_json' => true,
'response_body_is_json' => true
],
$postOptions
);
$httpHeaders = $postOptions['http_headers'];
// Some methods may need to override the authorization token used
if (empty($postOptions['authorization_token']))
{
// No override - work out which auth token to use
if (is_null($this->authToken))
{
// No auth token yet, use username/password
$httpHeaders[] = sprintf('Authorization: Basic %s', base64_encode($this->authHeader));
}
else
{
// Use the auth token we have
$httpHeaders[] = sprintf('Authorization: %s', $this->authToken);
}
}
else
{
// Override - use the auth token specified
$httpHeaders[] = sprintf('Authorization: %s', $postOptions['authorization_token']);
}
$ch = $this->getBasicHttpClient($url, $method, $httpHeaders);
if (!is_null($postData))
{
if ($postOptions['post_body_is_json'])
{
$postData = json_encode($postData);
}
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
}
Log::info(sprintf('%s: %s', strtoupper($method), $url));
Log::debug('HTTP headers:', $httpHeaders);
// Only log a post body if we have one and it's in JSON format (i.e. not a file upload)
if (!is_null($postData) && $postOptions['post_body_is_json'])
{
Log::debug($postData);
}
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
Log::info(sprintf('Received HTTP code %d', $httpCode));
// Only log a result if we have one and it's in JSON format (i.e. not a file download)
if (!is_null($result) && $result !== false && $postOptions['response_body_is_json'])
{
Log::debug($result);
}
// According to the Backblaze B2 Protocol, if we get a 500/503, we should retry the request
if ($httpCode == 500 || $httpCode == 503)
{
throw new BackblazeRetryException(
$httpCode,
new \Exception(sprintf('Exception from Backblaze B2: %s', $result))
);
}
else if ($httpCode != 200 && $httpCode != 304)
{
throw new \Exception(sprintf('Exception from Backblaze B2: %s', $result));
}
curl_close($ch);
return $postOptions['response_body_is_json']
? json_decode($result)
: $result;
}
}

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

@ -0,0 +1,164 @@
<?php
namespace App\Services;
class GiteaService
{
private $cacheFile = null;
private $config = [];
private $currentVersionNumber;
public function __construct(array $config = null, $currentVersionNumber = null)
{
// 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()
{
$cacheData = null;
if ($this->doesCacheExist())
{
// Get the etag from the cache
$cacheData = $this->getCacheData();
}
else
{
// Lookup and store the version information
$statusCode = -1;
$result = $this->getReleasesFromGitea($statusCode);
if ($statusCode == 200)
{
$releases = json_decode($result[1]);
$latestRelease = null;
foreach ($releases as $release)
{
if (is_null($latestRelease) || version_compare($release->tag_name, $latestRelease->tag_name) > 0)
{
$latestRelease = $release;
}
}
$cacheData = $this->setCacheData($latestRelease);
}
}
// GitHub compatibility
$cacheData->html_url = sprintf($this->config['releases_url'], $this->config['repo_owner'], $this->config['repo_name']);
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);
if ($exists)
{
// Check modified time on the file
$stat = stat($this->cacheFile);
$diff = time() - $stat['mtime'];
if ($diff > $this->config['cache_time_seconds'])
{
$exists = false;
}
}
return $exists;
}
private function getCacheData()
{
return json_decode(file_get_contents($this->cacheFile));
}
private function getReleasesFromGitea(&$statusCode)
{
$httpHeaders = [
sprintf('User-Agent: aheathershaw/blue-twilight (v%s)', $this->currentVersionNumber)
];
if (isset($this->config['api_key']) && !empty($this->config['api_key']))
{
$httpHeaders[] = sprintf('Authorization: %s', $this->config['api_key']);
}
$apiUrl = sprintf('%s/repos/%s/%s/releases', $this->config['api_url'], $this->config['repo_owner'], $this->config['repo_name']);
$ch = curl_init($apiUrl);
curl_setopt($ch, CURLOPT_HTTPHEADER, $httpHeaders);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
if ($result === false)
{
throw new \Exception(sprintf('Error from Gitea: %s', curl_error($ch)));
}
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
return explode("\r\n\r\n", $result, 2);
}
private function setCacheData($data)
{
if (!is_null($this->cacheFile))
{
file_put_contents($this->cacheFile, json_encode(get_object_vars($data)));
}
return $data;
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Services;
class GithubService
{
private $cacheFile = null;
private $config = [];
public function __construct()
{
$this->config = config('services.github');
$this->cacheFile = storage_path('app/github_cache.txt');
}
public function checkForLatestRelease()
{
$releaseInfo = [];
$etag = '';
if ($this->doesCacheExist())
{
// Get the etag from the cache
$cacheData = $this->getCacheData();
$etag = $cacheData->latest_release->etag;
$releaseInfo = $cacheData->latest_release->release_info;
}
// Lookup and store the version information
$statusCode = -1;
$result = $this->getLatestReleaseFromGithub($etag, $statusCode);
if ($statusCode == 200)
{
// Store the etag (in HTTP headers) for future reference
$matches = [];
$etag = '';
if (preg_match('/^etag: "(.+)"/mi', $result[0], $matches))
{
$etag = $matches[1];
}
$releaseInfo = json_decode($result[1]);
}
if (!empty($etag))
{
$this->setCacheData([
'latest_release' => [
'etag' => $etag,
'release_info' => $releaseInfo
]
]);
}
return $releaseInfo;
}
private function doesCacheExist()
{
return file_exists($this->cacheFile);
}
private function getCacheData()
{
return json_decode(file_get_contents($this->cacheFile));
}
private function getLatestReleaseFromGithub($etag = '', &$statusCode)
{
$httpHeaders = [
sprintf('User-Agent: pandy06269/blue-twilight (v%s)', config('app.version'))
];
if (!empty($etag))
{
$httpHeaders[] = sprintf('If-None-Match: "%s"', $etag);
}
$ch = curl_init($this->config['latest_release_url']);
curl_setopt($ch, CURLOPT_HTTPHEADER, $httpHeaders);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
if ($result === false)
{
throw new \Exception(sprintf('Error from Github: %s', curl_error($ch)));
}
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
return explode("\r\n\r\n", $result, 2);
}
private function setCacheData(array $data)
{
file_put_contents($this->cacheFile, json_encode($data));
}
}

View File

@ -4,15 +4,16 @@ namespace App\Services;
use App\Album;
use App\AlbumSources\IAlbumSource;
use App\Helpers\FileHelper;
use App\AlbumSources\IAnalysisQueueSource;
use App\Helpers\AnalysisQueueHelper;
use App\Helpers\ImageHelper;
use App\Helpers\MiscHelper;
use App\Helpers\ThemeHelper;
use App\Photo;
use Symfony\Component\HttpFoundation\File\File;
class PhotoService
{
const METADATA_VERSION = 1;
const METADATA_VERSION = 2;
/**
* @var Album
@ -49,10 +50,12 @@ class PhotoService
$this->themeHelper = new ThemeHelper();
}
public function analyse($queueToken)
public function analyse($queueToken, $isReanalyse = false)
{
$queuePath = FileHelper::getQueuePath($queueToken);
$photoFile = join(DIRECTORY_SEPARATOR, [$queuePath, $this->photo->storage_file_name]);
/** @var IAnalysisQueueSource $analysisQueueStorage */
$analysisQueueStorage = AnalysisQueueHelper::getStorageQueueSource();
$photoFile = $analysisQueueStorage->fetchItemFromAnalysisQueue($queueToken, $this->photo->storage_file_name);
try
{
@ -68,12 +71,23 @@ class PhotoService
$this->photo->mime_type = $imageInfo['mime'];
// Read the Exif data
$exifData = @exif_read_data($photoFile);
$isExifDataFound = ($exifData !== false && is_array($exifData));
if (empty($this->photo->raw_exif_data))
{
$exifData = @exif_read_data($photoFile);
$isExifDataFound = ($exifData !== false && is_array($exifData));
$this->photo->raw_exif_data = $isExifDataFound ? base64_encode(serialize($exifData)) : '';
}
else
{
$exifData = unserialize(base64_decode($this->photo->raw_exif_data));
$isExifDataFound = ($exifData !== false && is_array($exifData));
}
$angleToRotate = 0;
// If Exif data contains an Orientation, ensure we rotate the original image as such
if ($isExifDataFound && isset($exifData['Orientation']))
// If Exif data contains an Orientation, ensure we rotate the original image as such (providing we don't
// currently have a metadata version - i.e. it hasn't been read and rotated already before)
if ($isExifDataFound && isset($exifData['Orientation']) && !$isReanalyse)
{
switch ($exifData['Orientation'])
{
@ -107,10 +121,14 @@ class PhotoService
if ($isExifDataFound)
{
$this->photo->metadata_version = self::METADATA_VERSION;
$this->photo->taken_at = $this->metadataDateTime($exifData);
$this->photo->camera_make = $this->metadataCameraMake($exifData);
$this->photo->camera_model = $this->metadataCameraModel($exifData);
$this->photo->camera_software = $this->metadataCameraSoftware($exifData);
$this->photo->taken_at = $this->metadataDateTime($exifData, $this->photo->taken_at);
$this->photo->camera_make = $this->metadataCameraMake($exifData, $this->photo->camera_make);
$this->photo->camera_model = $this->metadataCameraModel($exifData, $this->photo->camera_model);
$this->photo->camera_software = $this->metadataCameraSoftware($exifData, $this->photo->camera_software);
$this->photo->aperture_fnumber = $this->metadataApertureFNumber($exifData, $this->photo->aperture_fnumber);
$this->photo->iso_number = $this->metadataIsoNumber($exifData, $this->photo->iso_number);
$this->photo->focal_length = $this->metadataFocalLength($exifData, $this->photo->focal_length);
$this->photo->shutter_speed = $this->metadataExposureTime($exifData, $this->photo->shutter_speed);
}
$this->photo->is_analysed = true;
@ -127,10 +145,11 @@ class PhotoService
}
finally
{
// Remove the temporary file
@unlink($photoFile);
// If the queue directory is now empty, get rid of it
FileHelper::deleteIfEmpty($queuePath);
// Remove from the storage
$analysisQueueStorage->deleteItemFromAnalysisQueue($queueToken, $this->photo->storage_file_name);
}
}
@ -179,6 +198,24 @@ class PhotoService
$this->photo->delete();
}
public function downloadOriginalToFolder($folderPath)
{
$photoPath = join(DIRECTORY_SEPARATOR, [$folderPath, $this->photo->storage_file_name]);
$photoHandle = fopen($photoPath, 'w');
$stream = $this->albumSource->fetchPhotoContent($this->photo);
$stream->rewind();
while (!$stream->eof())
{
fwrite($photoHandle, $stream->read(4096));
}
fflush($photoHandle);
fclose($photoHandle);
$stream->close();
return $photoPath;
}
public function flip($horizontal, $vertical)
{
// First export the original photo from the storage provider
@ -264,6 +301,22 @@ class PhotoService
@unlink($photoPath);
}
private function calculateValueFromFraction($input)
{
$split = explode('/', $input);
if (count($split) != 2)
{
return $split;
}
$numerator = intval($split[0]);
$denominator = intval($split[1]);
return $denominator == 0
? 0
: ($numerator / $denominator);
}
private function downloadToTemporaryFolder()
{
$photoPath = tempnam(sys_get_temp_dir(), 'BlueTwilight_');
@ -271,7 +324,7 @@ class PhotoService
$stream = $this->albumSource->fetchPhotoContent($this->photo);
$stream->rewind();
while (!$stream->feof())
while (!$stream->eof())
{
fwrite($photoHandle, $stream->read(4096));
}
@ -282,37 +335,54 @@ class PhotoService
return $photoPath;
}
private function metadataCameraMake(array $exifData)
private function metadataApertureFNumber(array $exifData, $originalValue = null)
{
if (isset($exifData['FNumber']))
{
$value = $this->calculateValueFromFraction($exifData['FNumber']);
if (intval($value) === $value)
{
return sprintf('f/%d', $value);
}
return sprintf('f/%0.1f', $value);
}
return $originalValue;
}
private function metadataCameraMake(array $exifData, $originalValue = null)
{
if (isset($exifData['Make']))
{
return $exifData['Make'];
}
return null;
return $originalValue;
}
private function metadataCameraModel(array $exifData)
private function metadataCameraModel(array $exifData, $originalValue = null)
{
if (isset($exifData['Model']))
{
return $exifData['Model'];
}
return null;
return $originalValue;
}
private function metadataCameraSoftware(array $exifData)
private function metadataCameraSoftware(array $exifData, $originalValue = null)
{
if (isset($exifData['Software']))
{
return $exifData['Software'];
}
return null;
return $originalValue;
}
private function metadataDateTime(array $exifData)
private function metadataDateTime(array $exifData, $originalValue = null)
{
$dateTime = null;
if (isset($exifData['DateTimeOriginal']))
@ -324,11 +394,43 @@ class PhotoService
$dateTime = $exifData['DateTime'];
}
if (!is_null($dateTime))
if (is_null($dateTime))
{
$dateTime = preg_replace('/^([\d]{4}):([\d]{2}):([\d]{2})/', '$1-$2-$3', $dateTime);
return $originalValue;
}
return $dateTime;
return preg_replace('/^([\d]{4}):([\d]{2}):([\d]{2})/', '$1-$2-$3', $dateTime);
}
private function metadataExposureTime(array $exifData, $originalValue = null)
{
if (isset($exifData['ExposureTime']))
{
$decimal = $this->calculateValueFromFraction($exifData['ExposureTime']);
$fraction = MiscHelper::decimalToFraction($decimal);
return sprintf('%d/%d', $fraction[0], $fraction[1]);
}
return $originalValue;
}
private function metadataFocalLength(array $exifData, $originalValue = null)
{
if (isset($exifData['FocalLength']))
{
return $this->calculateValueFromFraction($exifData['FocalLength']);
}
return $originalValue;
}
private function metadataIsoNumber(array $exifData, $originalValue = null)
{
if (isset($exifData['ISOSpeedRatings']))
{
return $exifData['ISOSpeedRatings'];
}
return $originalValue;
}
}

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