diff --git a/.env.example.complete b/.env.example.complete index c0fed8c4e..37b46fec2 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -100,8 +100,7 @@ MEMCACHED_SERVERS=127.0.0.1:11211:100 REDIS_SERVERS=127.0.0.1:6379:0 # Queue driver to use -# Queue not really currently used but may be configurable in the future. -# Would advise not to change this for now. +# Can be 'sync', 'database' or 'redis' QUEUE_CONNECTION=sync # Storage system to use diff --git a/app/Actions/ActivityLogger.php b/app/Actions/ActivityLogger.php new file mode 100644 index 000000000..870e7f96d --- /dev/null +++ b/app/Actions/ActivityLogger.php @@ -0,0 +1,115 @@ +permissionService = $permissionService; + } + + /** + * Add a generic activity event to the database. + * + * @param string|Loggable $detail + */ + public function add(string $type, $detail = '') + { + $detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail; + + $activity = $this->newActivityForUser($type); + $activity->detail = $detailToStore; + + if ($detail instanceof Entity) { + $activity->entity_id = $detail->id; + $activity->entity_type = $detail->getMorphClass(); + } + + $activity->save(); + $this->setNotification($type); + $this->dispatchWebhooks($type, $detail); + } + + /** + * Get a new activity instance for the current user. + */ + protected function newActivityForUser(string $type): Activity + { + $ip = request()->ip() ?? ''; + + return (new Activity())->forceFill([ + 'type' => strtolower($type), + 'user_id' => user()->id, + 'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip, + ]); + } + + /** + * Removes the entity attachment from each of its activities + * and instead uses the 'extra' field with the entities name. + * Used when an entity is deleted. + */ + public function removeEntity(Entity $entity) + { + $entity->activity()->update([ + 'detail' => $entity->name, + 'entity_id' => null, + 'entity_type' => null, + ]); + } + + /** + * Flashes a notification message to the session if an appropriate message is available. + */ + protected function setNotification(string $type): void + { + $notificationTextKey = 'activities.' . $type . '_notification'; + if (trans()->has($notificationTextKey)) { + $message = trans($notificationTextKey); + session()->flash('success', $message); + } + } + + /** + * @param string|Loggable $detail + */ + protected function dispatchWebhooks(string $type, $detail): void + { + $webhooks = Webhook::query() + ->whereHas('trackedEvents', function(Builder $query) use ($type) { + $query->where('event', '=', $type) + ->orWhere('event', '=', 'all'); + }) + ->where('active', '=', true) + ->get(); + + foreach ($webhooks as $webhook) { + dispatch(new DispatchWebhookJob($webhook, $type, $detail)); + } + } + + /** + * Log out a failed login attempt, Providing the given username + * as part of the message if the '%u' string is used. + */ + public function logFailedLogin(string $username) + { + $message = config('logging.failed_login.message'); + if (!$message) { + return; + } + + $message = str_replace('%u', $username, $message); + $channel = config('logging.failed_login.channel'); + Log::channel($channel)->warning($message); + } +} diff --git a/app/Actions/ActivityService.php b/app/Actions/ActivityQueries.php similarity index 51% rename from app/Actions/ActivityService.php rename to app/Actions/ActivityQueries.php index 73dc76de0..b75994416 100644 --- a/app/Actions/ActivityService.php +++ b/app/Actions/ActivityQueries.php @@ -8,84 +8,25 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; -use BookStack\Interfaces\Loggable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\Relation; -use Illuminate\Support\Facades\Log; -class ActivityService +class ActivityQueries { - protected $activity; protected $permissionService; - public function __construct(Activity $activity, PermissionService $permissionService) + public function __construct(PermissionService $permissionService) { - $this->activity = $activity; $this->permissionService = $permissionService; } - /** - * Add activity data to database for an entity. - */ - public function addForEntity(Entity $entity, string $type) - { - $activity = $this->newActivityForUser($type); - $entity->activity()->save($activity); - $this->setNotification($type); - } - - /** - * Add a generic activity event to the database. - * - * @param string|Loggable $detail - */ - public function add(string $type, $detail = '') - { - if ($detail instanceof Loggable) { - $detail = $detail->logDescriptor(); - } - - $activity = $this->newActivityForUser($type); - $activity->detail = $detail; - $activity->save(); - $this->setNotification($type); - } - - /** - * Get a new activity instance for the current user. - */ - protected function newActivityForUser(string $type): Activity - { - $ip = request()->ip() ?? ''; - - return $this->activity->newInstance()->forceFill([ - 'type' => strtolower($type), - 'user_id' => user()->id, - 'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip, - ]); - } - - /** - * Removes the entity attachment from each of its activities - * and instead uses the 'extra' field with the entities name. - * Used when an entity is deleted. - */ - public function removeEntity(Entity $entity) - { - $entity->activity()->update([ - 'detail' => $entity->name, - 'entity_id' => null, - 'entity_type' => null, - ]); - } - /** * Gets the latest activity. */ public function latest(int $count = 20, int $page = 0): array { $activityList = $this->permissionService - ->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type') + ->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type') ->orderBy('created_at', 'desc') ->with(['user', 'entity']) ->skip($count * $page) @@ -111,7 +52,7 @@ class ActivityService $queryIds[(new Page())->getMorphClass()] = $entity->pages()->scopes('visible')->pluck('id'); } - $query = $this->activity->newQuery(); + $query = Activity::query(); $query->where(function (Builder $query) use ($queryIds) { foreach ($queryIds as $morphClass => $idArr) { $query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) { @@ -138,7 +79,7 @@ class ActivityService public function userActivity(User $user, int $count = 20, int $page = 0): array { $activityList = $this->permissionService - ->filterRestrictedEntityRelations($this->activity->newQuery(), 'activities', 'entity_id', 'entity_type') + ->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type') ->orderBy('created_at', 'desc') ->where('user_id', '=', $user->id) ->skip($count * $page) @@ -152,8 +93,6 @@ class ActivityService * Filters out similar activity. * * @param Activity[] $activities - * - * @return array */ protected function filterSimilar(iterable $activities): array { @@ -171,31 +110,4 @@ class ActivityService return $newActivity; } - /** - * Flashes a notification message to the session if an appropriate message is available. - */ - protected function setNotification(string $type) - { - $notificationTextKey = 'activities.' . $type . '_notification'; - if (trans()->has($notificationTextKey)) { - $message = trans($notificationTextKey); - session()->flash('success', $message); - } - } - - /** - * Log out a failed login attempt, Providing the given username - * as part of the message if the '%u' string is used. - */ - public function logFailedLogin(string $username) - { - $message = config('logging.failed_login.message'); - if (!$message) { - return; - } - - $message = str_replace('%u', $username, $message); - $channel = config('logging.failed_login.channel'); - Log::channel($channel)->warning($message); - } -} +} \ No newline at end of file diff --git a/app/Actions/ActivityType.php b/app/Actions/ActivityType.php index 60b1630e0..8b5213a8b 100644 --- a/app/Actions/ActivityType.php +++ b/app/Actions/ActivityType.php @@ -53,4 +53,16 @@ class ActivityType const MFA_SETUP_METHOD = 'mfa_setup_method'; const MFA_REMOVE_METHOD = 'mfa_remove_method'; + + const WEBHOOK_CREATE = 'webhook_create'; + const WEBHOOK_UPDATE = 'webhook_update'; + const WEBHOOK_DELETE = 'webhook_delete'; + + /** + * Get all the possible values. + */ + public static function all(): array + { + return (new \ReflectionClass(static::class))->getConstants(); + } } diff --git a/app/Actions/CommentRepo.php b/app/Actions/CommentRepo.php index 8061c4542..2f2dd658a 100644 --- a/app/Actions/CommentRepo.php +++ b/app/Actions/CommentRepo.php @@ -45,7 +45,7 @@ class CommentRepo $comment->parent_id = $parent_id; $entity->comments()->save($comment); - ActivityService::addForEntity($entity, ActivityType::COMMENTED_ON); + ActivityService::add(ActivityType::COMMENTED_ON, $entity); return $comment; } diff --git a/app/Actions/DispatchWebhookJob.php b/app/Actions/DispatchWebhookJob.php new file mode 100644 index 000000000..69d04d36c --- /dev/null +++ b/app/Actions/DispatchWebhookJob.php @@ -0,0 +1,109 @@ +webhook = $webhook; + $this->event = $event; + $this->detail = $detail; + $this->initiator = user(); + $this->initiatedTime = time(); + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $response = Http::asJson() + ->withOptions(['allow_redirects' => ['strict' => true]]) + ->timeout(3) + ->post($this->webhook->endpoint, $this->buildWebhookData()); + + if ($response->failed()) { + Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}"); + } + } + + protected function buildWebhookData(): array + { + $textParts = [ + $this->initiator->name, + trans('activities.' . $this->event), + ]; + + if ($this->detail instanceof Entity) { + $textParts[] = '"' . $this->detail->name . '"'; + } + + $data = [ + 'event' => $this->event, + 'text' => implode(' ', $textParts), + 'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(), + 'triggered_by' => $this->initiator->attributesToArray(), + 'triggered_by_profile_url' => $this->initiator->getProfileUrl(), + 'webhook_id' => $this->webhook->id, + 'webhook_name' => $this->webhook->name, + ]; + + if (method_exists($this->detail, 'getUrl')) { + $data['url'] = $this->detail->getUrl(); + } + + if ($this->detail instanceof Model) { + $data['related_item'] = $this->detail->attributesToArray(); + } + + return $data; + } +} diff --git a/app/Actions/Webhook.php b/app/Actions/Webhook.php new file mode 100644 index 000000000..ed13856f3 --- /dev/null +++ b/app/Actions/Webhook.php @@ -0,0 +1,75 @@ +hasMany(WebhookTrackedEvent::class); + } + + /** + * Update the tracked events for a webhook from the given list of event types. + */ + public function updateTrackedEvents(array $events): void + { + $this->trackedEvents()->delete(); + + $eventsToStore = array_intersect($events, array_values(ActivityType::all())); + if (in_array('all', $events)) { + $eventsToStore = ['all']; + } + + $trackedEvents = []; + foreach ($eventsToStore as $event) { + $trackedEvents[] = new WebhookTrackedEvent(['event' => $event]); + } + + $this->trackedEvents()->saveMany($trackedEvents); + } + + /** + * Check if this webhook tracks the given event. + */ + public function tracksEvent(string $event): bool + { + return $this->trackedEvents->pluck('event')->contains($event); + } + + /** + * Get a URL for this webhook within the settings interface. + */ + public function getUrl(string $path = ''): string + { + return url('/settings/webhooks/' . $this->id . '/' . ltrim($path, '/')); + } + + /** + * Get the string descriptor for this item. + */ + public function logDescriptor(): string + { + return "({$this->id}) {$this->name}"; + } +} diff --git a/app/Actions/WebhookTrackedEvent.php b/app/Actions/WebhookTrackedEvent.php new file mode 100644 index 000000000..a0530620a --- /dev/null +++ b/app/Actions/WebhookTrackedEvent.php @@ -0,0 +1,18 @@ + env('QUEUE_CONNECTION', 'sync'), // Queue connection configuration diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index 0eb402284..b55334295 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -14,6 +14,7 @@ use BookStack\Entities\Tools\SlugGenerator; use BookStack\Facades\Permissions; use BookStack\Interfaces\Deletable; use BookStack\Interfaces\Favouritable; +use BookStack\Interfaces\Loggable; use BookStack\Interfaces\Sluggable; use BookStack\Interfaces\Viewable; use BookStack\Model; @@ -45,7 +46,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @method static Builder withLastView() * @method static Builder withViewCount() */ -abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable +abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable, Loggable { use SoftDeletes; use HasCreatorAndUpdater; @@ -321,4 +322,12 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable ->where('user_id', '=', user()->id) ->exists(); } + + /** + * {@inheritdoc} + */ + public function logDescriptor(): string + { + return "({$this->id}) {$this->name}"; + } } diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index a692bbaf7..7c4b280a8 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -91,7 +91,7 @@ class BookRepo { $book = new Book(); $this->baseRepo->create($book, $input); - Activity::addForEntity($book, ActivityType::BOOK_CREATE); + Activity::add(ActivityType::BOOK_CREATE, $book); return $book; } @@ -102,7 +102,7 @@ class BookRepo public function update(Book $book, array $input): Book { $this->baseRepo->update($book, $input); - Activity::addForEntity($book, ActivityType::BOOK_UPDATE); + Activity::add(ActivityType::BOOK_UPDATE, $book); return $book; } @@ -127,7 +127,7 @@ class BookRepo { $trashCan = new TrashCan(); $trashCan->softDestroyBook($book); - Activity::addForEntity($book, ActivityType::BOOK_DELETE); + Activity::add(ActivityType::BOOK_DELETE, $book); $trashCan->autoClearOld(); } diff --git a/app/Entities/Repos/BookshelfRepo.php b/app/Entities/Repos/BookshelfRepo.php index 3146c7cba..ceabba59a 100644 --- a/app/Entities/Repos/BookshelfRepo.php +++ b/app/Entities/Repos/BookshelfRepo.php @@ -90,7 +90,7 @@ class BookshelfRepo $shelf = new Bookshelf(); $this->baseRepo->create($shelf, $input); $this->updateBooks($shelf, $bookIds); - Activity::addForEntity($shelf, ActivityType::BOOKSHELF_CREATE); + Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf); return $shelf; } @@ -106,7 +106,7 @@ class BookshelfRepo $this->updateBooks($shelf, $bookIds); } - Activity::addForEntity($shelf, ActivityType::BOOKSHELF_UPDATE); + Activity::add(ActivityType::BOOKSHELF_UPDATE, $shelf); return $shelf; } @@ -177,7 +177,7 @@ class BookshelfRepo { $trashCan = new TrashCan(); $trashCan->softDestroyShelf($shelf); - Activity::addForEntity($shelf, ActivityType::BOOKSHELF_DELETE); + Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf); $trashCan->autoClearOld(); } } diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index 68330dd57..b10fc4530 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -49,7 +49,7 @@ class ChapterRepo $chapter->book_id = $parentBook->id; $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1; $this->baseRepo->create($chapter, $input); - Activity::addForEntity($chapter, ActivityType::CHAPTER_CREATE); + Activity::add(ActivityType::CHAPTER_CREATE, $chapter); return $chapter; } @@ -60,7 +60,7 @@ class ChapterRepo public function update(Chapter $chapter, array $input): Chapter { $this->baseRepo->update($chapter, $input); - Activity::addForEntity($chapter, ActivityType::CHAPTER_UPDATE); + Activity::add(ActivityType::CHAPTER_UPDATE, $chapter); return $chapter; } @@ -74,7 +74,7 @@ class ChapterRepo { $trashCan = new TrashCan(); $trashCan->softDestroyChapter($chapter); - Activity::addForEntity($chapter, ActivityType::CHAPTER_DELETE); + Activity::add(ActivityType::CHAPTER_DELETE, $chapter); $trashCan->autoClearOld(); } @@ -103,7 +103,7 @@ class ChapterRepo $chapter->changeBook($parent->id); $chapter->rebuildPermissions(); - Activity::addForEntity($chapter, ActivityType::CHAPTER_MOVE); + Activity::add(ActivityType::CHAPTER_MOVE, $chapter); return $parent; } diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 24fc1e7dd..b315bead9 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -171,7 +171,7 @@ class PageRepo $draft->indexForSearch(); $draft->refresh(); - Activity::addForEntity($draft, ActivityType::PAGE_CREATE); + Activity::add(ActivityType::PAGE_CREATE, $draft); return $draft; } @@ -205,7 +205,7 @@ class PageRepo $this->savePageRevision($page, $summary); } - Activity::addForEntity($page, ActivityType::PAGE_UPDATE); + Activity::add(ActivityType::PAGE_UPDATE, $page); return $page; } @@ -281,7 +281,7 @@ class PageRepo { $trashCan = new TrashCan(); $trashCan->softDestroyPage($page); - Activity::addForEntity($page, ActivityType::PAGE_DELETE); + Activity::add(ActivityType::PAGE_DELETE, $page); $trashCan->autoClearOld(); } @@ -312,7 +312,7 @@ class PageRepo $summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]); $this->savePageRevision($page, $summary); - Activity::addForEntity($page, ActivityType::PAGE_RESTORE); + Activity::add(ActivityType::PAGE_RESTORE, $page); return $page; } @@ -341,7 +341,7 @@ class PageRepo $page->changeBook($newBookId); $page->rebuildPermissions(); - Activity::addForEntity($page, ActivityType::PAGE_MOVE); + Activity::add(ActivityType::PAGE_MOVE, $page); return $parent; } diff --git a/app/Entities/Tools/PermissionsUpdater.php b/app/Entities/Tools/PermissionsUpdater.php index 4e8351776..c771ee4b6 100644 --- a/app/Entities/Tools/PermissionsUpdater.php +++ b/app/Entities/Tools/PermissionsUpdater.php @@ -35,7 +35,7 @@ class PermissionsUpdater $entity->save(); $entity->rebuildPermissions(); - Activity::addForEntity($entity, ActivityType::PERMISSIONS_UPDATE); + Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity); } /** diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 427d88a02..742e10472 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -2,11 +2,11 @@ namespace BookStack\Http\Controllers\Auth; -use Activity; use BookStack\Auth\Access\LoginService; use BookStack\Auth\Access\SocialAuthService; use BookStack\Exceptions\LoginAttemptEmailNeededException; use BookStack\Exceptions\LoginAttemptException; +use BookStack\Facades\Activity; use BookStack\Http\Controllers\Controller; use Illuminate\Foundation\Auth\AuthenticatesUsers; use Illuminate\Http\Request; diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index 51cba642c..5434afaf8 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -3,6 +3,7 @@ namespace BookStack\Http\Controllers; use Activity; +use BookStack\Actions\ActivityQueries; use BookStack\Actions\ActivityType; use BookStack\Actions\View; use BookStack\Entities\Models\Bookshelf; @@ -101,7 +102,7 @@ class BookController extends Controller if ($bookshelf) { $bookshelf->appendBook($book); - Activity::addForEntity($bookshelf, ActivityType::BOOKSHELF_UPDATE); + Activity::add(ActivityType::BOOKSHELF_UPDATE, $bookshelf); } return redirect($book->getUrl()); @@ -110,7 +111,7 @@ class BookController extends Controller /** * Display the specified book. */ - public function show(Request $request, string $slug) + public function show(Request $request, ActivityQueries $activities, string $slug) { $book = $this->bookRepo->getBySlug($slug); $bookChildren = (new BookContents($book))->getTree(true); @@ -128,7 +129,7 @@ class BookController extends Controller 'current' => $book, 'bookChildren' => $bookChildren, 'bookParentShelves' => $bookParentShelves, - 'activity' => Activity::entityActivity($book, 20, 1), + 'activity' => $activities->entityActivity($book, 20, 1), ]); } diff --git a/app/Http/Controllers/BookSortController.php b/app/Http/Controllers/BookSortController.php index 0bd394778..010e74fa4 100644 --- a/app/Http/Controllers/BookSortController.php +++ b/app/Http/Controllers/BookSortController.php @@ -71,7 +71,7 @@ class BookSortController extends Controller // Rebuild permissions and add activity for involved books. $booksInvolved->each(function (Book $book) { - Activity::addForEntity($book, ActivityType::BOOK_SORT); + Activity::add(ActivityType::BOOK_SORT, $book); }); return redirect($book->getUrl()); diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php index 32248ee46..3bcdfbfb8 100644 --- a/app/Http/Controllers/BookshelfController.php +++ b/app/Http/Controllers/BookshelfController.php @@ -3,6 +3,7 @@ namespace BookStack\Http\Controllers; use Activity; +use BookStack\Actions\ActivityQueries; use BookStack\Actions\View; use BookStack\Entities\Models\Book; use BookStack\Entities\Repos\BookshelfRepo; @@ -101,7 +102,7 @@ class BookshelfController extends Controller * * @throws NotFoundException */ - public function show(string $slug) + public function show(ActivityQueries $activities, string $slug) { $shelf = $this->bookshelfRepo->getBySlug($slug); $this->checkOwnablePermission('book-view', $shelf); @@ -124,7 +125,7 @@ class BookshelfController extends Controller 'shelf' => $shelf, 'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks, 'view' => $view, - 'activity' => Activity::entityActivity($shelf, 20, 1), + 'activity' => $activities->entityActivity($shelf, 20, 1), 'order' => $order, 'sort' => $sort, ]); diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index df810a3cf..9e66a0640 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -2,7 +2,7 @@ namespace BookStack\Http\Controllers; -use Activity; +use BookStack\Actions\ActivityQueries; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\RecentlyViewed; @@ -16,9 +16,9 @@ class HomeController extends Controller /** * Display the homepage. */ - public function index() + public function index(ActivityQueries $activities) { - $activity = Activity::latest(10); + $activity = $activities->latest(10); $draftPages = []; if ($this->isSignedIn()) { diff --git a/app/Http/Controllers/MaintenanceController.php b/app/Http/Controllers/MaintenanceController.php index d6abe4682..f13266d7c 100644 --- a/app/Http/Controllers/MaintenanceController.php +++ b/app/Http/Controllers/MaintenanceController.php @@ -67,7 +67,7 @@ class MaintenanceController extends Controller $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email'); try { - user()->notify(new TestEmail()); + user()->notifyNow(new TestEmail()); $this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email])); } catch (\Exception $exception) { $errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage(); diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php index 7ba52d486..25b2a5851 100644 --- a/app/Http/Controllers/RoleController.php +++ b/app/Http/Controllers/RoleController.php @@ -23,7 +23,7 @@ class RoleController extends Controller /** * Show a listing of the roles in the system. */ - public function list() + public function index() { $this->checkPermission('user-roles-manage'); $roles = $this->permissionsRepo->getAllRoles(); diff --git a/app/Http/Controllers/UserProfileController.php b/app/Http/Controllers/UserProfileController.php index 09ae4c1bd..63565f3b2 100644 --- a/app/Http/Controllers/UserProfileController.php +++ b/app/Http/Controllers/UserProfileController.php @@ -2,6 +2,7 @@ namespace BookStack\Http\Controllers; +use BookStack\Actions\ActivityQueries; use BookStack\Auth\UserRepo; class UserProfileController extends Controller @@ -9,11 +10,11 @@ class UserProfileController extends Controller /** * Show the user profile page. */ - public function show(UserRepo $repo, string $slug) + public function show(UserRepo $repo, ActivityQueries $activities, string $slug) { $user = $repo->getBySlug($slug); - $userActivity = $repo->getActivity($user); + $userActivity = $activities->userActivity($user); $recentlyCreated = $repo->getRecentlyCreated($user, 5); $assetCounts = $repo->getAssetCounts($user); diff --git a/app/Http/Controllers/WebhookController.php b/app/Http/Controllers/WebhookController.php new file mode 100644 index 000000000..588b256a3 --- /dev/null +++ b/app/Http/Controllers/WebhookController.php @@ -0,0 +1,119 @@ +middleware([ + 'can:settings-manage', + ]); + } + + /** + * Show all webhooks configured in the system. + */ + public function index() + { + $webhooks = Webhook::query() + ->orderBy('name', 'desc') + ->with('trackedEvents') + ->get(); + return view('settings.webhooks.index', ['webhooks' => $webhooks]); + } + + /** + * Show the view for creating a new webhook in the system. + */ + public function create() + { + return view('settings.webhooks.create'); + } + + /** + * Store a new webhook in the system. + */ + public function store(Request $request) + { + $validated = $this->validate($request, [ + 'name' => ['required', 'max:150'], + 'endpoint' => ['required', 'url', 'max:500'], + 'events' => ['required', 'array'], + 'active' => ['required'], + ]); + + $webhook = new Webhook($validated); + $webhook->active = $validated['active'] === 'true'; + $webhook->save(); + $webhook->updateTrackedEvents(array_values($validated['events'])); + + $this->logActivity(ActivityType::WEBHOOK_CREATE, $webhook); + return redirect('/settings/webhooks'); + } + + /** + * Show the view to edit an existing webhook. + */ + public function edit(string $id) + { + /** @var Webhook $webhook */ + $webhook = Webhook::query() + ->with('trackedEvents') + ->findOrFail($id); + + return view('settings.webhooks.edit', ['webhook' => $webhook]); + } + + /** + * Update an existing webhook with the provided request data. + */ + public function update(Request $request, string $id) + { + $validated = $this->validate($request, [ + 'name' => ['required', 'max:150'], + 'endpoint' => ['required', 'url', 'max:500'], + 'events' => ['required', 'array'], + 'active' => ['required'], + ]); + + /** @var Webhook $webhook */ + $webhook = Webhook::query()->findOrFail($id); + + $webhook->active = $validated['active'] === 'true'; + $webhook->fill($validated)->save(); + $webhook->updateTrackedEvents($validated['events']); + + $this->logActivity(ActivityType::WEBHOOK_UPDATE, $webhook); + return redirect('/settings/webhooks'); + } + + /** + * Show the view to delete a webhook. + */ + public function delete(string $id) + { + /** @var Webhook $webhook */ + $webhook = Webhook::query()->findOrFail($id); + return view('settings.webhooks.delete', ['webhook' => $webhook]); + } + + /** + * Destroy a webhook from the system. + */ + public function destroy(string $id) + { + /** @var Webhook $webhook */ + $webhook = Webhook::query()->findOrFail($id); + + $webhook->trackedEvents()->delete(); + $webhook->delete(); + + $this->logActivity(ActivityType::WEBHOOK_DELETE, $webhook); + return redirect('/settings/webhooks'); + } +} diff --git a/app/Providers/CustomFacadeProvider.php b/app/Providers/CustomFacadeProvider.php index ca86b6607..0518af44f 100644 --- a/app/Providers/CustomFacadeProvider.php +++ b/app/Providers/CustomFacadeProvider.php @@ -2,7 +2,7 @@ namespace BookStack\Providers; -use BookStack\Actions\ActivityService; +use BookStack\Actions\ActivityLogger; use BookStack\Auth\Permissions\PermissionService; use BookStack\Theming\ThemeService; use BookStack\Uploads\ImageService; @@ -28,7 +28,7 @@ class CustomFacadeProvider extends ServiceProvider public function register() { $this->app->singleton('activity', function () { - return $this->app->make(ActivityService::class); + return $this->app->make(ActivityLogger::class); }); $this->app->singleton('images', function () { diff --git a/database/factories/Actions/WebhookFactory.php b/database/factories/Actions/WebhookFactory.php new file mode 100644 index 000000000..1230d49d1 --- /dev/null +++ b/database/factories/Actions/WebhookFactory.php @@ -0,0 +1,26 @@ + 'My webhook for ' . $this->faker->country(), + 'endpoint' => $this->faker->url, + 'active' => true, + ]; + } +} diff --git a/database/factories/Actions/WebhookTrackedEventFactory.php b/database/factories/Actions/WebhookTrackedEventFactory.php new file mode 100644 index 000000000..620776aab --- /dev/null +++ b/database/factories/Actions/WebhookTrackedEventFactory.php @@ -0,0 +1,23 @@ + Webhook::factory(), + 'event' => ActivityType::all()[array_rand(ActivityType::all())], + ]; + } +} diff --git a/database/migrations/2021_12_07_111343_create_webhooks_table.php b/database/migrations/2021_12_07_111343_create_webhooks_table.php new file mode 100644 index 000000000..be4fc539d --- /dev/null +++ b/database/migrations/2021_12_07_111343_create_webhooks_table.php @@ -0,0 +1,48 @@ +increments('id'); + $table->string('name', 150); + $table->boolean('active'); + $table->string('endpoint', 500); + $table->timestamps(); + + $table->index('name'); + $table->index('active'); + }); + + Schema::create('webhook_tracked_events', function (Blueprint $table) { + $table->increments('id'); + $table->integer('webhook_id'); + $table->string('event', 50); + $table->timestamps(); + + $table->index('event'); + $table->index('webhook_id'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('webhooks'); + Schema::dropIfExists('webhook_tracked_events'); + } +} diff --git a/database/migrations/2021_12_13_152024_create_jobs_table.php b/database/migrations/2021_12_13_152024_create_jobs_table.php new file mode 100644 index 000000000..1be9e8a80 --- /dev/null +++ b/database/migrations/2021_12_13_152024_create_jobs_table.php @@ -0,0 +1,36 @@ +bigIncrements('id'); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('jobs'); + } +} diff --git a/database/migrations/2021_12_13_152120_create_failed_jobs_table.php b/database/migrations/2021_12_13_152120_create_failed_jobs_table.php new file mode 100644 index 000000000..6aa6d743e --- /dev/null +++ b/database/migrations/2021_12_13_152120_create_failed_jobs_table.php @@ -0,0 +1,36 @@ +id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('failed_jobs'); + } +} diff --git a/resources/icons/webhooks.svg b/resources/icons/webhooks.svg new file mode 100644 index 000000000..fff081413 --- /dev/null +++ b/resources/icons/webhooks.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/components/index.js b/resources/js/components/index.js index 010ee04ba..fe348aba7 100644 --- a/resources/js/components/index.js +++ b/resources/js/components/index.js @@ -50,6 +50,7 @@ import templateManager from "./template-manager.js" import toggleSwitch from "./toggle-switch.js" import triLayout from "./tri-layout.js" import userSelect from "./user-select.js" +import webhookEvents from "./webhook-events"; import wysiwygEditor from "./wysiwyg-editor.js" const componentMapping = { @@ -105,6 +106,7 @@ const componentMapping = { "toggle-switch": toggleSwitch, "tri-layout": triLayout, "user-select": userSelect, + "webhook-events": webhookEvents, "wysiwyg-editor": wysiwygEditor, }; diff --git a/resources/js/components/webhook-events.js b/resources/js/components/webhook-events.js new file mode 100644 index 000000000..aa50aa9d8 --- /dev/null +++ b/resources/js/components/webhook-events.js @@ -0,0 +1,32 @@ + +/** + * Webhook Events + * Manages dynamic selection control in the webhook form interface. + * @extends {Component} + */ +class WebhookEvents { + + setup() { + this.checkboxes = this.$el.querySelectorAll('input[type="checkbox"]'); + this.allCheckbox = this.$el.querySelector('input[type="checkbox"][value="all"]'); + + this.$el.addEventListener('change', event => { + if (event.target.checked && event.target === this.allCheckbox) { + this.deselectIndividualEvents(); + } else if (event.target.checked) { + this.allCheckbox.checked = false; + } + }); + } + + deselectIndividualEvents() { + for (const checkbox of this.checkboxes) { + if (checkbox !== this.allCheckbox) { + checkbox.checked = false; + } + } + } + +} + +export default WebhookEvents; \ No newline at end of file diff --git a/resources/lang/en/activities.php b/resources/lang/en/activities.php index 50bda60bd..83a374d66 100644 --- a/resources/lang/en/activities.php +++ b/resources/lang/en/activities.php @@ -7,41 +7,41 @@ return [ // Pages 'page_create' => 'created page', - 'page_create_notification' => 'Page Successfully Created', + 'page_create_notification' => 'Page successfully created', 'page_update' => 'updated page', - 'page_update_notification' => 'Page Successfully Updated', + 'page_update_notification' => 'Page successfully updated', 'page_delete' => 'deleted page', - 'page_delete_notification' => 'Page Successfully Deleted', + 'page_delete_notification' => 'Page successfully deleted', 'page_restore' => 'restored page', - 'page_restore_notification' => 'Page Successfully Restored', + 'page_restore_notification' => 'Page successfully restored', 'page_move' => 'moved page', // Chapters 'chapter_create' => 'created chapter', - 'chapter_create_notification' => 'Chapter Successfully Created', + 'chapter_create_notification' => 'Chapter successfully created', 'chapter_update' => 'updated chapter', - 'chapter_update_notification' => 'Chapter Successfully Updated', + 'chapter_update_notification' => 'Chapter successfully updated', 'chapter_delete' => 'deleted chapter', - 'chapter_delete_notification' => 'Chapter Successfully Deleted', + 'chapter_delete_notification' => 'Chapter successfully deleted', 'chapter_move' => 'moved chapter', // Books 'book_create' => 'created book', - 'book_create_notification' => 'Book Successfully Created', + 'book_create_notification' => 'Book successfully created', 'book_update' => 'updated book', - 'book_update_notification' => 'Book Successfully Updated', + 'book_update_notification' => 'Book successfully updated', 'book_delete' => 'deleted book', - 'book_delete_notification' => 'Book Successfully Deleted', + 'book_delete_notification' => 'Book successfully deleted', 'book_sort' => 'sorted book', - 'book_sort_notification' => 'Book Successfully Re-sorted', + 'book_sort_notification' => 'Book successfully re-sorted', // Bookshelves - 'bookshelf_create' => 'created Bookshelf', - 'bookshelf_create_notification' => 'Bookshelf Successfully Created', + 'bookshelf_create' => 'created bookshelf', + 'bookshelf_create_notification' => 'Bookshelf successfully created', 'bookshelf_update' => 'updated bookshelf', - 'bookshelf_update_notification' => 'Bookshelf Successfully Updated', + 'bookshelf_update_notification' => 'Bookshelf successfully updated', 'bookshelf_delete' => 'deleted bookshelf', - 'bookshelf_delete_notification' => 'Bookshelf Successfully Deleted', + 'bookshelf_delete_notification' => 'Bookshelf successfully deleted', // Favourites 'favourite_add_notification' => '":name" has been added to your favourites', @@ -51,6 +51,14 @@ return [ 'mfa_setup_method_notification' => 'Multi-factor method successfully configured', 'mfa_remove_method_notification' => 'Multi-factor method successfully removed', + // Webhooks + 'webhook_create' => 'created webhook', + 'webhook_create_notification' => 'Webhook successfully created', + 'webhook_update' => 'updated webhook', + 'webhook_update_notification' => 'Webhook successfully updated', + 'webhook_delete' => 'deleted webhook', + 'webhook_delete_notification' => 'Webhook successfully deleted', + // Other 'commented_on' => 'commented on', 'permissions_update' => 'updated permissions', diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php index 722bf00db..53db3cf40 100644 --- a/resources/lang/en/common.php +++ b/resources/lang/en/common.php @@ -71,6 +71,9 @@ return [ 'list_view' => 'List View', 'default' => 'Default', 'breadcrumb' => 'Breadcrumb', + 'status' => 'Status', + 'status_active' => 'Active', + 'status_inactive' => 'Inactive', // Header 'header_menu_expand' => 'Expand Header Menu', diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 688b0aad8..1cca516c8 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -233,6 +233,28 @@ return [ 'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?', 'user_api_token_delete_success' => 'API token successfully deleted', + // Webhooks + 'webhooks' => 'Webhooks', + 'webhooks_create' => 'Create New Webhook', + 'webhooks_none_created' => 'No webhooks have yet been created.', + 'webhooks_edit' => 'Edit Webhook', + 'webhooks_save' => 'Save Webhook', + 'webhooks_details' => 'Webhook Details', + 'webhooks_details_desc' => 'Provide a user friendly name and a POST endpoint as a location for the webhook data to be sent to.', + 'webhooks_events' => 'Webhook Events', + 'webhooks_events_desc' => 'Select all the events that should trigger this webhook to be called.', + 'webhooks_events_warning' => 'Keep in mind that these events will be triggered for all selected events, even if custom permissions are applied. Ensure that use of this webhook won\'t expose confidential content.', + 'webhooks_events_all' => 'All system events', + 'webhooks_name' => 'Webhook Name', + 'webhooks_endpoint' => 'Webhook Endpoint', + 'webhooks_active' => 'Webhook Active', + 'webhook_events_table_header' => 'Events', + 'webhooks_delete' => 'Delete Webhook', + 'webhooks_delete_warning' => 'This will fully delete this webhook, with the name \':webhookName\', from the system.', + 'webhooks_delete_confirm' => 'Are you sure you want to delete this webhook?', + 'webhooks_format_example' => 'Webhook Format Example', + 'webhooks_format_example_desc' => 'Webhook data is sent as a POST request to the configured endpoint as JSON following the format below. The "related_item" and "url" properties are optional and will depend on the type of event triggered.', + //! If editing translations files directly please ignore this in all //! languages apart from en. Content will be auto-copied from en. //!//////////////////////////////// diff --git a/resources/views/api-docs/parts/getting-started.blade.php b/resources/views/api-docs/parts/getting-started.blade.php index ca28a7d90..3bcf29dd4 100644 --- a/resources/views/api-docs/parts/getting-started.blade.php +++ b/resources/views/api-docs/parts/getting-started.blade.php @@ -1,5 +1,27 @@

