diff --git a/app/Activity/Controllers/WatchController.php b/app/Activity/Controllers/WatchController.php new file mode 100644 index 000000000..3d7e18116 --- /dev/null +++ b/app/Activity/Controllers/WatchController.php @@ -0,0 +1,65 @@ +checkPermission('receive-notifications'); + $this->preventGuestAccess(); + + $requestData = $this->validate($request, [ + 'level' => ['required', 'string'], + ]); + + $watchable = $this->getValidatedModelFromRequest($request); + $watchOptions = new UserEntityWatchOptions(user(), $watchable); + $watchOptions->updateLevelByName($requestData['level']); + + $this->showSuccessNotification(trans('activities.watch_update_level_notification')); + + return redirect()->back(); + } + + /** + * @throws ValidationException + * @throws Exception + */ + protected function getValidatedModelFromRequest(Request $request): Entity + { + $modelInfo = $this->validate($request, [ + 'type' => ['required', 'string'], + 'id' => ['required', 'integer'], + ]); + + if (!class_exists($modelInfo['type'])) { + throw new Exception('Model not found'); + } + + /** @var Model $model */ + $model = new $modelInfo['type'](); + if (!$model instanceof Entity) { + throw new Exception('Model not an entity'); + } + + $modelInstance = $model->newQuery() + ->where('id', '=', $modelInfo['id']) + ->first(['id', 'name', 'owned_by']); + + $inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance)); + if (is_null($modelInstance) || $inaccessibleEntity) { + throw new Exception('Model instance not found'); + } + + return $modelInstance; + } +} diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index 7aea6124a..bcbed6c56 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -5,6 +5,7 @@ namespace BookStack\Activity\Models; use BookStack\App\Model; use BookStack\Users\Models\HasCreatorAndUpdater; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; /** @@ -32,6 +33,14 @@ class Comment extends Model implements Loggable return $this->morphTo('entity'); } + /** + * Get the parent comment this is in reply to (if existing). + */ + public function parent(): BelongsTo + { + return $this->belongsTo(Comment::class); + } + /** * Check if a comment has been updated since creation. */ @@ -42,20 +51,16 @@ class Comment extends Model implements Loggable /** * Get created date as a relative diff. - * - * @return mixed */ - public function getCreatedAttribute() + public function getCreatedAttribute(): string { return $this->created_at->diffForHumans(); } /** * Get updated date as a relative diff. - * - * @return mixed */ - public function getUpdatedAttribute() + public function getUpdatedAttribute(): string { return $this->updated_at->diffForHumans(); } diff --git a/app/Activity/Models/Watch.php b/app/Activity/Models/Watch.php new file mode 100644 index 000000000..dfb72cc0a --- /dev/null +++ b/app/Activity/Models/Watch.php @@ -0,0 +1,45 @@ +morphTo(); + } + + public function jointPermissions(): HasMany + { + return $this->hasMany(JointPermission::class, 'entity_id', 'watchable_id') + ->whereColumn('watches.watchable_type', '=', 'joint_permissions.entity_type'); + } + + public function getLevelName(): string + { + return WatchLevels::levelValueToName($this->level); + } + + public function ignoring(): bool + { + return $this->level === WatchLevels::IGNORE; + } +} diff --git a/app/Activity/Notifications/Handlers/BaseNotificationHandler.php b/app/Activity/Notifications/Handlers/BaseNotificationHandler.php new file mode 100644 index 000000000..b5f339b2c --- /dev/null +++ b/app/Activity/Notifications/Handlers/BaseNotificationHandler.php @@ -0,0 +1,42 @@ + $notification + * @param int[] $userIds + */ + protected function sendNotificationToUserIds(string $notification, array $userIds, User $initiator, string|Loggable $detail, Entity $relatedModel): void + { + $users = User::query()->whereIn('id', array_unique($userIds))->get(); + + foreach ($users as $user) { + // Prevent sending to the user that initiated the activity + if ($user->id === $initiator->id) { + continue; + } + + // Prevent sending of the user does not have notification permissions + if (!$user->can('receive-notifications')) { + continue; + } + + // Prevent sending if the user does not have access to the related content + $permissions = new PermissionApplicator($user); + if (!$permissions->checkOwnableUserAccess($relatedModel, 'view')) { + continue; + } + + // Send the notification + $user->notify(new $notification($detail, $initiator)); + } + } +} diff --git a/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php b/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php new file mode 100644 index 000000000..bc12c8566 --- /dev/null +++ b/app/Activity/Notifications/Handlers/CommentCreationNotificationHandler.php @@ -0,0 +1,48 @@ +entity; + $watchers = new EntityWatchers($page, WatchLevels::COMMENTS); + $watcherIds = $watchers->getWatcherUserIds(); + + // Page owner if user preferences allow + if (!$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) { + $userNotificationPrefs = new UserNotificationPreferences($page->ownedBy); + if ($userNotificationPrefs->notifyOnOwnPageComments()) { + $watcherIds[] = $page->owned_by; + } + } + + // Parent comment creator if preferences allow + $parentComment = $detail->parent()->first(); + if ($parentComment && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) { + $parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy); + if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) { + $watcherIds[] = $parentComment->created_by; + } + } + + $this->sendNotificationToUserIds(CommentCreationNotification::class, $watcherIds, $user, $detail, $page); + } +} diff --git a/app/Activity/Notifications/Handlers/NotificationHandler.php b/app/Activity/Notifications/Handlers/NotificationHandler.php new file mode 100644 index 000000000..8c5498664 --- /dev/null +++ b/app/Activity/Notifications/Handlers/NotificationHandler.php @@ -0,0 +1,17 @@ +sendNotificationToUserIds(PageCreationNotification::class, $watchers->getWatcherUserIds(), $user, $detail, $detail); + } +} diff --git a/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php b/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php new file mode 100644 index 000000000..744aba18f --- /dev/null +++ b/app/Activity/Notifications/Handlers/PageUpdateNotificationHandler.php @@ -0,0 +1,51 @@ +activity() + ->where('type', '=', ActivityType::PAGE_UPDATE) + ->where('id', '!=', $activity->id) + ->latest('created_at') + ->first(); + + // Return if the same user has already updated the page in the last 15 mins + if ($lastUpdate && $lastUpdate->user_id === $user->id) { + if ($lastUpdate->created_at->gt(now()->subMinutes(15))) { + return; + } + } + + // Get active watchers + $watchers = new EntityWatchers($detail, WatchLevels::UPDATES); + $watcherIds = $watchers->getWatcherUserIds(); + + // Add page owner if preferences allow + if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) { + $userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy); + if ($userNotificationPrefs->notifyOnOwnPageChanges()) { + $watcherIds[] = $detail->owned_by; + } + } + + $this->sendNotificationToUserIds(PageUpdateNotification::class, $watcherIds, $user, $detail, $detail); + } +} diff --git a/app/Activity/Notifications/MessageParts/LinkedMailMessageLine.php b/app/Activity/Notifications/MessageParts/LinkedMailMessageLine.php new file mode 100644 index 000000000..8f6a4e2b9 --- /dev/null +++ b/app/Activity/Notifications/MessageParts/LinkedMailMessageLine.php @@ -0,0 +1,26 @@ +url) . '">' . e($this->linkText) . ''; + return str_replace(':link', $link, e($this->line)); + } +} diff --git a/app/Activity/Notifications/MessageParts/ListMessageLine.php b/app/Activity/Notifications/MessageParts/ListMessageLine.php new file mode 100644 index 000000000..f808d2561 --- /dev/null +++ b/app/Activity/Notifications/MessageParts/ListMessageLine.php @@ -0,0 +1,26 @@ +list as $header => $content) { + $list[] = '' . e($header) . ' ' . e($content); + } + return implode("
\n", $list); + } +} diff --git a/app/Activity/Notifications/Messages/BaseActivityNotification.php b/app/Activity/Notifications/Messages/BaseActivityNotification.php new file mode 100644 index 000000000..eb6eb0cc8 --- /dev/null +++ b/app/Activity/Notifications/Messages/BaseActivityNotification.php @@ -0,0 +1,63 @@ + $this->detail, + 'activity_creator' => $this->user, + ]; + } + + /** + * Build the common reason footer line used in mail messages. + */ + protected function buildReasonFooterLine(): LinkedMailMessageLine + { + return new LinkedMailMessageLine( + url('/preferences/notifications'), + trans('notifications.footer_reason'), + trans('notifications.footer_reason_link'), + ); + } +} diff --git a/app/Activity/Notifications/Messages/CommentCreationNotification.php b/app/Activity/Notifications/Messages/CommentCreationNotification.php new file mode 100644 index 000000000..ce358724b --- /dev/null +++ b/app/Activity/Notifications/Messages/CommentCreationNotification.php @@ -0,0 +1,30 @@ +detail; + /** @var Page $page */ + $page = $comment->entity; + + return (new MailMessage()) + ->subject(trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()])) + ->line(trans('notifications.new_comment_intro', ['appName' => setting('app-name')])) + ->line(new ListMessageLine([ + trans('notifications.detail_page_name') => $page->name, + trans('notifications.detail_commenter') => $this->user->name, + trans('notifications.detail_comment') => strip_tags($comment->html), + ])) + ->action(trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id)) + ->line($this->buildReasonFooterLine()); + } +} diff --git a/app/Activity/Notifications/Messages/PageCreationNotification.php b/app/Activity/Notifications/Messages/PageCreationNotification.php new file mode 100644 index 000000000..068f95acc --- /dev/null +++ b/app/Activity/Notifications/Messages/PageCreationNotification.php @@ -0,0 +1,26 @@ +detail; + + return (new MailMessage()) + ->subject(trans('notifications.new_page_subject', ['pageName' => $page->getShortName()])) + ->line(trans('notifications.new_page_intro', ['appName' => setting('app-name')])) + ->line(new ListMessageLine([ + trans('notifications.detail_page_name') => $page->name, + trans('notifications.detail_created_by') => $this->user->name, + ])) + ->action(trans('notifications.action_view_page'), $page->getUrl()) + ->line($this->buildReasonFooterLine()); + } +} diff --git a/app/Activity/Notifications/Messages/PageUpdateNotification.php b/app/Activity/Notifications/Messages/PageUpdateNotification.php new file mode 100644 index 000000000..c4a6de0bd --- /dev/null +++ b/app/Activity/Notifications/Messages/PageUpdateNotification.php @@ -0,0 +1,27 @@ +detail; + + return (new MailMessage()) + ->subject(trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()])) + ->line(trans('notifications.updated_page_intro', ['appName' => setting('app-name')])) + ->line(new ListMessageLine([ + trans('notifications.detail_page_name') => $page->name, + trans('notifications.detail_updated_by') => $this->user->name, + ])) + ->line(trans('notifications.updated_page_debounce')) + ->action(trans('notifications.action_view_page'), $page->getUrl()) + ->line($this->buildReasonFooterLine()); + } +} diff --git a/app/Activity/Notifications/NotificationManager.php b/app/Activity/Notifications/NotificationManager.php new file mode 100644 index 000000000..294f56ebb --- /dev/null +++ b/app/Activity/Notifications/NotificationManager.php @@ -0,0 +1,52 @@ +[] + */ + protected array $handlers = []; + + public function handle(Activity $activity, string|Loggable $detail, User $user): void + { + $activityType = $activity->type; + $handlersToRun = $this->handlers[$activityType] ?? []; + foreach ($handlersToRun as $handlerClass) { + /** @var NotificationHandler $handler */ + $handler = new $handlerClass(); + $handler->handle($activity, $detail, $user); + } + } + + /** + * @param class-string $handlerClass + */ + public function registerHandler(string $activityType, string $handlerClass): void + { + if (!isset($this->handlers[$activityType])) { + $this->handlers[$activityType] = []; + } + + if (!in_array($handlerClass, $this->handlers[$activityType])) { + $this->handlers[$activityType][] = $handlerClass; + } + } + + public function loadDefaultHandlers(): void + { + $this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class); + $this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class); + $this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class); + } +} diff --git a/app/Activity/Tools/ActivityLogger.php b/app/Activity/Tools/ActivityLogger.php index 315e8bfdf..adda36c1b 100644 --- a/app/Activity/Tools/ActivityLogger.php +++ b/app/Activity/Tools/ActivityLogger.php @@ -6,7 +6,7 @@ use BookStack\Activity\DispatchWebhookJob; use BookStack\Activity\Models\Activity; use BookStack\Activity\Models\Loggable; use BookStack\Activity\Models\Webhook; -use BookStack\App\Model; +use BookStack\Activity\Notifications\NotificationManager; use BookStack\Entities\Models\Entity; use BookStack\Facades\Theme; use BookStack\Theming\ThemeEvents; @@ -15,10 +15,16 @@ use Illuminate\Support\Facades\Log; class ActivityLogger { + public function __construct( + protected NotificationManager $notifications + ) { + $this->notifications->loadDefaultHandlers(); + } + /** * Add a generic activity event to the database. */ - public function add(string $type, $detail = '') + public function add(string $type, string|Loggable $detail = ''): void { $detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail; @@ -34,6 +40,7 @@ class ActivityLogger $this->setNotification($type); $this->dispatchWebhooks($type, $detail); + $this->notifications->handle($activity, $detail, user()); Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail); } diff --git a/app/Activity/Tools/EntityWatchers.php b/app/Activity/Tools/EntityWatchers.php new file mode 100644 index 000000000..1ab53cb1c --- /dev/null +++ b/app/Activity/Tools/EntityWatchers.php @@ -0,0 +1,86 @@ +build(); + } + + public function getWatcherUserIds(): array + { + return $this->watchers; + } + + public function isUserIgnoring(int $userId): bool + { + return in_array($userId, $this->ignorers); + } + + protected function build(): void + { + $watches = $this->getRelevantWatches(); + + // Sort before de-duping, so that the order looped below follows book -> chapter -> page ordering + usort($watches, function (Watch $watchA, Watch $watchB) { + $entityTypeDiff = $watchA->watchable_type <=> $watchB->watchable_type; + return $entityTypeDiff === 0 ? ($watchA->user_id <=> $watchB->user_id) : $entityTypeDiff; + }); + + // De-dupe by user id to get their most relevant level + $levelByUserId = []; + foreach ($watches as $watch) { + $levelByUserId[$watch->user_id] = $watch->level; + } + + // Populate the class arrays + $this->watchers = array_keys(array_filter($levelByUserId, fn(int $level) => $level >= $this->watchLevel)); + $this->ignorers = array_keys(array_filter($levelByUserId, fn(int $level) => $level === 0)); + } + + /** + * @return Watch[] + */ + protected function getRelevantWatches(): array + { + /** @var Entity[] $entitiesInvolved */ + $entitiesInvolved = array_filter([ + $this->entity, + $this->entity instanceof BookChild ? $this->entity->book : null, + $this->entity instanceof Page ? $this->entity->chapter : null, + ]); + + $query = Watch::query()->where(function (Builder $query) use ($entitiesInvolved) { + foreach ($entitiesInvolved as $entity) { + $query->orWhere(function (Builder $query) use ($entity) { + $query->where('watchable_type', '=', $entity->getMorphClass()) + ->where('watchable_id', '=', $entity->id); + }); + } + }); + + return $query->get([ + 'level', 'watchable_id', 'watchable_type', 'user_id' + ])->all(); + } +} diff --git a/app/Activity/Tools/UserEntityWatchOptions.php b/app/Activity/Tools/UserEntityWatchOptions.php new file mode 100644 index 000000000..231204fdf --- /dev/null +++ b/app/Activity/Tools/UserEntityWatchOptions.php @@ -0,0 +1,131 @@ +user->can('receive-notifications') && !$this->user->isDefault(); + } + + public function getWatchLevel(): string + { + return WatchLevels::levelValueToName($this->getWatchLevelValue()); + } + + public function isWatching(): bool + { + return $this->getWatchLevelValue() !== WatchLevels::DEFAULT; + } + + public function getWatchedParent(): ?WatchedParentDetails + { + $watchMap = $this->getWatchMap(); + unset($watchMap[$this->entity->getMorphClass()]); + + if (isset($watchMap['chapter'])) { + return new WatchedParentDetails('chapter', $watchMap['chapter']); + } + + if (isset($watchMap['book'])) { + return new WatchedParentDetails('book', $watchMap['book']); + } + + return null; + } + + public function updateLevelByName(string $level): void + { + $levelValue = WatchLevels::levelNameToValue($level); + $this->updateLevelByValue($levelValue); + } + + public function updateLevelByValue(int $level): void + { + if ($level < 0) { + $this->remove(); + return; + } + + $this->updateLevel($level); + } + + public function getWatchMap(): array + { + if (!is_null($this->watchMap)) { + return $this->watchMap; + } + + $entities = [$this->entity]; + if ($this->entity instanceof BookChild) { + $entities[] = $this->entity->book; + } + if ($this->entity instanceof Page && $this->entity->chapter) { + $entities[] = $this->entity->chapter; + } + + $query = Watch::query() + ->where('user_id', '=', $this->user->id) + ->where(function (Builder $subQuery) use ($entities) { + foreach ($entities as $entity) { + $subQuery->orWhere(function (Builder $whereQuery) use ($entity) { + $whereQuery->where('watchable_type', '=', $entity->getMorphClass()) + ->where('watchable_id', '=', $entity->id); + }); + } + }); + + $this->watchMap = $query->get(['watchable_type', 'level']) + ->pluck('level', 'watchable_type') + ->toArray(); + + return $this->watchMap; + } + + protected function getWatchLevelValue() + { + return $this->getWatchMap()[$this->entity->getMorphClass()] ?? WatchLevels::DEFAULT; + } + + protected function updateLevel(int $levelValue): void + { + Watch::query()->updateOrCreate([ + 'watchable_id' => $this->entity->id, + 'watchable_type' => $this->entity->getMorphClass(), + 'user_id' => $this->user->id, + ], [ + 'level' => $levelValue, + ]); + $this->watchMap = null; + } + + protected function remove(): void + { + $this->entityQuery()->delete(); + $this->watchMap = null; + } + + protected function entityQuery(): Builder + { + return Watch::query()->where('watchable_id', '=', $this->entity->id) + ->where('watchable_type', '=', $this->entity->getMorphClass()) + ->where('user_id', '=', $this->user->id); + } +} diff --git a/app/Activity/Tools/WatchedParentDetails.php b/app/Activity/Tools/WatchedParentDetails.php new file mode 100644 index 000000000..5a881c04f --- /dev/null +++ b/app/Activity/Tools/WatchedParentDetails.php @@ -0,0 +1,19 @@ +level === WatchLevels::IGNORE; + } +} diff --git a/app/Activity/WatchLevels.php b/app/Activity/WatchLevels.php new file mode 100644 index 000000000..de3c5e122 --- /dev/null +++ b/app/Activity/WatchLevels.php @@ -0,0 +1,91 @@ + value array. + * @returns array + */ + public static function all(): array + { + $options = []; + foreach ((new \ReflectionClass(static::class))->getConstants() as $name => $value) { + $options[strtolower($name)] = $value; + } + + return $options; + } + + /** + * Get the watch options suited for the given entity. + * @returns array + */ + public static function allSuitedFor(Entity $entity): array + { + $options = static::all(); + + if ($entity instanceof Page) { + unset($options['new']); + } elseif ($entity instanceof Bookshelf) { + return []; + } + + return $options; + } + + /** + * Convert the given name to a level value. + * Defaults to default value if the level does not exist. + */ + public static function levelNameToValue(string $level): int + { + return static::all()[$level] ?? static::DEFAULT; + } + + /** + * Convert the given int level value to a level name. + * Defaults to 'default' level name if not existing. + */ + public static function levelValueToName(int $level): string + { + foreach (static::all() as $name => $value) { + if ($level === $value) { + return $name; + } + } + + return 'default'; + } +} diff --git a/app/App/Providers/AppServiceProvider.php b/app/App/Providers/AppServiceProvider.php index 0c0895bf4..deb664ba6 100644 --- a/app/App/Providers/AppServiceProvider.php +++ b/app/App/Providers/AppServiceProvider.php @@ -9,6 +9,7 @@ use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; use BookStack\Exceptions\BookStackExceptionHandlerPage; +use BookStack\Permissions\PermissionApplicator; use BookStack\Settings\SettingService; use BookStack\Util\CspService; use GuzzleHttp\Client; @@ -79,5 +80,9 @@ class AppServiceProvider extends ServiceProvider 'timeout' => 3, ]); }); + + $this->app->singleton(PermissionApplicator::class, function ($app) { + return new PermissionApplicator(null); + }); } } diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index dcd1af5a1..55d28c684 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -5,6 +5,7 @@ namespace BookStack\Entities\Controllers; use BookStack\Activity\ActivityQueries; use BookStack\Activity\ActivityType; use BookStack\Activity\Models\View; +use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Tools\BookContents; @@ -138,6 +139,7 @@ class BookController extends Controller 'current' => $book, 'bookChildren' => $bookChildren, 'bookParentShelves' => $bookParentShelves, + 'watchOptions' => new UserEntityWatchOptions(user(), $book), 'activity' => $activities->entityActivity($book, 20, 1), 'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($book), ]); diff --git a/app/Entities/Controllers/ChapterController.php b/app/Entities/Controllers/ChapterController.php index 7dcb66903..ee1df0581 100644 --- a/app/Entities/Controllers/ChapterController.php +++ b/app/Entities/Controllers/ChapterController.php @@ -3,6 +3,7 @@ namespace BookStack\Entities\Controllers; use BookStack\Activity\Models\View; +use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Entities\Models\Book; use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Tools\BookContents; @@ -81,6 +82,7 @@ class ChapterController extends Controller 'chapter' => $chapter, 'current' => $chapter, 'sidebarTree' => $sidebarTree, + 'watchOptions' => new UserEntityWatchOptions(user(), $chapter), 'pages' => $pages, 'next' => $nextPreviousLocator->getNext(), 'previous' => $nextPreviousLocator->getPrevious(), diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php index e96d41bb1..624931065 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -4,6 +4,7 @@ namespace BookStack\Entities\Controllers; use BookStack\Activity\Models\View; use BookStack\Activity\Tools\CommentTree; +use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\PageRepo; use BookStack\Entities\Tools\BookContents; @@ -151,6 +152,7 @@ class PageController extends Controller 'sidebarTree' => $sidebarTree, 'commentTree' => $commentTree, 'pageNav' => $pageNav, + 'watchOptions' => new UserEntityWatchOptions(user(), $page), 'next' => $nextPreviousLocator->getNext(), 'previous' => $nextPreviousLocator->getPrevious(), 'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($page), diff --git a/app/Entities/Queries/TopFavourites.php b/app/Entities/Queries/TopFavourites.php index 3f8d2e62e..cbccf35b0 100644 --- a/app/Entities/Queries/TopFavourites.php +++ b/app/Entities/Queries/TopFavourites.php @@ -10,7 +10,7 @@ class TopFavourites extends EntityQuery public function run(int $count, int $skip = 0) { $user = user(); - if (is_null($user) || $user->isDefault()) { + if ($user->isDefault()) { return collect(); } 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/Permissions/PermissionApplicator.php b/app/Permissions/PermissionApplicator.php index b4fafaa9e..a796bdaee 100644 --- a/app/Permissions/PermissionApplicator.php +++ b/app/Permissions/PermissionApplicator.php @@ -8,7 +8,6 @@ use BookStack\Entities\Models\Page; use BookStack\Permissions\Models\EntityPermission; use BookStack\Users\Models\HasCreatorAndUpdater; use BookStack\Users\Models\HasOwner; -use BookStack\Users\Models\Role; use BookStack\Users\Models\User; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Query\Builder as QueryBuilder; @@ -16,6 +15,11 @@ use InvalidArgumentException; class PermissionApplicator { + public function __construct( + protected ?User $user = null + ) { + } + /** * Checks if an entity has a restriction set upon it. * @@ -173,7 +177,7 @@ class PermissionApplicator */ protected function currentUser(): User { - return user(); + return $this->user ?? user(); } /** diff --git a/app/Permissions/PermissionsRepo.php b/app/Permissions/PermissionsRepo.php index 889a6ea08..b41612968 100644 --- a/app/Permissions/PermissionsRepo.php +++ b/app/Permissions/PermissionsRepo.php @@ -12,12 +12,11 @@ use Illuminate\Database\Eloquent\Collection; class PermissionsRepo { - protected JointPermissionBuilder $permissionBuilder; protected array $systemRoles = ['admin', 'public']; - public function __construct(JointPermissionBuilder $permissionBuilder) - { - $this->permissionBuilder = $permissionBuilder; + public function __construct( + protected JointPermissionBuilder $permissionBuilder + ) { } /** diff --git a/app/Settings/UserNotificationPreferences.php b/app/Settings/UserNotificationPreferences.php new file mode 100644 index 000000000..5b267b533 --- /dev/null +++ b/app/Settings/UserNotificationPreferences.php @@ -0,0 +1,46 @@ +getNotificationSetting('own-page-changes'); + } + + public function notifyOnOwnPageComments(): bool + { + return $this->getNotificationSetting('own-page-comments'); + } + + public function notifyOnCommentReplies(): bool + { + return $this->getNotificationSetting('comment-replies'); + } + + public function updateFromSettingsArray(array $settings) + { + $allowList = ['own-page-changes', 'own-page-comments', 'comment-replies']; + foreach ($settings as $setting => $status) { + if (!in_array($setting, $allowList)) { + continue; + } + + $value = $status === 'true' ? 'true' : 'false'; + setting()->putUser($this->user, 'notifications#' . $setting, $value); + } + } + + protected function getNotificationSetting(string $key): bool + { + return setting()->getUser($this->user, 'notifications#' . $key); + } +} diff --git a/app/Users/Controllers/RoleController.php b/app/Users/Controllers/RoleController.php index f6472e4de..0052d829d 100644 --- a/app/Users/Controllers/RoleController.php +++ b/app/Users/Controllers/RoleController.php @@ -13,11 +13,9 @@ use Illuminate\Http\Request; class RoleController extends Controller { - protected PermissionsRepo $permissionsRepo; - - public function __construct(PermissionsRepo $permissionsRepo) - { - $this->permissionsRepo = $permissionsRepo; + public function __construct( + protected PermissionsRepo $permissionsRepo + ) { } /** diff --git a/app/Users/Controllers/UserPreferencesController.php b/app/Users/Controllers/UserPreferencesController.php index b20a8aa37..503aeaeb0 100644 --- a/app/Users/Controllers/UserPreferencesController.php +++ b/app/Users/Controllers/UserPreferencesController.php @@ -2,18 +2,27 @@ namespace BookStack\Users\Controllers; +use BookStack\Activity\Models\Watch; use BookStack\Http\Controller; +use BookStack\Permissions\PermissionApplicator; +use BookStack\Settings\UserNotificationPreferences; use BookStack\Settings\UserShortcutMap; use BookStack\Users\UserRepo; use Illuminate\Http\Request; class UserPreferencesController extends Controller { - protected UserRepo $userRepo; + public function __construct( + protected UserRepo $userRepo + ) { + } - public function __construct(UserRepo $userRepo) + /** + * Show the overview for user preferences. + */ + public function index() { - $this->userRepo = $userRepo; + return view('users.preferences.index'); } /** @@ -24,6 +33,8 @@ class UserPreferencesController extends Controller $shortcuts = UserShortcutMap::fromUserPreferences(); $enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false); + $this->setPageTitle(trans('preferences.shortcuts_interface')); + return view('users.preferences.shortcuts', [ 'shortcuts' => $shortcuts, 'enabled' => $enabled, @@ -47,6 +58,46 @@ class UserPreferencesController extends Controller return redirect('/preferences/shortcuts'); } + /** + * Show the notification preferences for the current user. + */ + public function showNotifications(PermissionApplicator $permissions) + { + $this->checkPermission('receive-notifications'); + $this->preventGuestAccess(); + + $preferences = (new UserNotificationPreferences(user())); + + $query = Watch::query()->where('user_id', '=', user()->id); + $query = $permissions->restrictEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type'); + $watches = $query->with('watchable')->paginate(20); + + $this->setPageTitle(trans('preferences.notifications')); + return view('users.preferences.notifications', [ + 'preferences' => $preferences, + 'watches' => $watches, + ]); + } + + /** + * Update the notification preferences for the current user. + */ + public function updateNotifications(Request $request) + { + $this->checkPermission('receive-notifications'); + $this->preventGuestAccess(); + $data = $this->validate($request, [ + 'preferences' => ['required', 'array'], + 'preferences.*' => ['required', 'string'], + ]); + + $preferences = (new UserNotificationPreferences(user())); + $preferences->updateFromSettingsArray($data['preferences']); + $this->showSuccessNotification(trans('preferences.notifications_update_success')); + + return redirect('/preferences/notifications'); + } + /** * Update the preferred view format for a list view of the given type. */ @@ -123,7 +174,7 @@ class UserPreferencesController extends Controller { $validated = $this->validate($request, [ 'language' => ['required', 'string', 'max:20'], - 'active' => ['required', 'bool'], + 'active' => ['required', 'bool'], ]); $currentFavoritesStr = setting()->getForCurrentUser('code-language-favourites', ''); diff --git a/app/Users/Models/User.php b/app/Users/Models/User.php index 08cab69fb..be3e9b9b3 100644 --- a/app/Users/Models/User.php +++ b/app/Users/Models/User.php @@ -88,8 +88,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon /** * This holds the default user when loaded. - * - * @var null|User */ protected static ?User $defaultUser = null; @@ -107,6 +105,11 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon return static::$defaultUser; } + public static function clearDefault(): void + { + static::$defaultUser = null; + } + /** * Check if the user is the default public user. */ diff --git a/database/migrations/2023_07_25_124945_add_receive_notifications_role_permissions.php b/database/migrations/2023_07_25_124945_add_receive_notifications_role_permissions.php new file mode 100644 index 000000000..4872e421e --- /dev/null +++ b/database/migrations/2023_07_25_124945_add_receive_notifications_role_permissions.php @@ -0,0 +1,51 @@ +insertGetId([ + 'name' => 'receive-notifications', + 'display_name' => 'Receive & Manage Notifications', + 'created_at' => Carbon::now()->toDateTimeString(), + 'updated_at' => Carbon::now()->toDateTimeString(), + ]); + + $adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id; + DB::table('permission_role')->insert([ + 'role_id' => $adminRoleId, + 'permission_id' => $permissionId, + ]); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + $permission = DB::table('role_permissions') + ->where('name', '=', 'receive-notifications') + ->first(); + + if ($permission) { + DB::table('permission_role')->where([ + 'permission_id' => $permission->id, + ])->delete(); + } + + DB::table('role_permissions') + ->where('name', '=', 'receive-notifications') + ->delete(); + } +}; diff --git a/database/migrations/2023_07_31_104430_create_watches_table.php b/database/migrations/2023_07_31_104430_create_watches_table.php new file mode 100644 index 000000000..e2a5c20d0 --- /dev/null +++ b/database/migrations/2023_07_31_104430_create_watches_table.php @@ -0,0 +1,37 @@ +increments('id'); + $table->integer('user_id')->index(); + $table->integer('watchable_id'); + $table->string('watchable_type', 100); + $table->tinyInteger('level', false, true)->index(); + $table->timestamps(); + + $table->index(['watchable_id', 'watchable_type'], 'watchable_index'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('watches'); + } +}; diff --git a/database/seeders/DummyContentSeeder.php b/database/seeders/DummyContentSeeder.php index 0f030d671..47e8d1d7c 100644 --- a/database/seeders/DummyContentSeeder.php +++ b/database/seeders/DummyContentSeeder.php @@ -27,6 +27,8 @@ class DummyContentSeeder extends Seeder // Create an editor user $editorUser = User::factory()->create(); $editorRole = Role::getRole('editor'); + $additionalEditorPerms = ['receive-notifications', 'comment-create-all']; + $editorRole->permissions()->syncWithoutDetaching(RolePermission::whereIn('name', $additionalEditorPerms)->pluck('id')); $editorUser->attachRole($editorRole); // Create a viewer user diff --git a/lang/en/activities.php b/lang/en/activities.php index a96299ea7..d5b55c03d 100644 --- a/lang/en/activities.php +++ b/lang/en/activities.php @@ -58,6 +58,9 @@ return [ 'favourite_add_notification' => '":name" has been added to your favourites', 'favourite_remove_notification' => '":name" has been removed from your favourites', + // Watching + 'watch_update_level_notification' => 'Watch preferences successfully updated', + // Auth 'auth_login' => 'logged in', 'auth_register' => 'registered as new user', diff --git a/lang/en/common.php b/lang/en/common.php index de7937b2b..47b74d5b6 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -42,6 +42,7 @@ return [ 'remove' => 'Remove', 'add' => 'Add', 'configure' => 'Configure', + 'manage' => 'Manage', 'fullscreen' => 'Fullscreen', 'favourite' => 'Favourite', 'unfavourite' => 'Unfavourite', diff --git a/lang/en/entities.php b/lang/en/entities.php index 4fb043aa9..b1b0e5236 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -403,4 +403,28 @@ return [ 'references' => 'References', 'references_none' => 'There are no tracked references to this item.', 'references_to_desc' => 'Shown below are all the known pages in the system that link to this item.', + + // Watch Options + 'watch' => 'Watch', + 'watch_title_default' => 'Default Preferences', + 'watch_desc_default' => 'Revert watching to just your default notification preferences.', + 'watch_title_ignore' => 'Ignore', + 'watch_desc_ignore' => 'Ignore all notifications, including those from user-level preferences.', + 'watch_title_new' => 'New Pages', + 'watch_desc_new' => 'Notify when any new page is created within this item.', + 'watch_title_updates' => 'All Page Updates', + 'watch_desc_updates' => 'Notify upon all new pages and page changes.', + 'watch_desc_updates_page' => 'Notify upon all page changes.', + 'watch_title_comments' => 'All Page Updates & Comments', + 'watch_desc_comments' => 'Notify upon all new pages, page changes and new comments.', + 'watch_desc_comments_page' => 'Notify upon page changes and new comments.', + 'watch_change_default' => 'Change default notification preferences', + 'watch_detail_ignore' => 'Ignoring notifications', + 'watch_detail_new' => 'Watching for new pages', + 'watch_detail_updates' => 'Watching new pages and updates', + 'watch_detail_comments' => 'Watching new pages, updates & comments', + 'watch_detail_parent_book' => 'Watching via parent book', + 'watch_detail_parent_book_ignore' => 'Ignoring via parent book', + 'watch_detail_parent_chapter' => 'Watching via parent chapter', + 'watch_detail_parent_chapter_ignore' => 'Ignoring via parent chapter', ]; diff --git a/lang/en/notifications.php b/lang/en/notifications.php new file mode 100644 index 000000000..5539ae9a9 --- /dev/null +++ b/lang/en/notifications.php @@ -0,0 +1,26 @@ + 'New comment on page: :pageName', + 'new_comment_intro' => 'A user has commented on a page in :appName:', + 'new_page_subject' => 'New page: :pageName', + 'new_page_intro' => 'A new page has been created in :appName:', + 'updated_page_subject' => 'Updated page: :pageName', + 'updated_page_intro' => 'A page has been updated in :appName:', + 'updated_page_debounce' => 'To prevent a mass of notifications, for a while you won\'t be sent notifications for further edits to this page by the same editor.', + + 'detail_page_name' => 'Page Name:', + 'detail_commenter' => 'Commenter:', + 'detail_comment' => 'Comment:', + 'detail_created_by' => 'Created By:', + 'detail_updated_by' => 'Updated By:', + + 'action_view_comment' => 'View Comment', + 'action_view_page' => 'View Page', + + 'footer_reason' => 'This notification was sent to you because :link cover this type of activity for this item.', + 'footer_reason_link' => 'your notification preferences', +]; diff --git a/lang/en/preferences.php b/lang/en/preferences.php index e9a47461b..118e8ba82 100644 --- a/lang/en/preferences.php +++ b/lang/en/preferences.php @@ -5,6 +5,8 @@ */ return [ + 'preferences' => 'Preferences', + 'shortcuts' => 'Shortcuts', 'shortcuts_interface' => 'Interface Keyboard Shortcuts', 'shortcuts_toggle_desc' => 'Here you can enable or disable keyboard system interface shortcuts, used for navigation and actions.', @@ -15,4 +17,17 @@ return [ 'shortcuts_save' => 'Save Shortcuts', 'shortcuts_overlay_desc' => 'Note: When shortcuts are enabled a helper overlay is available via pressing "?" which will highlight the available shortcuts for actions currently visible on the screen.', 'shortcuts_update_success' => 'Shortcut preferences have been updated!', -]; \ No newline at end of file + 'shortcuts_overview_desc' => 'Manage keyboard shortcuts you can use to navigate the system user interface.', + + 'notifications' => 'Notification Preferences', + 'notifications_desc' => 'Control the email notifications you receive when certain activity is performed within the system.', + 'notifications_opt_own_page_changes' => 'Notify upon changes to pages I own', + 'notifications_opt_own_page_comments' => 'Notify upon comments on pages I own', + 'notifications_opt_comment_replies' => 'Notify upon replies to my comments', + 'notifications_save' => 'Save Preferences', + 'notifications_update_success' => 'Notification preferences have been updated!', + 'notifications_watched' => 'Watched & Ignored Items', + 'notifications_watched_desc' => ' Below are the items that have custom watch preferences applied. To update your preferences for these, view the item then find the watch options in the sidebar.', + + 'profile_overview_desc' => ' Manage your user profile details including preferred language and authentication options.', +]; diff --git a/lang/en/settings.php b/lang/en/settings.php index c110e8992..8821c77f0 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -163,6 +163,7 @@ return [ 'role_manage_settings' => 'Manage app settings', 'role_export_content' => 'Export content', 'role_editor_change' => 'Change page editor', + 'role_notifications' => 'Receive & manage notifications', 'role_asset' => 'Asset Permissions', 'roles_system_warning' => 'Be aware that access to any of the above three permissions can allow a user to alter their own privileges or the privileges of others in the system. Only assign roles with these permissions to trusted users.', 'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.', diff --git a/resources/icons/user-preferences.svg b/resources/icons/user-preferences.svg new file mode 100644 index 000000000..5ae1773ca --- /dev/null +++ b/resources/icons/user-preferences.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/watch-ignore.svg b/resources/icons/watch-ignore.svg new file mode 100644 index 000000000..2c6ffc24a --- /dev/null +++ b/resources/icons/watch-ignore.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/view.svg b/resources/icons/watch.svg similarity index 87% rename from resources/icons/view.svg rename to resources/icons/watch.svg index c95c8875c..0be661912 100644 --- a/resources/icons/view.svg +++ b/resources/icons/watch.svg @@ -1,4 +1,3 @@ - \ No newline at end of file diff --git a/resources/js/components/dropdown.js b/resources/js/components/dropdown.js index b68f332b6..2c5919a37 100644 --- a/resources/js/components/dropdown.js +++ b/resources/js/components/dropdown.js @@ -132,6 +132,7 @@ export class Dropdown extends Component { onSelect(this.toggle, event => { event.stopPropagation(); + event.preventDefault(); this.show(event); if (event instanceof KeyboardEvent) { keyboardNavHandler.focusNext(); diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index a8604b81b..50776ea28 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -353,7 +353,7 @@ body.flexbox { margin-inline-end: $-xl; grid-template-columns: 1fr 4fr 1fr; grid-template-areas: "a b c"; - grid-column-gap: $-xxl; + grid-column-gap: $-xl; .tri-layout-right { grid-area: c; min-width: 0; @@ -378,6 +378,14 @@ body.flexbox { padding-inline-end: $-l; } } +@include between($xxl, $xxxl) { + .tri-layout-container { + grid-template-columns: 1fr calc(940px + (2 * $-m)) 1fr; + grid-column-gap: $-s; + margin-inline-start: $-m; + margin-inline-end: $-m; + } +} @include between($l, $xxl) { .tri-layout-left { position: sticky; diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index ad0803e71..323551196 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -672,7 +672,7 @@ ul.pagination { @include lightDark(color, #555, #eee); fill: currentColor; text-align: start !important; - max-height: 500px; + max-height: 80vh; overflow-y: auto; &.anchor-left { inset-inline-end: auto; @@ -681,6 +681,10 @@ ul.pagination { &.wide { min-width: 220px; } + &.xl-limited { + width: 280px; + max-width: 100%; + } .text-muted { color: #999; fill: #999; @@ -705,6 +709,11 @@ ul.pagination { white-space: nowrap; line-height: 1.4; cursor: pointer; + &.break-text { + white-space: normal; + word-wrap: break-word; + overflow-wrap: break-word; + } &:hover, &:focus { text-decoration: none; background-color: var(--color-primary-light); diff --git a/resources/sass/_text.scss b/resources/sass/_text.scss index 7cade9607..a3e6f09ac 100644 --- a/resources/sass/_text.scss +++ b/resources/sass/_text.scss @@ -365,6 +365,7 @@ li.checkbox-item, li.task-list-item { } .break-text { + white-space: normal; word-wrap: break-word; overflow-wrap: break-word; } diff --git a/resources/sass/_variables.scss b/resources/sass/_variables.scss index a3598e29c..35586bf58 100644 --- a/resources/sass/_variables.scss +++ b/resources/sass/_variables.scss @@ -2,6 +2,7 @@ /////////////// // Screen breakpoints +$xxxl: 1700px; $xxl: 1400px; $xl: 1100px; $l: 1000px; diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index 8bb41c18b..75b01a379 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -70,7 +70,7 @@
{{ trans('common.details') }}