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 @@
+ This documentation covers use of the REST API.
+ Some alternative options for extension and customization can be found below:
+
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))
+
{{ trans('settings.audit_desc') }}
{{ trans('settings.webhooks_delete_warning', ['webhookName' => $webhook->name]) }}
+ + + +{{ 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')) }} + | +
+ {{ trans('settings.webhooks_none_created') }} +
+ @endif + + +{{ trans('settings.webhooks_details_desc') }}
+{{ trans('settings.webhooks_events_desc') }}
+{{ trans('settings.webhooks_events_warning') }}
+ +{{ 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
+ }
+}
+