Getting Started

+

+ This documentation covers use of the REST API.
+ Some alternative options for extension and customization can be found below: +

+ + + +
+
Authentication

To access the API a user has to have the "Access System API" permission enabled on one of their assigned roles. diff --git a/resources/views/common/activity-item.blade.php b/resources/views/common/activity-item.blade.php index eebfb591a..89d44b152 100644 --- a/resources/views/common/activity-item.blade.php +++ b/resources/views/common/activity-item.blade.php @@ -24,8 +24,6 @@ "{{ $activity->entity->name }}" @endif - @if($activity->extra) "{{ $activity->extra }}" @endif -
@icon('time'){{ $activity->created_at->diffForHumans() }} diff --git a/resources/views/form/errors.blade.php b/resources/views/form/errors.blade.php new file mode 100644 index 000000000..03cd4be88 --- /dev/null +++ b/resources/views/form/errors.blade.php @@ -0,0 +1,3 @@ +@if($errors->has($name)) +

{{ $errors->first($name) }}
+@endif \ No newline at end of file diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php index d7c31b0dd..48e46a59d 100644 --- a/resources/views/settings/audit.blade.php +++ b/resources/views/settings/audit.blade.php @@ -10,7 +10,7 @@
-

{{ trans('settings.audit') }}

+

