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 @@ +<?php + +namespace BookStack\Actions; + +use BookStack\Auth\Permissions\PermissionService; +use BookStack\Entities\Models\Entity; +use BookStack\Interfaces\Loggable; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\Log; + +class ActivityLogger +{ + protected $permissionService; + + public function __construct(PermissionService $permissionService) + { + $this->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 @@ +<?php + +namespace BookStack\Actions; + +use BookStack\Auth\User; +use BookStack\Entities\Models\Entity; +use BookStack\Interfaces\Loggable; +use BookStack\Model; +use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Foundation\Bus\Dispatchable; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; + +class DispatchWebhookJob implements ShouldQueue +{ + use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + + /** + * @var Webhook + */ + protected $webhook; + + /** + * @var string + */ + protected $event; + + /** + * @var string|Loggable + */ + protected $detail; + + /** + * @var User + */ + protected $initiator; + + /** + * @var int + */ + protected $initiatedTime; + + /** + * Create a new job instance. + * + * @return void + */ + public function __construct(Webhook $webhook, string $event, $detail) + { + $this->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 @@ +<?php + +namespace BookStack\Actions; + +use BookStack\Interfaces\Loggable; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; + +/** + * @property int $id + * @property string $name + * @property string $endpoint + * @property Collection $trackedEvents + * @property bool $active + */ +class Webhook extends Model implements Loggable +{ + protected $fillable = ['name', 'endpoint']; + + use HasFactory; + + /** + * Define the tracked event relation a webhook. + */ + public function trackedEvents(): HasMany + { + return $this->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 @@ +<?php + +namespace BookStack\Actions; + +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; + +/** + * @property int $id + * @property int $webhook_id + * @property string $event + */ +class WebhookTrackedEvent extends Model +{ + protected $fillable = ['event']; + + use HasFactory; +} diff --git a/app/Auth/UserRepo.php b/app/Auth/UserRepo.php index 84002b7f7..ff2e91ee2 100644 --- a/app/Auth/UserRepo.php +++ b/app/Auth/UserRepo.php @@ -2,7 +2,6 @@ namespace BookStack\Auth; -use Activity; use BookStack\Entities\EntityProvider; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; @@ -218,14 +217,6 @@ class UserRepo } } - /** - * Get the latest activity for a user. - */ - public function getActivity(User $user, int $count = 20, int $page = 0): array - { - return Activity::userActivity($user, $count, $page); - } - /** * Get the recently created content for this given user. */ diff --git a/app/Config/queue.php b/app/Config/queue.php index 0f5ee3ce5..a14799f35 100644 --- a/app/Config/queue.php +++ b/app/Config/queue.php @@ -11,7 +11,7 @@ return [ // Default driver to use for the queue - // Options: null, sync, redis + // Options: sync, database, redis 'default' => 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 @@ +<?php + +namespace BookStack\Http\Controllers; + +use BookStack\Actions\ActivityType; +use BookStack\Actions\Webhook; +use Illuminate\Http\Request; + +class WebhookController extends Controller +{ + public function __construct() + { + $this->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 @@ +<?php + +namespace Database\Factories\Actions; + +use BookStack\Actions\Webhook; +use Illuminate\Database\Eloquent\Factories\Factory; + +class WebhookFactory extends Factory +{ + + protected $model = Webhook::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition() + { + return [ + 'name' => '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 @@ +<?php + +namespace Database\Factories\Actions; + +use BookStack\Actions\ActivityType; +use BookStack\Actions\Webhook; +use Illuminate\Database\Eloquent\Factories\Factory; + +class WebhookTrackedEventFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition() + { + return [ + 'webhook_id' => 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 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +class CreateWebhooksTable extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::create('webhooks', function (Blueprint $table) { + $table->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 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +class CreateJobsTable extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::create('jobs', function (Blueprint $table) { + $table->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 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +class CreateFailedJobsTable extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::create('failed_jobs', function (Blueprint $table) { + $table->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 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M10,15l5.88,0c0.27-0.31,0.67-0.5,1.12-0.5c0.83,0,1.5,0.67,1.5,1.5c0,0.83-0.67,1.5-1.5,1.5c-0.44,0-0.84-0.19-1.12-0.5 l-3.98,0c-0.46,2.28-2.48,4-4.9,4c-2.76,0-5-2.24-5-5c0-2.42,1.72-4.44,4-4.9l0,2.07C4.84,13.58,4,14.7,4,16c0,1.65,1.35,3,3,3 s3-1.35,3-3V15z M12.5,4c1.65,0,3,1.35,3,3h2c0-2.76-2.24-5-5-5l0,0c-2.76,0-5,2.24-5,5c0,1.43,0.6,2.71,1.55,3.62l-2.35,3.9 C6.02,14.66,5.5,15.27,5.5,16c0,0.83,0.67,1.5,1.5,1.5s1.5-0.67,1.5-1.5c0-0.16-0.02-0.31-0.07-0.45l3.38-5.63 C10.49,9.61,9.5,8.42,9.5,7C9.5,5.35,10.85,4,12.5,4z M17,13c-0.64,0-1.23,0.2-1.72,0.54l-3.05-5.07C11.53,8.35,11,7.74,11,7 c0-0.83,0.67-1.5,1.5-1.5S14,6.17,14,7c0,0.15-0.02,0.29-0.06,0.43l2.19,3.65C16.41,11.03,16.7,11,17,11l0,0c2.76,0,5,2.24,5,5 c0,2.76-2.24,5-5,5c-1.85,0-3.47-1.01-4.33-2.5l2.67,0C15.82,18.82,16.39,19,17,19c1.65,0,3-1.35,3-3S18.65,13,17,13z"/></svg> \ 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 @@ <h1 class="list-heading text-capitals mb-l">Getting Started</h1> +<p class="mb-none"> + This documentation covers use of the REST API. <br> + Some alternative options for extension and customization can be found below: +</p> + +<ul> + <li> + <a href="{{ url('/settings/webhooks') }}" target="_blank" rel="noopener noreferrer">Webhooks</a> - + HTTP POST calls upon events occurring in BookStack. + </li> + <li> + <a href="https://github.com/BookStackApp/BookStack/blob/master/dev/docs/visual-theme-system.md" target="_blank" rel="noopener noreferrer">Visual Theme System</a> - + Methods to override views, translations and icons within BookStack. + </li> + <li> + <a href="https://github.com/BookStackApp/BookStack/blob/master/dev/docs/logical-theme-system.md" target="_blank" rel="noopener noreferrer">Logical Theme System</a> - + Methods to extend back-end functionality within BookStack. + </li> +</ul> + +<hr> + <h5 id="authentication" class="text-mono mb-m">Authentication</h5> <p> To access the API a user has to have the <em>"Access System API"</em> 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 - <br> <span class="text-muted"><small>@icon('time'){{ $activity->created_at->diffForHumans() }}</small></span> 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)) + <div class="text-neg text-small">{{ $errors->first($name) }}</div> +@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 @@ </div> <div class="card content-wrap auto-height"> - <h2 class="list-heading">{{ trans('settings.audit') }}</h2> + <h1 class="list-heading">{{ trans('settings.audit') }}</h1> <p class="text-muted">{{ trans('settings.audit_desc') }}</p> <div class="flex-container-row"> 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 <div class="py-m flex fit-content"> @include('settings.parts.navbar', ['selected' => $selected]) </div> - <div class="flex"></div> - <div class="text-right p-m flex fit-content"> - <a target="_blank" rel="noopener noreferrer" href="https://github.com/BookStackApp/BookStack/releases"> - BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }} - </a> - </div> +</div> +<div class="px-s"> + <hr class="darker m-none"> +</div> +<div class="py-l px-m flex fit-content"> + <a target="_blank" rel="noopener noreferrer" href="https://github.com/BookStackApp/BookStack/releases"> + BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }} + </a> </div> \ 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')) <a href="{{ url('/settings/roles') }}" @if($selected == 'roles') class="active" @endif>@icon('lock-open'){{ trans('settings.roles') }}</a> @endif + @if(userCan('settings-manage')) + <a href="{{ url('/settings/webhooks') }}" @if($selected == 'webhooks') class="active" @endif>@icon('webhooks'){{ trans('settings.webhooks') }}</a> + @endif </nav> \ 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') + + <div class="container small"> + + <div class="py-m"> + @include('settings.parts.navbar', ['selected' => 'webhooks']) + </div> + + <form action="{{ url("/settings/webhooks/create") }}" method="POST"> + @include('settings.webhooks.parts.form', ['title' => trans('settings.webhooks_create')]) + </form> + + @include('settings.webhooks.parts.format-example') + </div> + +@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') + <div class="container small"> + + <div class="py-m"> + @include('settings.parts.navbar', ['selected' => 'webhooks']) + </div> + + <div class="card content-wrap auto-height"> + <h1 class="list-heading"> {{ trans('settings.webhooks_delete') }}</h1> + + <p>{{ trans('settings.webhooks_delete_warning', ['webhookName' => $webhook->name]) }}</p> + + + <form action="{{ $webhook->getUrl() }}" method="POST"> + {!! csrf_field() !!} + {!! method_field('DELETE') !!} + + <div class="grid half v-center"> + <div> + <p class="text-neg"> + <strong>{{ trans('settings.webhooks_delete_confirm') }}</strong> + </p> + </div> + <div> + <div class="form-group text-right"> + <a href="{{ $webhook->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a> + <button type="submit" class="button">{{ trans('common.confirm') }}</button> + </div> + </div> + </div> + + + </form> + </div> + + </div> +@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') + + <div class="container small"> + <div class="py-m"> + @include('settings.parts.navbar', ['selected' => 'webhooks']) + </div> + + <form action="{{ $webhook->getUrl() }}" method="POST"> + {!! method_field('PUT') !!} + @include('settings.webhooks.parts.form', ['model' => $webhook, 'title' => trans('settings.webhooks_edit')]) + </form> + + @include('settings.webhooks.parts.format-example') + </div> + +@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') + + <div class="container small"> + + <div class="py-m"> + @include('settings.parts.navbar', ['selected' => 'webhooks']) + </div> + + <div class="card content-wrap auto-height"> + + <div class="grid half v-center"> + <h1 class="list-heading">{{ trans('settings.webhooks') }}</h1> + + <div class="text-right"> + <a href="{{ url("/settings/webhooks/create") }}" + class="button outline">{{ trans('settings.webhooks_create') }}</a> + </div> + </div> + + @if(count($webhooks) > 0) + + <table class="table"> + <tr> + <th>{{ trans('common.name') }}</th> + <th>{{ trans('settings.webhook_events_table_header') }}</th> + <th>{{ trans('common.status') }}</th> + </tr> + @foreach($webhooks as $webhook) + <tr> + <td> + <a href="{{ $webhook->getUrl() }}">{{ $webhook->name }}</a> <br> + <span class="small text-muted italic">{{ $webhook->endpoint }}</span> + </td> + <td> + @if($webhook->tracksEvent('all')) + {{ trans('settings.webhooks_events_all') }} + @else + {{ $webhook->trackedEvents->count() }} + @endif + </td> + <td> + {{ trans('common.status_' . ($webhook->active ? 'active' : 'inactive')) }} + </td> + </tr> + @endforeach + </table> + @else + <p class="text-muted empty-text px-none"> + {{ trans('settings.webhooks_none_created') }} + </p> + @endif + + + </div> + </div> + +@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() !!} + +<div class="card content-wrap auto-height"> + <h1 class="list-heading">{{ $title }}</h1> + + <div class="setting-list"> + + <div class="grid half"> + <div> + <label class="setting-list-label">{{ trans('settings.webhooks_details') }}</label> + <p class="small">{{ trans('settings.webhooks_details_desc') }}</p> + <div> + @include('form.toggle-switch', [ + 'name' => 'active', + 'value' => old('active') ?? $model->active ?? true, + 'label' => trans('settings.webhooks_active'), + ]) + @include('form.errors', ['name' => 'active']) + </div> + </div> + <div> + <div class="form-group"> + <label for="name">{{ trans('settings.webhooks_name') }}</label> + @include('form.text', ['name' => 'name']) + </div> + <div class="form-group"> + <label for="endpoint">{{ trans('settings.webhooks_endpoint') }}</label> + @include('form.text', ['name' => 'endpoint']) + </div> + </div> + </div> + + <div component="webhook-events"> + <label class="setting-list-label">{{ trans('settings.webhooks_events') }}</label> + @include('form.errors', ['name' => 'events']) + + <p class="small">{{ trans('settings.webhooks_events_desc') }}</p> + <p class="text-warn small">{{ trans('settings.webhooks_events_warning') }}</p> + + <div class="toggle-switch-list"> + @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), + ]) + </div> + + <hr class="my-s"> + + <div class="dual-column-content toggle-switch-list"> + @foreach(\BookStack\Actions\ActivityType::all() as $activityType) + <div> + @include('form.custom-checkbox', [ + 'name' => 'events[]', + 'value' => $activityType, + 'label' => $activityType, + 'checked' => old('events') ? in_array($activityType, old('events')) : (isset($webhook) ? $webhook->tracksEvent($activityType) : false), + ]) + </div> + @endforeach + </div> + </div> + + </div> + + <div class="form-group text-right"> + <a href="{{ url("/settings/webhooks") }}" class="button outline">{{ trans('common.cancel') }}</a> + @if ($webhook->id ?? false) + <a href="{{ $webhook->getUrl('/delete') }}" class="button outline">{{ trans('settings.webhooks_delete') }}</a> + @endif + <button type="submit" class="button">{{ trans('settings.webhooks_save') }}</button> + </div> + +</div> 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 @@ +<div component="code-highlighter" class="card content-wrap auto-height"> + <h2 class="list-heading">{{ trans('settings.webhooks_format_example') }}</h2> + <p>{{ trans('settings.webhooks_format_example_desc') }}</p> + <pre><code class="language-json">{ + "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 + } +}</code></pre> +</div> \ 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 @@ <?php -namespace Tests; +namespace Tests\Actions; use BookStack\Actions\Activity; -use BookStack\Actions\ActivityService; +use BookStack\Actions\ActivityLogger; use BookStack\Actions\ActivityType; use BookStack\Auth\UserRepo; use BookStack\Entities\Models\Chapter; @@ -11,16 +11,19 @@ use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\PageRepo; use BookStack\Entities\Tools\TrashCan; use Carbon\Carbon; +use Tests\TestCase; +use function app; +use function config; class AuditLogTest extends TestCase { - /** @var ActivityService */ + /** @var ActivityLogger */ protected $activityService; protected function setUp(): void { parent::setUp(); - $this->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 @@ +<?php + +namespace Tests\Actions; + +use BookStack\Actions\ActivityLogger; +use BookStack\Actions\ActivityType; +use BookStack\Actions\DispatchWebhookJob; +use BookStack\Actions\Webhook; +use BookStack\Auth\User; +use BookStack\Entities\Models\Page; +use Illuminate\Http\Client\Request; +use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Http; +use Tests\TestCase; + +class WebhookCallTest extends TestCase +{ + + public function test_webhook_listening_to_all_called_on_event() + { + $this->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 @@ +<?php + +namespace Tests\Actions; + +use BookStack\Actions\ActivityType; +use BookStack\Actions\Webhook; +use Tests\TestCase; + +class WebhookManagementTest extends TestCase +{ + + public function test_index_view() + { + $webhook = $this->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 @@ <?php +namespace Tests; + +use Exception; use Illuminate\Cache\ArrayStore; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Session; -use Tests\TestCase; +use Mockery; class StatusTest extends TestCase { diff --git a/tests/User/UserProfileTest.php b/tests/User/UserProfileTest.php index c3888f8c5..869368975 100644 --- a/tests/User/UserProfileTest.php +++ b/tests/User/UserProfileTest.php @@ -64,8 +64,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']); $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('/')