diff --git a/app/Album.php b/app/Album.php index 8e94eda..d19c77b 100644 --- a/app/Album.php +++ b/app/Album.php @@ -123,6 +123,12 @@ class Album extends Model } } + if (is_null($current->parent_album_id) && $current->is_permissions_inherited) + { + // Use default permissions list + return 0; + } + return $current->id; } diff --git a/app/AlbumDefaultAnonymousPermission.php b/app/AlbumDefaultAnonymousPermission.php new file mode 100644 index 0000000..6924144 --- /dev/null +++ b/app/AlbumDefaultAnonymousPermission.php @@ -0,0 +1,9 @@ + false, 'albums_menu_number_items' => 10, + 'allow_photo_comments' => false, + 'allow_photo_comments_anonymous' => true, 'allow_self_registration' => true, 'analytics_code' => '', 'app_name' => trans('global.app_name'), @@ -111,6 +113,10 @@ class ConfigHelper '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, 'recaptcha_enabled_registration' => false, 'recaptcha_secret_key' => '', diff --git a/app/Helpers/PermissionsHelper.php b/app/Helpers/PermissionsHelper.php index 1e3d626..de42934 100644 --- a/app/Helpers/PermissionsHelper.php +++ b/app/Helpers/PermissionsHelper.php @@ -3,6 +3,9 @@ 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; @@ -50,6 +53,23 @@ class PermissionsHelper ->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 @@ -60,6 +80,10 @@ class PermissionsHelper $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(); @@ -71,60 +95,109 @@ class PermissionsHelper { $effectiveAlbumID = $album->effectiveAlbumIDForPermissions(); - $anonymousPermissions = array_filter($albumAnonPermissions->toArray(), function($item) use ($effectiveAlbumID) + if ($effectiveAlbumID === 0) { - return ($item->album_id == $effectiveAlbumID); - }); + /* Use the default permissions list */ - 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) + foreach ($defaultAnonPermissions as $anonymousPermission) { $permissionsCache[] = [ - 'user_id' => $userGroup->user_id, 'album_id' => $album->id, - 'permission_id' => $groupPermission->permission_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() + ]; + } + } } } diff --git a/app/Http/Controllers/Admin/AlbumController.php b/app/Http/Controllers/Admin/AlbumController.php index 21791d7..f8582a0 100644 --- a/app/Http/Controllers/Admin/AlbumController.php +++ b/app/Http/Controllers/Admin/AlbumController.php @@ -3,6 +3,9 @@ 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; @@ -28,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'); @@ -83,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'); @@ -215,6 +277,130 @@ class AlbumController extends Controller ]); } + 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'); @@ -474,21 +660,41 @@ class AlbumController extends Controller $album->generateUrlPath(); $album->save(); - // Link all default permissions to anonymous users (if a public album) - if (!$album->is_permissions_inherited) + // Link the default permissions (if a public album) + $isPrivate = (strtolower($request->get('is_private')) == 'on'); + if (!$album->is_permissions_inherited && !$isPrivate) { - $isPrivate = (strtolower($request->get('is_private')) == 'on'); + $defaultAlbumUserPermissions = AlbumDefaultUserPermission::all(); + $defaultAlbumGroupPermissions = AlbumDefaultGroupPermission::all(); + $defaultAnonPermissions = AlbumDefaultAnonymousPermission::all(); - if (!$isPrivate) + /** @var AlbumDefaultAnonymousPermission $permission */ + foreach ($defaultAnonPermissions as $permission) { - /** @var Permission $permission */ - foreach (Permission::where(['section' => 'album', 'is_default' => true])->get() as $permission) - { - $album->anonymousPermissions()->attach($permission->id, [ - 'created_at' => new \DateTime(), - 'updated_at' => new \DateTime() - ]); - } + $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() + ]); } } diff --git a/app/Http/Controllers/Admin/DefaultController.php b/app/Http/Controllers/Admin/DefaultController.php index 8be8d16..60bf4b6 100644 --- a/app/Http/Controllers/Admin/DefaultController.php +++ b/app/Http/Controllers/Admin/DefaultController.php @@ -16,6 +16,7 @@ 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\GithubService; use App\Services\PhotoService; @@ -140,6 +141,7 @@ class DefaultController extends Controller $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'); @@ -157,6 +159,7 @@ class DefaultController extends Controller 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'), @@ -231,9 +234,13 @@ class DefaultController extends Controller $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', 'recaptcha_enabled_registration', 'remove_copyright', 'require_email_verification', @@ -252,6 +259,8 @@ class DefaultController extends Controller 'facebook_app_secret', 'google_app_id', 'google_app_secret', + 'photo_comments_allowed_html', + 'photo_comments_thread_depth', 'sender_address', 'sender_name', 'smtp_server', diff --git a/app/Http/Controllers/Admin/GroupController.php b/app/Http/Controllers/Admin/GroupController.php index 70a237f..40b7d8f 100644 --- a/app/Http/Controllers/Admin/GroupController.php +++ b/app/Http/Controllers/Admin/GroupController.php @@ -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)) diff --git a/app/Http/Controllers/Admin/PhotoCommentController.php b/app/Http/Controllers/Admin/PhotoCommentController.php new file mode 100644 index 0000000..ce41bed --- /dev/null +++ b/app/Http/Controllers/Admin/PhotoCommentController.php @@ -0,0 +1,375 @@ +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', ['id' => $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', ['id' => $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', ['id' => $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) + { + $owner = $album->user; + + Mail::to($owner)->send(new PhotoCommentApproved($owner, $album, $photo, $comment)); + + // Also send a notification to the comment poster + $poster = new User(); + $poster->name = $comment->authorDisplayName(); + $poster->email = $comment->authorEmail(); + + Mail::to($poster)->send(new PhotoCommentApprovedUser($poster, $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(); + + Mail::to($parentPoster)->send(new PhotoCommentRepliedTo($parentPoster, $album, $photo, $comment)); + } + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 53009e4..257bee4 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -6,6 +6,7 @@ use App\Facade\Theme; use App\Facade\UserConfig; 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; @@ -29,6 +30,8 @@ class LoginController extends Controller use AuthenticatesUsers; + protected $generator; + /** * Where to redirect users after login / registration. * @@ -41,9 +44,19 @@ class LoginController extends Controller * * @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) @@ -88,6 +101,8 @@ class LoginController extends Controller */ public function showLoginForm(Request $request) { + $request->getSession()->put('url.intended', $this->generator->previous(false)); + return Theme::render('auth.v2_unified', [ 'active_tab' => 'login', 'info' => $request->session()->get('info'), diff --git a/app/Http/Controllers/Gallery/PhotoCommentController.php b/app/Http/Controllers/Gallery/PhotoCommentController.php new file mode 100644 index 0000000..3ae5db5 --- /dev/null +++ b/app/Http/Controllers/Gallery/PhotoCommentController.php @@ -0,0 +1,371 @@ +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->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 + { + $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 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; + } + + /** + * 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) + { + Mail::to($moderator)->send(new ModeratePhotoComment($moderator, $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) + { + $owner = $album->user; + + Mail::to($owner)->send(new PhotoCommentApproved($owner, $album, $photo, $comment)); + + // Also send a notification to the comment poster + $poster = new User(); + $poster->name = $comment->authorDisplayName(); + $poster->email = $comment->authorEmail(); + + Mail::to($poster)->send(new PhotoCommentApprovedUser($poster, $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(); + + Mail::to($parentPoster)->send(new PhotoCommentRepliedTo($parentPoster, $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
+ 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; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Gallery/PhotoController.php b/app/Http/Controllers/Gallery/PhotoController.php index bd50bb0..98a3eb8 100644 --- a/app/Http/Controllers/Gallery/PhotoController.php +++ b/app/Http/Controllers/Gallery/PhotoController.php @@ -157,7 +157,8 @@ class PhotoController extends Controller 'is_original_allowed' => $isOriginalAllowed, 'next_photo' => $nextPhoto, 'photo' => $photo, - 'previous_photo' => $previousPhoto + 'previous_photo' => $previousPhoto, + 'success' => $request->getSession()->get('success') ]); } diff --git a/app/Mail/ModeratePhotoComment.php b/app/Mail/ModeratePhotoComment.php new file mode 100644 index 0000000..db6db37 --- /dev/null +++ b/app/Mail/ModeratePhotoComment.php @@ -0,0 +1,56 @@ +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 + ]); + } +} diff --git a/app/Mail/PhotoCommentApproved.php b/app/Mail/PhotoCommentApproved.php new file mode 100644 index 0000000..fb3a326 --- /dev/null +++ b/app/Mail/PhotoCommentApproved.php @@ -0,0 +1,60 @@ +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 + ]); + } +} diff --git a/app/Mail/PhotoCommentApprovedUser.php b/app/Mail/PhotoCommentApprovedUser.php new file mode 100644 index 0000000..c791a6f --- /dev/null +++ b/app/Mail/PhotoCommentApprovedUser.php @@ -0,0 +1,60 @@ +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 + ]); + } +} diff --git a/app/Mail/PhotoCommentRepliedTo.php b/app/Mail/PhotoCommentRepliedTo.php new file mode 100644 index 0000000..77dcebc --- /dev/null +++ b/app/Mail/PhotoCommentRepliedTo.php @@ -0,0 +1,60 @@ +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 + ]); + } +} diff --git a/app/Photo.php b/app/Photo.php index 486e979..a4fef8b 100644 --- a/app/Photo.php +++ b/app/Photo.php @@ -52,6 +52,11 @@ class Photo extends Model return $this->belongsTo(Album::class); } + public function comments() + { + return $this->hasMany(PhotoComment::class); + } + public function exifUrl() { return route('viewExifData', [ @@ -77,6 +82,32 @@ class Photo extends Model 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); diff --git a/app/PhotoComment.php b/app/PhotoComment.php new file mode 100644 index 0000000..87f542f --- /dev/null +++ b/app/PhotoComment.php @@ -0,0 +1,91 @@ +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 = '
'; + $end = '
'; + $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('%s
', $this->comment); + } +} \ No newline at end of file diff --git a/app/Policies/AlbumPolicy.php b/app/Policies/AlbumPolicy.php index 2309121..0eeed6f 100644 --- a/app/Policies/AlbumPolicy.php +++ b/app/Policies/AlbumPolicy.php @@ -3,6 +3,7 @@ namespace App\Policies; use App\Album; +use App\Facade\UserConfig; use App\Group; use App\Helpers\PermissionsHelper; use App\Permission; @@ -93,6 +94,34 @@ class AlbumPolicy return $this->userHasPermission($user, $album, 'manipulate-photos'); } + 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) { if ($user->id == $album->user_id) diff --git a/app/Policies/PhotoPolicy.php b/app/Policies/PhotoPolicy.php index 52c7119..eaa0626 100644 --- a/app/Policies/PhotoPolicy.php +++ b/app/Policies/PhotoPolicy.php @@ -61,4 +61,26 @@ 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); + } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index d37e63a..556dfa2 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -54,6 +54,10 @@ 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'); diff --git a/database/migrations/2018_09_17_132906_create_photo_comments_table.php b/database/migrations/2018_09_17_132906_create_photo_comments_table.php new file mode 100644 index 0000000..7c6f101 --- /dev/null +++ b/database/migrations/2018_09_17_132906_create_photo_comments_table.php @@ -0,0 +1,52 @@ +bigIncrements('id'); + $table->unsignedBigInteger('photo_id'); + $table->string('name'); + $table->string('email'); + $table->text('comment'); + $table->unsignedInteger('created_user_id')->nullable(true); + $table->unsignedInteger('approved_user_id')->nullable(true); + $table->dateTime('approved_at')->nullable(true); + $table->unsignedBigInteger('parent_comment_id')->nullable(true); + $table->timestamps(); + + $table->foreign('photo_id') + ->references('id')->on('photos') + ->onDelete('cascade'); + $table->foreign('created_user_id') + ->references('id')->on('users') + ->onDelete('cascade'); + $table->foreign('approved_user_id') + ->references('id')->on('users') + ->onDelete('cascade'); + $table->foreign('parent_comment_id') + ->references('id')->on('photo_comments') + ->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('photo_comments'); + } +} diff --git a/database/migrations/2018_09_19_190631_add_comment_rejected_columns.php b/database/migrations/2018_09_19_190631_add_comment_rejected_columns.php new file mode 100644 index 0000000..788effb --- /dev/null +++ b/database/migrations/2018_09_19_190631_add_comment_rejected_columns.php @@ -0,0 +1,34 @@ +unsignedInteger('rejected_user_id')->nullable(true); + $table->dateTime('rejected_at')->nullable(true); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('photo_comments', function (Blueprint $table) { + $table->dropColumn('rejected_user_id'); + $table->dropColumn('rejected_at'); + }); + } +} diff --git a/database/migrations/2018_09_23_100536_create_album_default_group_permissions_table.php b/database/migrations/2018_09_23_100536_create_album_default_group_permissions_table.php new file mode 100644 index 0000000..d881618 --- /dev/null +++ b/database/migrations/2018_09_23_100536_create_album_default_group_permissions_table.php @@ -0,0 +1,40 @@ +unsignedInteger('group_id'); + $table->unsignedInteger('permission_id'); + + $table->foreign('group_id') + ->references('id')->on('groups') + ->onDelete('cascade'); + $table->foreign('permission_id') + ->references('id')->on('permissions') + ->onDelete('no action'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('album_default_group_permissions'); + } +} diff --git a/database/migrations/2018_09_23_100542_create_album_default_user_permissions_table.php b/database/migrations/2018_09_23_100542_create_album_default_user_permissions_table.php new file mode 100644 index 0000000..2cf4b42 --- /dev/null +++ b/database/migrations/2018_09_23_100542_create_album_default_user_permissions_table.php @@ -0,0 +1,40 @@ +unsignedInteger('user_id'); + $table->unsignedInteger('permission_id'); + + $table->foreign('user_id') + ->references('id')->on('users') + ->onDelete('cascade'); + $table->foreign('permission_id') + ->references('id')->on('permissions') + ->onDelete('no action'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('album_default_user_permissions'); + } +} diff --git a/database/migrations/2018_09_23_100608_create_album_default_anonymous_permissions_table.php b/database/migrations/2018_09_23_100608_create_album_default_anonymous_permissions_table.php new file mode 100644 index 0000000..7d1b51a --- /dev/null +++ b/database/migrations/2018_09_23_100608_create_album_default_anonymous_permissions_table.php @@ -0,0 +1,36 @@ +unsignedInteger('permission_id'); + + $table->foreign('permission_id') + ->references('id')->on('permissions') + ->onDelete('no action'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('album_default_anonymous_permissions'); + } +} diff --git a/database/seeds/PermissionsSeeder.php b/database/seeds/PermissionsSeeder.php index 23a7df0..e5e5494 100644 --- a/database/seeds/PermissionsSeeder.php +++ b/database/seeds/PermissionsSeeder.php @@ -72,6 +72,14 @@ class PermissionsSeeder extends Seeder 'is_default' => false, 'sort_order' => 0 ]); + + // admin:manage-comments = controls if photo comments can be managed + DatabaseSeeder::createOrUpdate('permissions', [ + 'section' => 'admin', + 'description' => 'manage-comments', + 'is_default' => false, + 'sort_order' => 0 + ]); } private function seedAlbumPermissions() @@ -139,5 +147,21 @@ class PermissionsSeeder extends Seeder 'is_default' => false, 'sort_order' => 60 ]); + + // album:moderate-comments = moderate comments posted on photos + DatabaseSeeder::createOrUpdate('permissions', [ + 'section' => 'album', + 'description' => 'moderate-comments', + 'is_default' => false, + 'sort_order' => 70 + ]); + + // album:moderate-comments = moderate comments posted on photos + DatabaseSeeder::createOrUpdate('permissions', [ + 'section' => 'album', + 'description' => 'post-comment', + 'is_default' => false, + 'sort_order' => 80 + ]); } } diff --git a/public/css/blue-twilight.css b/public/css/blue-twilight.css index c8da95b..b9c96be 100644 --- a/public/css/blue-twilight.css +++ b/public/css/blue-twilight.css @@ -100,6 +100,10 @@ width: auto; } +.photo-comment .card-subtitle { + font-size: smaller; +} + .stats-table .icon-col { font-size: 1.4em; width: 20%; diff --git a/public/css/blue-twilight.min.css b/public/css/blue-twilight.min.css index 524a512..d61b69e 100644 --- a/public/css/blue-twilight.min.css +++ b/public/css/blue-twilight.min.css @@ -1,4 +1,4 @@ -.admin-sidebar-card{margin-bottom:15px}.album-expand-handle{cursor:pointer;margin-top:5px}.meta-label,.meta-value{vertical-align:middle !important}.photo .loading{background-color:#fff;display:none;height:100%;left:0;opacity:.8;position:absolute;text-align:center;top:0;width:100%;z-index:1000}.photo .loading img{margin-top:40px}.text-red{color:red}[v-cloak]{display:none}.activity-grid{font-size:smaller}.activity-grid th,.activity-grid td{padding:5px !important;text-align:center}.activity-grid td{color:#fff;height:20px}.activity-grid .has-activity{background-color:#1e90ff}.activity-grid .invalid-date{background-color:#e5e5e5}.activity-grid .no-activity{background-color:#fff}.activity-grid th:first-child,.activity-grid td:first-child{border-left:1px solid #dee2e6}.activity-grid th,.activity-grid td{border-right:1px solid #dee2e6}.activity-grid tr:last-child td{border-bottom:1px solid #dee2e6}.activity-grid .border-spacer-element{border-right-width:0;padding:0 !important;width:1px}.album-slideshow-container #image-preview{height:600px;max-width:100%;width:800px}.album-slideshow-container #image-preview img{max-width:100%}.album-slideshow-container .thumbnails{overflow-x:scroll;overflow-y:hidden;white-space:nowrap;width:auto}.stats-table .icon-col{font-size:1.4em;width:20%;vertical-align:middle}.stats-table .stat-col{font-size:1.8em;font-weight:bold;width:40%}.stats-table .text-col{font-size:1.2em;vertical-align:middle;width:40%}html{font-size:14px !important}button,input,optgroup,select,textarea{cursor:pointer;font-family:inherit !important}.album-photo-cards .card{margin-bottom:15px}.container,.container-fluid{margin-top:20px}.hidden{display:none}.tab-content{border:solid 1px #ddd;border-top:0;padding:20px}.tether-element,.tether-element:after,.tether-element:before,.tether-element *,.tether-element *:after,.tether-element *:before{box-sizing:border-box}.tether-element{position:absolute;display:none}.tether-element.tether-open{display:block}.tether-element.tether-theme-basic{max-width:100%;max-height:100%}.tether-element.tether-theme-basic .tether-content{border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,0.2);font-family:inherit;background:#fff;color:inherit;padding:1em;font-size:1.1em;line-height:1.5em}.tether-element,.tether-element:after,.tether-element:before,.tether-element *,.tether-element *:after,.tether-element *:before{box-sizing:border-box}.tether-element{position:absolute;display:none}.tether-element.tether-open{display:block}.tt-query{-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);-moz-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.tt-hint{color:#999}.tt-menu{width:422px;margin-top:4px;padding:4px 0;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.2);-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);-moz-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2)}.tt-suggestion{padding:3px 20px;line-height:24px}.tt-suggestion.tt-cursor,.tt-suggestion:hover{color:#fff;background-color:#0097cf}.tt-suggestion p{margin:0}/*! +.admin-sidebar-card{margin-bottom:15px}.album-expand-handle{cursor:pointer;margin-top:5px}.meta-label,.meta-value{vertical-align:middle !important}.photo .loading{background-color:#fff;display:none;height:100%;left:0;opacity:.8;position:absolute;text-align:center;top:0;width:100%;z-index:1000}.photo .loading img{margin-top:40px}.text-red{color:red}[v-cloak]{display:none}.activity-grid{font-size:smaller}.activity-grid th,.activity-grid td{padding:5px !important;text-align:center}.activity-grid td{color:#fff;height:20px}.activity-grid .has-activity{background-color:#1e90ff}.activity-grid .invalid-date{background-color:#e5e5e5}.activity-grid .no-activity{background-color:#fff}.activity-grid th:first-child,.activity-grid td:first-child{border-left:1px solid #dee2e6}.activity-grid th,.activity-grid td{border-right:1px solid #dee2e6}.activity-grid tr:last-child td{border-bottom:1px solid #dee2e6}.activity-grid .border-spacer-element{border-right-width:0;padding:0 !important;width:1px}.album-slideshow-container #image-preview{height:600px;max-width:100%;width:800px}.album-slideshow-container #image-preview img{max-width:100%}.album-slideshow-container .thumbnails{overflow-x:scroll;overflow-y:hidden;white-space:nowrap;width:auto}.photo-comment .card-subtitle{font-size:smaller}.stats-table .icon-col{font-size:1.4em;width:20%;vertical-align:middle}.stats-table .stat-col{font-size:1.8em;font-weight:bold;width:40%}.stats-table .text-col{font-size:1.2em;vertical-align:middle;width:40%}html{font-size:14px !important}button,input,optgroup,select,textarea{cursor:pointer;font-family:inherit !important}.album-photo-cards .card{margin-bottom:15px}.container,.container-fluid{margin-top:20px}.hidden{display:none}.tab-content{border:solid 1px #ddd;border-top:0;padding:20px}.tether-element,.tether-element:after,.tether-element:before,.tether-element *,.tether-element *:after,.tether-element *:before{box-sizing:border-box}.tether-element{position:absolute;display:none}.tether-element.tether-open{display:block}.tether-element.tether-theme-basic{max-width:100%;max-height:100%}.tether-element.tether-theme-basic .tether-content{border-radius:5px;box-shadow:0 2px 8px rgba(0,0,0,0.2);font-family:inherit;background:#fff;color:inherit;padding:1em;font-size:1.1em;line-height:1.5em}.tether-element,.tether-element:after,.tether-element:before,.tether-element *,.tether-element *:after,.tether-element *:before{box-sizing:border-box}.tether-element{position:absolute;display:none}.tether-element.tether-open{display:block}.tt-query{-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);-moz-box-shadow:inset 0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 1px rgba(0,0,0,0.075)}.tt-hint{color:#999}.tt-menu{width:422px;margin-top:4px;padding:4px 0;background-color:#fff;border:1px solid #ccc;border:1px solid rgba(0,0,0,0.2);-webkit-border-radius:4px;-moz-border-radius:4px;border-radius:4px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);-moz-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2)}.tt-suggestion{padding:3px 20px;line-height:24px}.tt-suggestion.tt-cursor,.tt-suggestion:hover{color:#fff;background-color:#0097cf}.tt-suggestion p{margin:0}/*! * Bootstrap v4.1.2 (https://getbootstrap.com/) * Copyright 2011-2018 The Bootstrap Authors * Copyright 2011-2018 Twitter, Inc. diff --git a/public/js/blue-twilight.js b/public/js/blue-twilight.js index 88ffc9e..272d0bb 100644 --- a/public/js/blue-twilight.js +++ b/public/js/blue-twilight.js @@ -41534,6 +41534,82 @@ module.exports = function(Chart) { },{"25":25,"45":45,"6":6}]},{},[7])(7) }); +/** + * This model is used by gallery/photo.blade.php, to handle comments and individual photo actions. + * @constructor + */ +function PhotoViewModel(urls) { + this.el = '#photo-app'; + + this.data = { + is_reply_form_loading: false, + reply_comment_id: 0 + }; + + this.computed = { + replyFormStyle: function() + { + return { + 'display': this.is_reply_form_loading ? 'none' : 'block' + }; + } + }; + + this.methods = { + init: function() { + var self = this; + + // Load the right comment reply form + $('#comment-reply-modal').on('show.bs.modal', function (event) { + var url = urls.reply_comment_form.replace(/-1$/, self.reply_comment_id); + $.get(url, function(result) + { + $('#comment-reply-form-content').html(result); + initTinyMce('#comment-text-reply'); + self.is_reply_form_loading = false; + }); + }); + + $('#comment-reply-modal').on('hide.bs.modal', function (event) { + tinymce.remove('#comment-text-reply'); + }); + }, + postCommentReply: function() { + var form = $('form', '#comment-reply-form-content'); + var formUrl = $(form).attr('action'); + + // Seems like the TinyMCE editor in the BS modal does not persist back to the textarea correctly - so do + // this manually (bit of a hack!) + $('#comment-text-reply', form).html(tinymce.get('comment-text-reply').getContent()); + + var formData = form.serialize(); + + $.post(formUrl, formData, function(result) + { + if (result.redirect_url) + { + window.location = result.redirect_url; + } + else + { + tinymce.remove('#comment-text-reply'); + $('#comment-reply-form-content').html(result); + initTinyMce('#comment-text-reply'); + } + }); + }, + replyToComment: function(e) { + var replyButton = $(e.target).closest('.photo-comment'); + this.reply_comment_id = replyButton.data('comment-id'); + + this.is_reply_form_loading = true; + $('#comment-reply-modal').modal('show'); + + e.preventDefault(); + return false; + } + }; +} function StorageLocationViewModel() { this.el = '#storage-options'; diff --git a/public/js/blue-twilight.min.js b/public/js/blue-twilight.min.js index 4b8105b..270e5b1 100644 --- a/public/js/blue-twilight.min.js +++ b/public/js/blue-twilight.min.js @@ -1 +1 @@ -function AboutViewModel(t){this.el="#about-app",this.data={can_upgrade:!1,is_loading:!0,version_body:"",version_date:"",version_name:"",version_url:""},this.computed={},this.methods={init:function(){var e=this;$.ajax(t.latest_release_url,{complete:function(){e.is_loading=!1},dataType:"json",error:function(t,e,n){},method:"GET",success:function(t){e.version_body=t.body,e.version_date=t.publish_date,e.version_name=t.name,e.version_url=t.url,e.can_upgrade=t.can_upgrade}})}}}function CreateAlbumViewModel(){this.el="#create-album-app",this.data={is_inherit_permissions:!0,is_private:!1,parent_id:""},this.computed={isParentAlbum:function(){return""==this.parent_id},isPrivateDisabled:function(){return!this.isParentAlbum&&this.is_inherit_permissions}}}function EditAlbumViewModel(){this.el="#edit-album-app",this.data={parent_id:""},this.computed={isParentAlbum:function(){return""==this.parent_id}}}function SettingsViewModel(t,e){this.el="#settings-app",this.data={is_rebuilding_permissions_cache:!1},this.methods={rebuildPermissionsCache:function(n){var i=this;return $.ajax(t.rebuild_permissions_cache,{complete:function(){i.is_rebuilding_permissions_cache=!1},dataType:"json",error:function(t,n,i){alert(e.permissions_cache_rebuild_failed)},method:"POST",success:function(t){alert(e.permissions_cache_rebuild_succeeded)}}),n.preventDefault(),!1}}}function AnalyseAlbumViewModel(){this.el="#analyse-album",this.data={imagesFailed:[],imagesToAnalyse:[],imagesInProgress:[],imagesRecentlyCompleted:[],numberSuccessful:0,numberFailed:0},this.computed={failedPercentage:function(){var t=0;return this.numberTotal>0&&(t=this.numberFailed/this.numberTotal*100),t.toFixed(2)+"%"},isCompleted:function(){return this.numberTotal>0&&this.numberSuccessful+this.numberFailed>=this.numberTotal},latestCompletedImages:function(){var t=this.imagesRecentlyCompleted.length-3<0?0:this.imagesRecentlyCompleted.length-3,e=t+3;return this.imagesRecentlyCompleted.slice(t,e)},numberTotal:function(){return this.imagesToAnalyse.length},successfulPercentage:function(){var t=0;return this.numberTotal>0&&(t=this.numberSuccessful/this.numberTotal*100),t.toFixed(2)+"%"}},this.methods={analyseImage:function(t){var e=this;this.imagesToAnalyse.push(t),$.ajax(t.url,{beforeSend:function(){e.imagesInProgress.push(t)},dataType:"json",error:function(n,i,r){e.numberFailed++,e.imagesFailed.push({name:t.name,reason:i}),t.isSuccessful=!1,t.isPending=!1},method:"POST",success:function(n){if(n.is_successful){e.numberSuccessful++,t.isSuccessful=!0,t.isPending=!1,e.imagesRecentlyCompleted.push(t);var i=e.imagesInProgress.indexOf(t);i>-1&&e.imagesInProgress.splice(i,1)}else e.numberFailed++,e.imagesFailed.push({name:t.name,reason:n.message}),t.isSuccessful=!1,t.isPending=!1}})}}}function AnalyseImageViewModel(t){this.isPending=!0,this.isSuccessful=!1,this.name=t.name,this.photoID=t.photo_id,this.url=t.url}function EditPhotosViewModel(t,e,n){this.el="#photos-tab",this.data={albums:[],bulkModifyMethod:"",isSubmitting:!1,photoIDs:[],photoIDsAvailable:[],selectAllInAlbum:0},this.methods={bulkModifySelected:function(t){if(this.isSubmitting)return!0;var n=this,i=$(t.target).closest("form");return"change_album"===this.bulkModifyMethod?(this.promptForNewAlbum(function(t){var e=$("select",t).val();$('input[name="new-album-id"]',i).val(e),n.isSubmitting=!0,$('button[name="bulk-apply"]',i).click(),_bt_showLoadingModal()}),t.preventDefault(),!1):"delete"!==this.bulkModifyMethod||(bootbox.dialog({message:e.delete_bulk_confirm_message,title:e.delete_bulk_confirm_title,buttons:{cancel:{label:e.action_cancel,className:"btn-secondary"},confirm:{label:e.action_delete,className:"btn-danger",callback:function(){n.isSubmitting=!0,$('button[name="bulk-apply"]',i).click(),_bt_showLoadingModal()}}}}),t.preventDefault(),!1)},changeAlbum:function(t){this.selectPhotoSingle(t.target);var e=this.photoIDs[0];return this.photoIDs=[],this.promptForNewAlbum(function(t){var i=$("select",t).val();$.post(n.move_photo.replace(/\/0$/,"/"+e),{new_album_id:i},function(){window.location.reload()})}),t.preventDefault(),!1},deletePhoto:function(t){this.selectPhotoSingle(t.target);var i=this.photoIDs[0];return this.photoIDs=[],bootbox.dialog({message:e.delete_confirm_message,title:e.delete_confirm_title,buttons:{cancel:{label:e.action_cancel,className:"btn-secondary"},confirm:{label:e.action_delete,className:"btn-danger",callback:function(){var t=n.delete_photo;t=t.replace(/\/0$/,"/"+i),$(".loading",parent).show(),$.post(t,{_method:"DELETE"},function(t){window.location.reload()})}}}}),t.preventDefault(),!1},flip:function(t,e,i){var r=n.flip_photo;r=r.replace("/0/","/"+this.photoIDs[0]+"/"),r=t?r.replace(/\/-1\//,"/1/"):r.replace(/\/-1\//,"/0/"),r=e?r.replace(/\/-2$/,"/1"):r.replace(/\/-2$/,"/0"),$(".loading",i).show(),$.post(r,function(){var t=$("img.photo-thumbnail",i),e=t.data("original-src");t.attr("src",e+"&_="+(new Date).getTime()),$(".loading",i).hide()}),this.photoIDs=[]},flipBoth:function(t){return this.selectPhotoSingle(t.target),this.flip(!0,!0,$(t.target).closest(".photo")),t.preventDefault(),!1},flipHorizontal:function(t){return this.selectPhotoSingle(t.target),this.flip(!0,!1,$(t.target).closest(".photo")),t.preventDefault(),!1},flipVertical:function(t){return this.selectPhotoSingle(t.target),this.flip(!1,!0,$(t.target).closest(".photo")),t.preventDefault(),!1},isPhotoSelected:function(t){return this.photoIDs.indexOf(t)>-1?"checked":""},promptForNewAlbum:function(n){for(var i=this.albums,r=$("").attr("name","album_id").addClass("form-control"),o=0;o@lang('admin.approve_comment_confirm', ['author_name' => $comment->authorDisplayName()])
+ + {!! $comment->textAsHtml() !!} + +@lang('admin.approve_comments_confirm', ['number' => $comment_count])
+ +@lang('admin.delete_comments_confirm', ['number' => $comment_count])
+ +@lang('admin.delete_comments_warning')
+ +@lang('admin.reject_comments_confirm', ['number' => $comment_count])
+ +
{{ $comment->authorDisplayName() }}
+{{ date(UserConfig::get('date_format'), strtotime($comment->created_at)) }}
+ {!! $comment->textAsHtml() !!} + + @if (!$is_reply && ($comment->depth() < UserConfig::get('photo_comments_thread_depth') - 1) && \App\User::currentOrAnonymous()->can('post-comment', $photo)) + @lang('gallery.photo_comments_reply_action') + @endif +