{{ trans('settings.audit') }}

{{ trans('settings.audit_desc') }}

diff --git a/resources/views/settings/parts/navbar-with-version.blade.php b/resources/views/settings/parts/navbar-with-version.blade.php index 09af699a3..bec41146b 100644 --- a/resources/views/settings/parts/navbar-with-version.blade.php +++ b/resources/views/settings/parts/navbar-with-version.blade.php @@ -6,10 +6,12 @@ $version - Version of bookstack to display
@include('settings.parts.navbar', ['selected' => $selected])
-
- +
+
+
+
+
+ + BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }} +
\ No newline at end of file diff --git a/resources/views/settings/parts/navbar.blade.php b/resources/views/settings/parts/navbar.blade.php index a472196c5..f2fad378c 100644 --- a/resources/views/settings/parts/navbar.blade.php +++ b/resources/views/settings/parts/navbar.blade.php @@ -13,4 +13,7 @@ @if(userCan('user-roles-manage')) @icon('lock-open'){{ trans('settings.roles') }} @endif + @if(userCan('settings-manage')) + @icon('webhooks'){{ trans('settings.webhooks') }} + @endif \ No newline at end of file diff --git a/resources/views/settings/webhooks/create.blade.php b/resources/views/settings/webhooks/create.blade.php new file mode 100644 index 000000000..4f20dd077 --- /dev/null +++ b/resources/views/settings/webhooks/create.blade.php @@ -0,0 +1,18 @@ +@extends('layouts.simple') + +@section('body') + +
+ +
+ @include('settings.parts.navbar', ['selected' => 'webhooks']) +
+ +
+ @include('settings.webhooks.parts.form', ['title' => trans('settings.webhooks_create')]) +
+ + @include('settings.webhooks.parts.format-example') +
+ +@stop diff --git a/resources/views/settings/webhooks/delete.blade.php b/resources/views/settings/webhooks/delete.blade.php new file mode 100644 index 000000000..65560f65f --- /dev/null +++ b/resources/views/settings/webhooks/delete.blade.php @@ -0,0 +1,39 @@ +@extends('layouts.simple') + +@section('body') +
+ +
+ @include('settings.parts.navbar', ['selected' => 'webhooks']) +
+ +
+

