diff --git a/app/Activity/Controllers/WatchController.php b/app/Activity/Controllers/WatchController.php index e2e27ca4a..43908812b 100644 --- a/app/Activity/Controllers/WatchController.php +++ b/app/Activity/Controllers/WatchController.php @@ -2,7 +2,6 @@ namespace BookStack\Activity\Controllers; -use BookStack\Activity\Models\Watch; use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\App\Model; use BookStack\Entities\Models\Entity; @@ -15,7 +14,9 @@ class WatchController extends Controller { public function update(Request $request) { - // TODO - Require notification permission + $this->checkPermission('receive-notifications'); + $this->preventGuestAccess(); + $requestData = $this->validate($request, [ 'level' => ['required', 'string'], ]); diff --git a/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php b/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php index 112852cf9..bc12c8566 100644 --- a/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php +++ b/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php @@ -8,6 +8,7 @@ use BookStack\Activity\Models\Loggable; use BookStack\Activity\Notifications\Messages\CommentCreationNotification; use BookStack\Activity\Tools\EntityWatchers; use BookStack\Activity\WatchLevels; +use BookStack\Entities\Models\Page; use BookStack\Settings\UserNotificationPreferences; use BookStack\Users\Models\User; @@ -20,15 +21,16 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler } // Main watchers + /** @var Page $page */ $page = $detail->entity; $watchers = new EntityWatchers($page, WatchLevels::COMMENTS); $watcherIds = $watchers->getWatcherUserIds(); // Page owner if user preferences allow - if (!$watchers->isUserIgnoring($detail->created_by) && $detail->createdBy) { - $userNotificationPrefs = new UserNotificationPreferences($detail->createdBy); + if (!$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) { + $userNotificationPrefs = new UserNotificationPreferences($page->ownedBy); if ($userNotificationPrefs->notifyOnOwnPageComments()) { - $watcherIds[] = $detail->created_by; + $watcherIds[] = $page->owned_by; } } diff --git a/app/Http/Controller.php b/app/Http/Controller.php index 78b899d25..584cea3aa 100644 --- a/app/Http/Controller.php +++ b/app/Http/Controller.php @@ -66,6 +66,16 @@ abstract class Controller extends BaseController } } + /** + * Prevent access for guest users beyond this point. + */ + protected function preventGuestAccess(): void + { + if (!signedInUser()) { + $this->showPermissionError(); + } + } + /** * Check the current user's permissions against an ownable item otherwise throw an exception. */ diff --git a/app/Users/Controllers/UserPreferencesController.php b/app/Users/Controllers/UserPreferencesController.php index d9ee50ca7..d73bb2d0c 100644 --- a/app/Users/Controllers/UserPreferencesController.php +++ b/app/Users/Controllers/UserPreferencesController.php @@ -62,6 +62,7 @@ class UserPreferencesController extends Controller public function showNotifications(PermissionApplicator $permissions) { $this->checkPermission('receive-notifications'); + $this->preventGuestAccess(); $preferences = (new UserNotificationPreferences(user())); @@ -81,6 +82,7 @@ class UserPreferencesController extends Controller public function updateNotifications(Request $request) { $this->checkPermission('receive-notifications'); + $this->preventGuestAccess(); $data = $this->validate($request, [ 'preferences' => ['required', 'array'], 'preferences.*' => ['required', 'string'], diff --git a/database/seeders/DummyContentSeeder.php b/database/seeders/DummyContentSeeder.php index f0d3ffcdb..47e8d1d7c 100644 --- a/database/seeders/DummyContentSeeder.php +++ b/database/seeders/DummyContentSeeder.php @@ -27,7 +27,7 @@ class DummyContentSeeder extends Seeder // Create an editor user $editorUser = User::factory()->create(); $editorRole = Role::getRole('editor'); - $additionalEditorPerms = ['receive-notifications']; + $additionalEditorPerms = ['receive-notifications', 'comment-create-all']; $editorRole->permissions()->syncWithoutDetaching(RolePermission::whereIn('name', $additionalEditorPerms)->pluck('id')); $editorUser->attachRole($editorRole); diff --git a/tests/Activity/WatchTest.php b/tests/Activity/WatchTest.php index 919e52608..d68bd271f 100644 --- a/tests/Activity/WatchTest.php +++ b/tests/Activity/WatchTest.php @@ -2,9 +2,14 @@ namespace Tests\Activity; +use BookStack\Activity\Notifications\Messages\CommentCreationNotification; +use BookStack\Activity\Notifications\Messages\PageCreationNotification; +use BookStack\Activity\Notifications\Messages\PageUpdateNotification; use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Activity\WatchLevels; use BookStack\Entities\Models\Entity; +use BookStack\Settings\UserNotificationPreferences; +use Illuminate\Support\Facades\Notification; use Tests\TestCase; class WatchTest extends TestCase @@ -83,6 +88,22 @@ class WatchTest extends TestCase ]); } + public function test_watch_update_fails_for_guest() + { + $this->setSettings(['app-public' => 'true']); + $guest = $this->users->guest(); + $this->permissions->grantUserRolePermissions($guest, ['receive-notifications']); + $book = $this->entities->book(); + + $resp = $this->put('/watching/update', [ + 'type' => get_class($book), + 'id' => $book->id, + 'level' => 'comments' + ]); + + $this->assertPermissionError($resp); + } + public function test_watch_detail_display_reflects_state() { $editor = $this->users->editor(); @@ -147,6 +168,147 @@ class WatchTest extends TestCase $respHtml->assertElementNotExists('form[action$="/watching/update"] button[name="level"][value="new"]'); } - // TODO - Guest user cannot see/set notifications - // TODO - Actual notification testing + public function test_notify_own_page_changes() + { + $editor = $this->users->editor(); + $entities = $this->entities->createChainBelongingToUser($editor); + $prefs = new UserNotificationPreferences($editor); + $prefs->updateFromSettingsArray(['own-page-changes' => 'true']); + + $notifications = Notification::fake(); + + $this->asAdmin(); + $this->entities->updatePage($entities['page'], ['name' => 'My updated page', 'html' => 'Hello']); + $notifications->assertSentTo($editor, PageUpdateNotification::class); + } + + public function test_notify_own_page_comments() + { + $editor = $this->users->editor(); + $entities = $this->entities->createChainBelongingToUser($editor); + $prefs = new UserNotificationPreferences($editor); + $prefs->updateFromSettingsArray(['own-page-comments' => 'true']); + + $notifications = Notification::fake(); + + $this->asAdmin()->post("/comment/{$entities['page']->id}", [ + 'text' => 'My new comment' + ]); + $notifications->assertSentTo($editor, CommentCreationNotification::class); + } + + public function test_notify_comment_replies() + { + $editor = $this->users->editor(); + $entities = $this->entities->createChainBelongingToUser($editor); + $prefs = new UserNotificationPreferences($editor); + $prefs->updateFromSettingsArray(['comment-replies' => 'true']); + + $notifications = Notification::fake(); + + $this->actingAs($editor)->post("/comment/{$entities['page']->id}", [ + 'text' => 'My new comment' + ]); + $comment = $entities['page']->comments()->first(); + + $this->asAdmin()->post("/comment/{$entities['page']->id}", [ + 'text' => 'My new comment response', + 'parent_id' => $comment->id, + ]); + $notifications->assertSentTo($editor, CommentCreationNotification::class); + } + + public function test_notify_watch_parent_book_ignore() + { + $editor = $this->users->editor(); + $entities = $this->entities->createChainBelongingToUser($editor); + $watches = new UserEntityWatchOptions($editor, $entities['book']); + $prefs = new UserNotificationPreferences($editor); + $watches->updateWatchLevel('ignore'); + $prefs->updateFromSettingsArray(['own-page-changes' => 'true', 'own-page-comments' => true]); + + $notifications = Notification::fake(); + + $this->asAdmin()->post("/comment/{$entities['page']->id}", [ + 'text' => 'My new comment response', + ]); + $this->entities->updatePage($entities['page'], ['name' => 'My updated page', 'html' => 'Hello']); + $notifications->assertNothingSent(); + } + + public function test_notify_watch_parent_book_comments() + { + $notifications = Notification::fake(); + $editor = $this->users->editor(); + $admin = $this->users->admin(); + $entities = $this->entities->createChainBelongingToUser($editor); + $watches = new UserEntityWatchOptions($editor, $entities['book']); + $watches->updateWatchLevel('comments'); + + // Comment post + $this->actingAs($admin)->post("/comment/{$entities['page']->id}", [ + 'text' => 'My new comment response', + ]); + + $notifications->assertSentTo($editor, function (CommentCreationNotification $notification) use ($editor, $admin, $entities) { + $mail = $notification->toMail($editor); + $mailContent = html_entity_decode(strip_tags($mail->render())); + return $mail->subject === 'New comment on page: ' . $entities['page']->getShortName() + && str_contains($mailContent, 'View Comment') + && str_contains($mailContent, 'Page Name: ' . $entities['page']->name) + && str_contains($mailContent, 'Commenter: ' . $admin->name) + && str_contains($mailContent, 'Comment: My new comment response'); + }); + } + + public function test_notify_watch_parent_book_updates() + { + $notifications = Notification::fake(); + $editor = $this->users->editor(); + $admin = $this->users->admin(); + $entities = $this->entities->createChainBelongingToUser($editor); + $watches = new UserEntityWatchOptions($editor, $entities['book']); + $watches->updateWatchLevel('updates'); + + $this->actingAs($admin); + $this->entities->updatePage($entities['page'], ['name' => 'Updated page', 'html' => 'new page content']); + + $notifications->assertSentTo($editor, function (PageUpdateNotification $notification) use ($editor, $admin) { + $mail = $notification->toMail($editor); + $mailContent = html_entity_decode(strip_tags($mail->render())); + return $mail->subject === 'Updated page: Updated page' + && str_contains($mailContent, 'View Page') + && str_contains($mailContent, 'Page Name: Updated page') + && str_contains($mailContent, 'Updated By: ' . $admin->name) + && str_contains($mailContent, 'you won\'t be sent notifications for further edits to this page by the same editor'); + }); + + // Test debounce + $notifications = Notification::fake(); + $this->entities->updatePage($entities['page'], ['name' => 'Updated page', 'html' => 'new page content']); + $notifications->assertNothingSentTo($editor); + } + + public function test_notify_watch_parent_book_new() + { + $notifications = Notification::fake(); + $editor = $this->users->editor(); + $admin = $this->users->admin(); + $entities = $this->entities->createChainBelongingToUser($editor); + $watches = new UserEntityWatchOptions($editor, $entities['book']); + $watches->updateWatchLevel('new'); + + $this->actingAs($admin)->get($entities['chapter']->getUrl('/create-page')); + $page = $entities['chapter']->pages()->where('draft', '=', true)->first(); + $this->post($page->getUrl(), ['name' => 'My new page', 'html' => 'My new page content']); + + $notifications->assertSentTo($editor, function (PageCreationNotification $notification) use ($editor, $admin) { + $mail = $notification->toMail($editor); + $mailContent = html_entity_decode(strip_tags($mail->render())); + return $mail->subject === 'New page: My new page' + && str_contains($mailContent, 'View Page') + && str_contains($mailContent, 'Page Name: My new page') + && str_contains($mailContent, 'Created By: ' . $admin->name); + }); + } } diff --git a/tests/Helpers/UserRoleProvider.php b/tests/Helpers/UserRoleProvider.php index b86e90394..2b7a3623d 100644 --- a/tests/Helpers/UserRoleProvider.php +++ b/tests/Helpers/UserRoleProvider.php @@ -50,6 +50,14 @@ class UserRoleProvider return $user; } + /** + * Get the system "guest" user. + */ + public function guest(): User + { + return User::where('system_name', '=', 'public')->firstOrFail(); + } + /** * Create a new fresh user without any relations. */ diff --git a/tests/User/UserPreferencesTest.php b/tests/User/UserPreferencesTest.php index a0e7e063f..bc023b4cd 100644 --- a/tests/User/UserPreferencesTest.php +++ b/tests/User/UserPreferencesTest.php @@ -121,6 +121,21 @@ class UserPreferencesTest extends TestCase $resp->assertDontSee('All Page Updates & Comments'); } + public function test_notification_preferences_not_accessible_to_guest() + { + $this->setSettings(['app-public' => 'true']); + $guest = $this->users->guest(); + $this->permissions->grantUserRolePermissions($guest, ['receive-notifications']); + + $resp = $this->get('/preferences/notifications'); + $this->assertPermissionError($resp); + + $resp = $this->put('/preferences/notifications', [ + 'preferences' => ['comment-replies' => 'true'], + ]); + $this->assertPermissionError($resp); + } + public function test_update_sort_preference() { $editor = $this->users->editor();