{{ trans('settings.webhooks_delete') }}

+ +

{{ trans('settings.webhooks_delete_warning', ['webhookName' => $webhook->name]) }}

+ + +
+ {!! csrf_field() !!} + {!! method_field('DELETE') !!} + +
+
+

+ {{ trans('settings.webhooks_delete_confirm') }} +

+
+
+
+ {{ trans('common.cancel') }} + +
+
+
+ + +
+
+ +
+@stop diff --git a/resources/views/settings/webhooks/edit.blade.php b/resources/views/settings/webhooks/edit.blade.php new file mode 100644 index 000000000..3b297eb7b --- /dev/null +++ b/resources/views/settings/webhooks/edit.blade.php @@ -0,0 +1,18 @@ +@extends('layouts.simple') + +@section('body') + +
+
+ @include('settings.parts.navbar', ['selected' => 'webhooks']) +
+ +
+ {!! method_field('PUT') !!} + @include('settings.webhooks.parts.form', ['model' => $webhook, 'title' => trans('settings.webhooks_edit')]) +
+ + @include('settings.webhooks.parts.format-example') +
+ +@stop diff --git a/resources/views/settings/webhooks/index.blade.php b/resources/views/settings/webhooks/index.blade.php new file mode 100644 index 000000000..d6423b6fb --- /dev/null +++ b/resources/views/settings/webhooks/index.blade.php @@ -0,0 +1,59 @@ +@extends('layouts.simple') + +@section('body') + +
+ +
+ @include('settings.parts.navbar', ['selected' => 'webhooks']) +
+ +
+ +
+

{{ trans('settings.webhooks') }}

+ + +
+ + @if(count($webhooks) > 0) + + + + + + + + @foreach($webhooks as $webhook) + + + + + + @endforeach +
{{ trans('common.name') }}{{ trans('settings.webhook_events_table_header') }}{{ trans('common.status') }}
+ {{ $webhook->name }}
+ {{ $webhook->endpoint }} +
+ @if($webhook->tracksEvent('all')) + {{ trans('settings.webhooks_events_all') }} + @else + {{ $webhook->trackedEvents->count() }} + @endif + + {{ trans('common.status_' . ($webhook->active ? 'active' : 'inactive')) }} +
+ @else +

+ {{ trans('settings.webhooks_none_created') }} +

+ @endif + + +
+
+ +@stop diff --git a/resources/views/settings/webhooks/parts/form.blade.php b/resources/views/settings/webhooks/parts/form.blade.php new file mode 100644 index 000000000..458b6767b --- /dev/null +++ b/resources/views/settings/webhooks/parts/form.blade.php @@ -0,0 +1,75 @@ +{!! csrf_field() !!} + +
+

{{ $title }}

+ +
+ +
+
+ +

{{ trans('settings.webhooks_details_desc') }}

+
+ @include('form.toggle-switch', [ + 'name' => 'active', + 'value' => old('active') ?? $model->active ?? true, + 'label' => trans('settings.webhooks_active'), + ]) + @include('form.errors', ['name' => 'active']) +
+
+
+
+ + @include('form.text', ['name' => 'name']) +
+
+ + @include('form.text', ['name' => 'endpoint']) +
+
+
+ +
+ + @include('form.errors', ['name' => 'events']) + +

{{ trans('settings.webhooks_events_desc') }}

+

{{ trans('settings.webhooks_events_warning') }}

+ +
+ @include('form.custom-checkbox', [ + 'name' => 'events[]', + 'value' => 'all', + 'label' => trans('settings.webhooks_events_all'), + 'checked' => old('events') ? in_array('all', old('events')) : (isset($webhook) ? $webhook->tracksEvent('all') : false), + ]) +
+ +
+ +
+ @foreach(\BookStack\Actions\ActivityType::all() as $activityType) +
+ @include('form.custom-checkbox', [ + 'name' => 'events[]', + 'value' => $activityType, + 'label' => $activityType, + 'checked' => old('events') ? in_array($activityType, old('events')) : (isset($webhook) ? $webhook->tracksEvent($activityType) : false), + ]) +
+ @endforeach +
+
+ +
+ +
+ {{ trans('common.cancel') }} + @if ($webhook->id ?? false) + {{ trans('settings.webhooks_delete') }} + @endif + +
+ +
diff --git a/resources/views/settings/webhooks/parts/format-example.blade.php b/resources/views/settings/webhooks/parts/format-example.blade.php new file mode 100644 index 000000000..135d3193b --- /dev/null +++ b/resources/views/settings/webhooks/parts/format-example.blade.php @@ -0,0 +1,34 @@ +
+

{{ trans('settings.webhooks_format_example') }}

+

{{ trans('settings.webhooks_format_example_desc') }}

+
{
+    "event": "page_update",
+    "text": "Benny updated page \"My wonderful updated page\"",
+    "triggered_at": "2021-12-11T22:25:10.000000Z",
+    "triggered_by": {
+        "id": 1,
+        "name": "Benny",
+        "slug": "benny"
+    },
+    "triggered_by_profile_url": "https://bookstack.local/user/benny",
+    "webhook_id": 2,
+    "webhook_name": "My page update webhook",
+    "url": "https://bookstack.local/books/my-awesome-book/page/my-wonderful-updated-page",
+    "related_item": {
+        "id": 2432,
+        "book_id": 13,
+        "chapter_id": 554,
+        "name": "My wonderful updated page",
+        "slug": "my-wonderful-updated-page",
+        "priority": 2,
+        "created_at": "2021-12-11T21:53:24.000000Z",
+        "updated_at": "2021-12-11T22:25:10.000000Z",
+        "created_by": 1,
+        "updated_by": 1,
+        "draft": false,
+        "revision_count": 9,
+        "template": false,
+        "owned_by": 1
+    }
+}
+
\ No newline at end of file diff --git a/routes/web.php b/routes/web.php index c924ed68c..d7e734c33 100644 --- a/routes/web.php +++ b/routes/web.php @@ -29,7 +29,11 @@ use BookStack\Http\Controllers\UserApiTokenController; use BookStack\Http\Controllers\UserController; use BookStack\Http\Controllers\UserProfileController; use BookStack\Http\Controllers\UserSearchController; +use BookStack\Http\Controllers\WebhookController; +use BookStack\Http\Middleware\VerifyCsrfToken; +use Illuminate\Session\Middleware\StartSession; use Illuminate\Support\Facades\Route; +use Illuminate\View\Middleware\ShareErrorsFromSession; Route::get('/status', [StatusController::class, 'show']); Route::get('/robots.txt', [HomeController::class, 'robots']); @@ -244,13 +248,22 @@ Route::middleware('auth')->group(function () { Route::delete('/settings/users/{userId}/api-tokens/{tokenId}', [UserApiTokenController::class, 'destroy']); // Roles - Route::get('/settings/roles', [RoleController::class, 'list']); + Route::get('/settings/roles', [RoleController::class, 'index']); Route::get('/settings/roles/new', [RoleController::class, 'create']); Route::post('/settings/roles/new', [RoleController::class, 'store']); Route::get('/settings/roles/delete/{id}', [RoleController::class, 'showDelete']); Route::delete('/settings/roles/delete/{id}', [RoleController::class, 'delete']); Route::get('/settings/roles/{id}', [RoleController::class, 'edit']); Route::put('/settings/roles/{id}', [RoleController::class, 'update']); + + // Webhooks + Route::get('/settings/webhooks', [WebhookController::class, 'index']); + Route::get('/settings/webhooks/create', [WebhookController::class, 'create']); + Route::post('/settings/webhooks/create', [WebhookController::class, 'store']); + Route::get('/settings/webhooks/{id}', [WebhookController::class, 'edit']); + Route::put('/settings/webhooks/{id}', [WebhookController::class, 'update']); + Route::get('/settings/webhooks/{id}/delete', [WebhookController::class, 'delete']); + Route::delete('/settings/webhooks/{id}', [WebhookController::class, 'destroy']); }); // MFA routes @@ -291,9 +304,9 @@ Route::post('/saml2/logout', [Auth\Saml2Controller::class, 'logout']); Route::get('/saml2/metadata', [Auth\Saml2Controller::class, 'metadata']); Route::get('/saml2/sls', [Auth\Saml2Controller::class, 'sls']); Route::post('/saml2/acs', [Auth\Saml2Controller::class, 'startAcs'])->withoutMiddleware([ - \Illuminate\Session\Middleware\StartSession::class, - \Illuminate\View\Middleware\ShareErrorsFromSession::class, - \BookStack\Http\Middleware\VerifyCsrfToken::class, + StartSession::class, + ShareErrorsFromSession::class, + VerifyCsrfToken::class, ]); Route::get('/saml2/acs', [Auth\Saml2Controller::class, 'processAcs']); diff --git a/tests/AuditLogTest.php b/tests/Actions/AuditLogTest.php similarity index 90% rename from tests/AuditLogTest.php rename to tests/Actions/AuditLogTest.php index b37de811a..ebfbf5abf 100644 --- a/tests/AuditLogTest.php +++ b/tests/Actions/AuditLogTest.php @@ -1,9 +1,9 @@ activityService = app(ActivityService::class); + $this->activityService = app(ActivityLogger::class); } public function test_only_accessible_with_right_permissions() @@ -46,7 +49,7 @@ class AuditLogTest extends TestCase $admin = $this->getAdmin(); $this->actingAs($admin); $page = Page::query()->first(); - $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE); + $this->activityService->add(ActivityType::PAGE_CREATE, $page); $activity = Activity::query()->orderBy('id', 'desc')->first(); $resp = $this->get('settings/audit'); @@ -61,7 +64,7 @@ class AuditLogTest extends TestCase $this->actingAs($this->getAdmin()); $page = Page::query()->first(); $pageName = $page->name; - $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE); + $this->activityService->add(ActivityType::PAGE_CREATE, $page); app(PageRepo::class)->destroy($page); app(TrashCan::class)->empty(); @@ -76,7 +79,7 @@ class AuditLogTest extends TestCase $viewer = $this->getViewer(); $this->actingAs($viewer); $page = Page::query()->first(); - $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE); + $this->activityService->add(ActivityType::PAGE_CREATE, $page); $this->actingAs($this->getAdmin()); app(UserRepo::class)->destroy($viewer); @@ -89,7 +92,7 @@ class AuditLogTest extends TestCase { $this->actingAs($this->getAdmin()); $page = Page::query()->first(); - $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE); + $this->activityService->add(ActivityType::PAGE_CREATE, $page); $resp = $this->get('settings/audit'); $resp->assertSeeText($page->name); @@ -102,7 +105,7 @@ class AuditLogTest extends TestCase { $this->actingAs($this->getAdmin()); $page = Page::query()->first(); - $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE); + $this->activityService->add(ActivityType::PAGE_CREATE, $page); $yesterday = (Carbon::now()->subDay()->format('Y-m-d')); $tomorrow = (Carbon::now()->addDay()->format('Y-m-d')); @@ -126,11 +129,11 @@ class AuditLogTest extends TestCase $editor = $this->getEditor(); $this->actingAs($admin); $page = Page::query()->first(); - $this->activityService->addForEntity($page, ActivityType::PAGE_CREATE); + $this->activityService->add(ActivityType::PAGE_CREATE, $page); $this->actingAs($editor); $chapter = Chapter::query()->first(); - $this->activityService->addForEntity($chapter, ActivityType::CHAPTER_UPDATE); + $this->activityService->add(ActivityType::CHAPTER_UPDATE, $chapter); $resp = $this->actingAs($admin)->get('settings/audit?user=' . $admin->id); $resp->assertSeeText($page->name); diff --git a/tests/Actions/WebhookCallTest.php b/tests/Actions/WebhookCallTest.php new file mode 100644 index 000000000..958d33d9d --- /dev/null +++ b/tests/Actions/WebhookCallTest.php @@ -0,0 +1,116 @@ +newWebhook([], ['all']); + Bus::fake(); + $this->runEvent(ActivityType::ROLE_CREATE); + Bus::assertDispatched(DispatchWebhookJob::class); + } + + public function test_webhook_listening_to_specific_event_called_on_event() + { + $this->newWebhook([], [ActivityType::ROLE_UPDATE]); + Bus::fake(); + $this->runEvent(ActivityType::ROLE_UPDATE); + Bus::assertDispatched(DispatchWebhookJob::class); + } + + public function test_webhook_listening_to_specific_event_not_called_on_other_event() + { + $this->newWebhook([], [ActivityType::ROLE_UPDATE]); + Bus::fake(); + $this->runEvent(ActivityType::ROLE_CREATE); + Bus::assertNotDispatched(DispatchWebhookJob::class); + } + + public function test_inactive_webhook_not_called_on_event() + { + $this->newWebhook(['active' => false], ['all']); + Bus::fake(); + $this->runEvent(ActivityType::ROLE_CREATE); + Bus::assertNotDispatched(DispatchWebhookJob::class); + } + + public function test_failed_webhook_call_logs_error() + { + $logger = $this->withTestLogger(); + Http::fake([ + '*' => Http::response('', 500), + ]); + $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']); + + $this->runEvent(ActivityType::ROLE_CREATE); + + $this->assertTrue($logger->hasError('Webhook call to endpoint https://wh.example.com failed with status 500')); + } + + public function test_webhook_call_data_format() + { + Http::fake([ + '*' => Http::response('', 200), + ]); + $webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']); + /** @var Page $page */ + $page = Page::query()->first(); + $editor = $this->getEditor(); + + $this->runEvent(ActivityType::PAGE_UPDATE, $page, $editor); + + Http::assertSent(function(Request $request) use ($editor, $page, $webhook) { + $reqData = $request->data(); + return $request->isJson() + && $reqData['event'] === 'page_update' + && $reqData['text'] === ($editor->name . ' updated page "' . $page->name . '"') + && is_string($reqData['triggered_at']) + && $reqData['triggered_by']['name'] === $editor->name + && $reqData['triggered_by_profile_url'] === $editor->getProfileUrl() + && $reqData['webhook_id'] === $webhook->id + && $reqData['webhook_name'] === $webhook->name + && $reqData['url'] === $page->getUrl() + && $reqData['related_item']['name'] === $page->name; + }); + } + + + protected function runEvent(string $event, $detail = '', ?User $user = null) + { + if (is_null($user)) { + $user = $this->getEditor(); + } + + $this->actingAs($user); + + $activityLogger = $this->app->make(ActivityLogger::class); + $activityLogger->add($event, $detail); + } + + protected function newWebhook(array $attrs = [], array $events = ['all']): Webhook + { + /** @var Webhook $webhook */ + $webhook = Webhook::factory()->create($attrs); + + foreach ($events as $event) { + $webhook->trackedEvents()->create(['event' => $event]); + } + + return $webhook; + } + +} \ No newline at end of file diff --git a/tests/Actions/WebhookManagementTest.php b/tests/Actions/WebhookManagementTest.php new file mode 100644 index 000000000..8abf06fc5 --- /dev/null +++ b/tests/Actions/WebhookManagementTest.php @@ -0,0 +1,173 @@ +newWebhook([ + 'name' => 'My awesome webhook', + 'endpoint' => 'https://example.com/donkey/webhook', + ], ['all']); + + $resp = $this->asAdmin()->get('/settings/webhooks'); + $resp->assertOk(); + $resp->assertElementContains('a[href$="/settings/webhooks/create"]', 'Create New Webhook'); + $resp->assertElementExists('a[href="' . $webhook->getUrl() . '"]', $webhook->name); + $resp->assertSee($webhook->endpoint); + $resp->assertSee('All system events'); + $resp->assertSee('Active'); + } + + public function test_create_view() + { + $resp = $this->asAdmin()->get('/settings/webhooks/create'); + $resp->assertOk(); + $resp->assertSee('Create New Webhook'); + $resp->assertElementContains('form[action$="/settings/webhooks/create"] button', 'Save Webhook'); + } + + public function test_store() + { + $resp = $this->asAdmin()->post('/settings/webhooks/create', [ + 'name' => 'My first webhook', + 'endpoint' => 'https://example.com/webhook', + 'events' => ['all'], + 'active' => 'true' + ]); + + $resp->assertRedirect('/settings/webhooks'); + $this->assertActivityExists(ActivityType::WEBHOOK_CREATE); + + $resp = $this->followRedirects($resp); + $resp->assertSee('Webhook successfully created'); + + $this->assertDatabaseHas('webhooks', [ + 'name' => 'My first webhook', + 'endpoint' => 'https://example.com/webhook', + 'active' => true, + ]); + + /** @var Webhook $webhook */ + $webhook = Webhook::query()->where('name', '=', 'My first webhook')->first(); + $this->assertDatabaseHas('webhook_tracked_events', [ + 'webhook_id' => $webhook->id, + 'event' => 'all', + ]); + } + + public function test_edit_view() + { + $webhook = $this->newWebhook(); + + $resp = $this->asAdmin()->get('/settings/webhooks/' . $webhook->id); + $resp->assertOk(); + $resp->assertSee('Edit Webhook'); + $resp->assertElementContains('form[action="' . $webhook->getUrl() . '"] button', 'Save Webhook'); + $resp->assertElementContains('a[href="' . $webhook->getUrl('/delete') . '"]', 'Delete Webhook'); + $resp->assertElementExists('input[type="checkbox"][value="all"][name="events[]"]'); + } + + public function test_update() + { + $webhook = $this->newWebhook(); + + $resp = $this->asAdmin()->put('/settings/webhooks/' . $webhook->id, [ + 'name' => 'My updated webhook', + 'endpoint' => 'https://example.com/updated-webhook', + 'events' => [ActivityType::PAGE_CREATE, ActivityType::PAGE_UPDATE], + 'active' => 'true' + ]); + $resp->assertRedirect('/settings/webhooks'); + + $resp = $this->followRedirects($resp); + $resp->assertSee('Webhook successfully updated'); + + $this->assertDatabaseHas('webhooks', [ + 'id' => $webhook->id, + 'name' => 'My updated webhook', + 'endpoint' => 'https://example.com/updated-webhook', + 'active' => true, + ]); + + $trackedEvents = $webhook->trackedEvents()->get(); + $this->assertCount(2, $trackedEvents); + $this->assertEquals(['page_create', 'page_update'], $trackedEvents->pluck('event')->values()->all()); + + $this->assertActivityExists(ActivityType::WEBHOOK_UPDATE); + } + + public function test_delete_view() + { + $webhook = $this->newWebhook(['name' => 'Webhook to delete']); + + $resp = $this->asAdmin()->get('/settings/webhooks/' . $webhook->id . '/delete'); + $resp->assertOk(); + $resp->assertSee('Delete Webhook'); + $resp->assertSee('This will fully delete this webhook, with the name \'Webhook to delete\', from the system.'); + $resp->assertElementContains('form[action$="/settings/webhooks/' . $webhook->id . '"]', 'Delete'); + } + + public function test_destroy() + { + $webhook = $this->newWebhook(); + + $resp = $this->asAdmin()->delete('/settings/webhooks/' . $webhook->id); + $resp->assertRedirect('/settings/webhooks'); + + $resp = $this->followRedirects($resp); + $resp->assertSee('Webhook successfully deleted'); + + $this->assertDatabaseMissing('webhooks', ['id' => $webhook->id]); + $this->assertDatabaseMissing('webhook_tracked_events', ['webhook_id' => $webhook->id]); + + $this->assertActivityExists(ActivityType::WEBHOOK_DELETE); + } + + public function test_settings_manage_permission_required_for_webhook_routes() + { + $editor = $this->getEditor(); + $this->actingAs($editor); + + $routes = [ + ['GET', '/settings/webhooks'], + ['GET', '/settings/webhooks/create'], + ['POST', '/settings/webhooks/create'], + ['GET', '/settings/webhooks/1'], + ['PUT', '/settings/webhooks/1'], + ['DELETE', '/settings/webhooks/1'], + ['GET', '/settings/webhooks/1/delete'], + ]; + + foreach ($routes as [$method, $endpoint]) { + $resp = $this->call($method, $endpoint); + $this->assertPermissionError($resp); + } + + $this->giveUserPermissions($editor, ['settings-manage']); + + foreach ($routes as [$method, $endpoint]) { + $resp = $this->call($method, $endpoint); + $this->assertNotPermissionError($resp); + } + } + + protected function newWebhook(array $attrs = [], array $events = ['all']): Webhook + { + /** @var Webhook $webhook */ + $webhook = Webhook::factory()->create($attrs); + + foreach ($events as $event) { + $webhook->trackedEvents()->create(['event' => $event]); + } + + return $webhook; + } + +} \ No newline at end of file diff --git a/tests/Commands/ClearActivityCommandTest.php b/tests/Commands/ClearActivityCommandTest.php index 172e6c6ae..71baa0ca6 100644 --- a/tests/Commands/ClearActivityCommandTest.php +++ b/tests/Commands/ClearActivityCommandTest.php @@ -4,6 +4,8 @@ namespace Tests\Commands; use BookStack\Actions\ActivityType; use BookStack\Entities\Models\Page; +use BookStack\Facades\Activity; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\DB; use Tests\TestCase; @@ -12,8 +14,9 @@ class ClearActivityCommandTest extends TestCase public function test_clear_activity_command() { $this->asEditor(); - $page = Page::first(); - \Activity::addForEntity($page, ActivityType::PAGE_UPDATE); + /** @var Page $page */ + $page = Page::query()->first(); + Activity::add(ActivityType::PAGE_UPDATE, $page); $this->assertDatabaseHas('activities', [ 'type' => 'page_update', @@ -22,7 +25,7 @@ class ClearActivityCommandTest extends TestCase ]); DB::rollBack(); - $exitCode = \Artisan::call('bookstack:clear-activity'); + $exitCode = Artisan::call('bookstack:clear-activity'); DB::beginTransaction(); $this->assertTrue($exitCode === 0, 'Command executed successfully'); diff --git a/tests/StatusTest.php b/tests/StatusTest.php index 37b1b15a1..82c377615 100644 --- a/tests/StatusTest.php +++ b/tests/StatusTest.php @@ -1,10 +1,13 @@ create(); $this->actingAs($newUser); $entities = $this->createEntityChainBelongingToUser($newUser, $newUser); - Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE); - Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE); + Activity::add(ActivityType::BOOK_UPDATE, $entities['book']); + Activity::add(ActivityType::PAGE_CREATE, $entities['page']); $this->asAdmin()->get('/user/' . $newUser->slug) ->assertElementContains('#recent-user-activity', 'updated book') @@ -78,8 +78,8 @@ class UserProfileTest extends TestCase $newUser = User::factory()->create(); $this->actingAs($newUser); $entities = $this->createEntityChainBelongingToUser($newUser, $newUser); - Activity::addForEntity($entities['book'], ActivityType::BOOK_UPDATE); - Activity::addForEntity($entities['page'], ActivityType::PAGE_CREATE); + Activity::add(ActivityType::BOOK_UPDATE, $entities['book']); + Activity::add(ActivityType::PAGE_CREATE, $entities['page']); $linkSelector = '#recent-activity a[href$="/user/' . $newUser->slug . '"]'; $this->asAdmin()->get('/')