From a3a30556958e125d33df3c55697c0a8b9273f588 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 7 Dec 2021 14:55:11 +0000 Subject: [PATCH 01/13] Started webhook implementation --- app/Actions/Webhook.php | 11 +++++++ app/Http/Controllers/RoleController.php | 2 +- app/Http/Controllers/WebhookController.php | 16 +++++++++ database/factories/WebhookFactory.php | 21 ++++++++++++ ...021_12_07_111343_create_webhooks_table.php | 33 +++++++++++++++++++ resources/icons/webhooks.svg | 1 + resources/lang/en/settings.php | 4 +++ resources/views/settings/audit.blade.php | 2 +- .../parts/navbar-with-version.blade.php | 14 ++++---- .../views/settings/parts/navbar.blade.php | 3 ++ .../views/settings/webhooks/index.blade.php | 25 ++++++++++++++ routes/web.php | 15 ++++++--- 12 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 app/Actions/Webhook.php create mode 100644 app/Http/Controllers/WebhookController.php create mode 100644 database/factories/WebhookFactory.php create mode 100644 database/migrations/2021_12_07_111343_create_webhooks_table.php create mode 100644 resources/icons/webhooks.svg create mode 100644 resources/views/settings/webhooks/index.blade.php diff --git a/app/Actions/Webhook.php b/app/Actions/Webhook.php new file mode 100644 index 000000000..6939b54d6 --- /dev/null +++ b/app/Actions/Webhook.php @@ -0,0 +1,11 @@ +checkPermission('user-roles-manage'); $roles = $this->permissionsRepo->getAllRoles(); diff --git a/app/Http/Controllers/WebhookController.php b/app/Http/Controllers/WebhookController.php new file mode 100644 index 000000000..8745bf91d --- /dev/null +++ b/app/Http/Controllers/WebhookController.php @@ -0,0 +1,16 @@ + 'My webhook for ' . $this->faker->country(), + 'endpoint' => $this->faker->url, + ]; + } +} 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..7ccfe693d --- /dev/null +++ b/database/migrations/2021_12_07_111343_create_webhooks_table.php @@ -0,0 +1,33 @@ +increments('id'); + $table->string('name', 150); + $table->string('endpoint', 500); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('webhooks'); + } +} 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/lang/en/settings.php b/resources/lang/en/settings.php index 688b0aad8..57cbe500e 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -233,6 +233,10 @@ 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', + //! 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/settings/audit.blade.php b/resources/views/settings/audit.blade.php index 84f180f3b..9261ed61b 100644 --- a/resources/views/settings/audit.blade.php +++ b/resources/views/settings/audit.blade.php @@ -10,7 +10,7 @@
-

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

+

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

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

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

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

+ + +
+ + +
+
+ +@stop diff --git a/routes/web.php b/routes/web.php index c924ed68c..627ce6523 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,16 @@ 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']); }); // MFA routes @@ -291,9 +298,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']); From 4621d8bcc51d2cba552055151ae6696348231b54 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 8 Dec 2021 14:29:42 +0000 Subject: [PATCH 02/13] Initial controller/views for webhooks management --- app/Actions/ActivityType.php | 12 +++ app/Actions/Webhook.php | 16 +++- app/Http/Controllers/WebhookController.php | 78 +++++++++++++++++++ resources/js/components/index.js | 2 + resources/js/components/webhook-events.js | 32 ++++++++ resources/lang/en/activities.php | 8 ++ resources/lang/en/settings.php | 13 ++++ .../views/settings/webhooks/create.blade.php | 16 ++++ .../views/settings/webhooks/delete.blade.php | 39 ++++++++++ .../views/settings/webhooks/edit.blade.php | 16 ++++ .../views/settings/webhooks/index.blade.php | 2 +- .../settings/webhooks/parts/form.blade.php | 57 ++++++++++++++ routes/web.php | 6 ++ 13 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 resources/js/components/webhook-events.js create mode 100644 resources/views/settings/webhooks/create.blade.php create mode 100644 resources/views/settings/webhooks/delete.blade.php create mode 100644 resources/views/settings/webhooks/edit.blade.php create mode 100644 resources/views/settings/webhooks/parts/form.blade.php 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/Webhook.php b/app/Actions/Webhook.php index 6939b54d6..2d11584e6 100644 --- a/app/Actions/Webhook.php +++ b/app/Actions/Webhook.php @@ -2,10 +2,24 @@ namespace BookStack\Actions; +use BookStack\Interfaces\Loggable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -class Webhook extends Model +/** + * @property int $id + * @property string $name + * @property string $endpoint + */ +class Webhook extends Model implements Loggable { use HasFactory; + + /** + * Get the string descriptor for this item. + */ + public function logDescriptor(): string + { + return "({$this->id}) {$this->name}"; + } } diff --git a/app/Http/Controllers/WebhookController.php b/app/Http/Controllers/WebhookController.php index 8745bf91d..15a31f312 100644 --- a/app/Http/Controllers/WebhookController.php +++ b/app/Http/Controllers/WebhookController.php @@ -2,15 +2,93 @@ 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() { + // TODO - Get and pass webhooks return view('settings.webhooks.index'); } + + /** + * 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) + { + // TODO - Create webhook + $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()->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) + { + /** @var Webhook $webhook */ + $webhook = Webhook::query()->findOrFail($id); + + // TODO - Update + + $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); + + // TODO - Delete event type relations + $webhook->delete(); + + $this->logActivity(ActivityType::WEBHOOK_DELETE, $webhook); + return redirect('/settings/webhooks'); + } } 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..54080d36e --- /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.$refs.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..1919df706 100644 --- a/resources/lang/en/activities.php +++ b/resources/lang/en/activities.php @@ -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/settings.php b/resources/lang/en/settings.php index 57cbe500e..6812075b3 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -236,6 +236,19 @@ return [ // Webhooks 'webhooks' => 'Webhooks', 'webhooks_create' => 'Create New Webhook', + '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_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?', //! 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/settings/webhooks/create.blade.php b/resources/views/settings/webhooks/create.blade.php new file mode 100644 index 000000000..b49afe415 --- /dev/null +++ b/resources/views/settings/webhooks/create.blade.php @@ -0,0 +1,16 @@ +@extends('layouts.simple') + +@section('body') + +
+ +
+ @include('settings.parts.navbar', ['selected' => 'webhooks']) +
+ +
+ @include('settings.webhooks.parts.form', ['title' => trans('settings.webhooks_create')]) +
+
+ +@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..a89b01171 --- /dev/null +++ b/resources/views/settings/webhooks/delete.blade.php @@ -0,0 +1,39 @@ +@extends('layouts.simple') + +@section('body') +
+ +
+ @include('settings.parts.navbar', ['selected' => 'webhooks']) +
+ +
+

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

+ +

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

+ + +
id}") }}" method="POST"> + {!! csrf_field() !!} + {!! method_field('DELETE') !!} + +
+
+

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

+
+
+ +
+
+ + +
+
+ +
+@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..d4e60cc14 --- /dev/null +++ b/resources/views/settings/webhooks/edit.blade.php @@ -0,0 +1,16 @@ +@extends('layouts.simple') + +@section('body') + +
+
+ @include('settings.parts.navbar', ['selected' => 'webhooks']) +
+ +
id}") }}" method="POST"> + {!! method_field('PUT') !!} + @include('settings.webhooks.parts.form', ['model' => $webhook, 'title' => trans('settings.webhooks_edit')]) +
+
+ +@stop diff --git a/resources/views/settings/webhooks/index.blade.php b/resources/views/settings/webhooks/index.blade.php index ca93cfeb0..8adf60835 100644 --- a/resources/views/settings/webhooks/index.blade.php +++ b/resources/views/settings/webhooks/index.blade.php @@ -14,7 +14,7 @@

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

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..935b01992 --- /dev/null +++ b/resources/views/settings/webhooks/parts/form.blade.php @@ -0,0 +1,57 @@ +{!! csrf_field() !!} + +
+

{{ $title }}

+ +
+ +
+
+ +

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

+
+
+
+ + @include('form.text', ['name' => 'name']) +
+
+ + @include('form.text', ['name' => 'endpoint']) +
+
+
+ +
+ +

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

+

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

+ +
+ +
+ +
+ +
+ @foreach(\BookStack\Actions\ActivityType::all() as $activityType) + + @endforeach +
+
+ +
+ +
+ {{ trans('common.cancel') }} + @if ($webhook->id ?? false) + id}") }}" class="button outline">{{ trans('settings.webhooks_delete') }} + @endif + +
+ +
diff --git a/routes/web.php b/routes/web.php index 627ce6523..d7e734c33 100644 --- a/routes/web.php +++ b/routes/web.php @@ -258,6 +258,12 @@ Route::middleware('auth')->group(function () { // 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 From 8716b1922b5a619e1b070184e0a4d7b565cb1f39 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 8 Dec 2021 17:35:58 +0000 Subject: [PATCH 03/13] Completed webhook management interface Got webhook CRUD actions in place within the interface. Quick manual test pass done, Needs automated tests. --- app/Actions/Webhook.php | 49 +++++++++++++++++++ app/Actions/WebhookTrackedEvent.php | 18 +++++++ app/Http/Controllers/WebhookController.php | 33 ++++++++++--- ...021_12_07_111343_create_webhooks_table.php | 12 +++++ resources/js/components/webhook-events.js | 2 +- resources/lang/en/settings.php | 1 + resources/views/form/errors.blade.php | 3 ++ .../views/settings/webhooks/create.blade.php | 2 +- .../views/settings/webhooks/delete.blade.php | 4 +- .../views/settings/webhooks/edit.blade.php | 2 +- .../views/settings/webhooks/index.blade.php | 32 +++++++++++- .../settings/webhooks/parts/form.blade.php | 30 ++++++++---- 12 files changed, 166 insertions(+), 22 deletions(-) create mode 100644 app/Actions/WebhookTrackedEvent.php create mode 100644 resources/views/form/errors.blade.php diff --git a/app/Actions/Webhook.php b/app/Actions/Webhook.php index 2d11584e6..55bc855ce 100644 --- a/app/Actions/Webhook.php +++ b/app/Actions/Webhook.php @@ -3,18 +3,67 @@ 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 */ 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. */ 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 @@ +orderBy('name', 'desc') + ->with('trackedEvents') + ->get(); + return view('settings.webhooks.index', ['webhooks' => $webhooks]); } /** @@ -37,7 +40,16 @@ class WebhookController extends Controller */ public function store(Request $request) { - // TODO - Create webhook + $validated = $this->validate($request, [ + 'name' => ['required', 'max:150'], + 'endpoint' => ['required', 'url', 'max:500'], + 'events' => ['required', 'array'] + ]); + + $webhook = new Webhook($validated); + $webhook->save(); + $webhook->updateTrackedEvents(array_values($validated['events'])); + $this->logActivity(ActivityType::WEBHOOK_CREATE, $webhook); return redirect('/settings/webhooks'); } @@ -48,7 +60,9 @@ class WebhookController extends Controller public function edit(string $id) { /** @var Webhook $webhook */ - $webhook = Webhook::query()->findOrFail($id); + $webhook = Webhook::query() + ->with('trackedEvents') + ->findOrFail($id); return view('settings.webhooks.edit', ['webhook' => $webhook]); } @@ -58,10 +72,17 @@ class WebhookController extends Controller */ public function update(Request $request, string $id) { + $validated = $this->validate($request, [ + 'name' => ['required', 'max:150'], + 'endpoint' => ['required', 'url', 'max:500'], + 'events' => ['required', 'array'] + ]); + /** @var Webhook $webhook */ $webhook = Webhook::query()->findOrFail($id); - // TODO - Update + $webhook->fill($validated)->save(); + $webhook->updateTrackedEvents($validated['events']); $this->logActivity(ActivityType::WEBHOOK_UPDATE, $webhook); return redirect('/settings/webhooks'); @@ -85,7 +106,7 @@ class WebhookController extends Controller /** @var Webhook $webhook */ $webhook = Webhook::query()->findOrFail($id); - // TODO - Delete event type relations + $webhook->trackedEvents()->delete(); $webhook->delete(); $this->logActivity(ActivityType::WEBHOOK_DELETE, $webhook); diff --git a/database/migrations/2021_12_07_111343_create_webhooks_table.php b/database/migrations/2021_12_07_111343_create_webhooks_table.php index 7ccfe693d..2ded0b949 100644 --- a/database/migrations/2021_12_07_111343_create_webhooks_table.php +++ b/database/migrations/2021_12_07_111343_create_webhooks_table.php @@ -18,6 +18,18 @@ class CreateWebhooksTable extends Migration $table->string('name', 150); $table->string('endpoint', 500); $table->timestamps(); + + $table->index('name'); + }); + + 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'); }); } diff --git a/resources/js/components/webhook-events.js b/resources/js/components/webhook-events.js index 54080d36e..aa50aa9d8 100644 --- a/resources/js/components/webhook-events.js +++ b/resources/js/components/webhook-events.js @@ -8,7 +8,7 @@ class WebhookEvents { setup() { this.checkboxes = this.$el.querySelectorAll('input[type="checkbox"]'); - this.allCheckbox = this.$refs.all; + this.allCheckbox = this.$el.querySelector('input[type="checkbox"][value="all"]'); this.$el.addEventListener('change', event => { if (event.target.checked && event.target === this.allCheckbox) { diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 6812075b3..209702d0e 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -246,6 +246,7 @@ return [ 'webhooks_events_all' => 'All system events', 'webhooks_name' => 'Webhook Name', 'webhooks_endpoint' => 'Webhook Endpoint', + '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?', diff --git a/resources/views/form/errors.blade.php b/resources/views/form/errors.blade.php new file mode 100644 index 000000000..03cd4be88 --- /dev/null +++ b/resources/views/form/errors.blade.php @@ -0,0 +1,3 @@ +@if($errors->has($name)) +
{{ $errors->first($name) }}
+@endif \ No newline at end of file diff --git a/resources/views/settings/webhooks/create.blade.php b/resources/views/settings/webhooks/create.blade.php index b49afe415..d5fd1d38d 100644 --- a/resources/views/settings/webhooks/create.blade.php +++ b/resources/views/settings/webhooks/create.blade.php @@ -8,7 +8,7 @@ @include('settings.parts.navbar', ['selected' => 'webhooks']) -
+ @include('settings.webhooks.parts.form', ['title' => trans('settings.webhooks_create')])
diff --git a/resources/views/settings/webhooks/delete.blade.php b/resources/views/settings/webhooks/delete.blade.php index a89b01171..65560f65f 100644 --- a/resources/views/settings/webhooks/delete.blade.php +++ b/resources/views/settings/webhooks/delete.blade.php @@ -13,7 +13,7 @@

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

-
id}") }}" method="POST"> + {!! csrf_field() !!} {!! method_field('DELETE') !!} @@ -25,7 +25,7 @@
diff --git a/resources/views/settings/webhooks/edit.blade.php b/resources/views/settings/webhooks/edit.blade.php index d4e60cc14..a221b4ce7 100644 --- a/resources/views/settings/webhooks/edit.blade.php +++ b/resources/views/settings/webhooks/edit.blade.php @@ -7,7 +7,7 @@ @include('settings.parts.navbar', ['selected' => 'webhooks']) - id}") }}" method="POST"> + {!! method_field('PUT') !!} @include('settings.webhooks.parts.form', ['model' => $webhook, 'title' => trans('settings.webhooks_edit')])
diff --git a/resources/views/settings/webhooks/index.blade.php b/resources/views/settings/webhooks/index.blade.php index 8adf60835..999a458ec 100644 --- a/resources/views/settings/webhooks/index.blade.php +++ b/resources/views/settings/webhooks/index.blade.php @@ -14,10 +14,40 @@

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

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

+ {{ trans('common.no_items') }} +

+ @endif + diff --git a/resources/views/settings/webhooks/parts/form.blade.php b/resources/views/settings/webhooks/parts/form.blade.php index 935b01992..e2b3fc34d 100644 --- a/resources/views/settings/webhooks/parts/form.blade.php +++ b/resources/views/settings/webhooks/parts/form.blade.php @@ -24,22 +24,32 @@
+ @include('form.errors', ['name' => 'events']) +

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

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

-
- +
+ @include('form.custom-checkbox', [ + 'name' => 'events[]', + 'value' => 'all', + 'label' => trans('settings.webhooks_events_all'), + 'checked' => old('events') ? in_array('all', old('events')) : (isset($webhook) ? $webhook->tracksEvent('all') : false), + ])
-
+
-
+
@foreach(\BookStack\Actions\ActivityType::all() as $activityType) - +
+ @include('form.custom-checkbox', [ + 'name' => 'events[]', + 'value' => $activityType, + 'label' => $activityType, + 'checked' => old('events') ? in_array($activityType, old('events')) : (isset($webhook) ? $webhook->tracksEvent($activityType) : false), + ]) +
@endforeach
@@ -49,7 +59,7 @@ From 638104125283209d6e81035741fdbb3d412cf334 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 10 Dec 2021 14:54:58 +0000 Subject: [PATCH 04/13] Added testing for webhook management interface --- .../{ => Actions}/WebhookFactory.php | 6 +- .../Actions/WebhookTrackedEventFactory.php | 23 +++ tests/{ => Actions}/AuditLogTest.php | 5 +- tests/Actions/WebhookManagementTest.php | 168 ++++++++++++++++++ tests/StatusTest.php | 5 +- 5 files changed, 204 insertions(+), 3 deletions(-) rename database/factories/{ => Actions}/WebhookFactory.php (77%) create mode 100644 database/factories/Actions/WebhookTrackedEventFactory.php rename tests/{ => Actions}/AuditLogTest.php (98%) create mode 100644 tests/Actions/WebhookManagementTest.php diff --git a/database/factories/WebhookFactory.php b/database/factories/Actions/WebhookFactory.php similarity index 77% rename from database/factories/WebhookFactory.php rename to database/factories/Actions/WebhookFactory.php index 4d716fc0c..a18ffbbc8 100644 --- a/database/factories/WebhookFactory.php +++ b/database/factories/Actions/WebhookFactory.php @@ -1,11 +1,15 @@ Webhook::factory(), + 'event' => ActivityType::all()[array_rand(ActivityType::all())], + ]; + } +} diff --git a/tests/AuditLogTest.php b/tests/Actions/AuditLogTest.php similarity index 98% rename from tests/AuditLogTest.php rename to tests/Actions/AuditLogTest.php index f909cd79a..3f314a98c 100644 --- a/tests/AuditLogTest.php +++ b/tests/Actions/AuditLogTest.php @@ -1,6 +1,6 @@ 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'); + } + + 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'], + ]); + + $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', + ]); + + /** @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], + ]); + $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', + ]); + + $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/StatusTest.php b/tests/StatusTest.php index 37b1b15a1..82c377615 100644 --- a/tests/StatusTest.php +++ b/tests/StatusTest.php @@ -1,10 +1,13 @@ Date: Fri, 10 Dec 2021 14:58:14 +0000 Subject: [PATCH 05/13] Added missing migration down table drop --- database/migrations/2021_12_07_111343_create_webhooks_table.php | 1 + 1 file changed, 1 insertion(+) diff --git a/database/migrations/2021_12_07_111343_create_webhooks_table.php b/database/migrations/2021_12_07_111343_create_webhooks_table.php index 2ded0b949..22b8549f4 100644 --- a/database/migrations/2021_12_07_111343_create_webhooks_table.php +++ b/database/migrations/2021_12_07_111343_create_webhooks_table.php @@ -41,5 +41,6 @@ class CreateWebhooksTable extends Migration public function down() { Schema::dropIfExists('webhooks'); + Schema::dropIfExists('webhook_tracked_events'); } } From 90797001701017bb7ecce11ae39bff1411fbdc35 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 11 Dec 2021 17:29:33 +0000 Subject: [PATCH 06/13] Refactored the activity service - Renamed to "ActivityLogger" to be more focused in usage. - Extracted out query elements to seperate "ActivityQueries" class. - Removed old 'addForEntity' activity method to limit activity record points. --- app/Actions/ActivityLogger.php | 95 +++++++++++++++++ ...ctivityService.php => ActivityQueries.php} | 100 ++---------------- app/Actions/CommentRepo.php | 2 +- app/Auth/UserRepo.php | 9 -- app/Entities/Models/Entity.php | 11 +- app/Entities/Repos/BookRepo.php | 6 +- app/Entities/Repos/BookshelfRepo.php | 6 +- app/Entities/Repos/ChapterRepo.php | 8 +- app/Entities/Repos/PageRepo.php | 10 +- app/Entities/Tools/PermissionsUpdater.php | 2 +- app/Http/Controllers/Auth/LoginController.php | 2 +- app/Http/Controllers/BookController.php | 7 +- app/Http/Controllers/BookSortController.php | 2 +- app/Http/Controllers/BookshelfController.php | 5 +- app/Http/Controllers/HomeController.php | 6 +- .../Controllers/UserProfileController.php | 5 +- app/Providers/CustomFacadeProvider.php | 4 +- .../Actions/WebhookTrackedEventFactory.php | 2 +- tests/Actions/AuditLogTest.php | 20 ++-- tests/Commands/ClearActivityCommandTest.php | 9 +- tests/User/UserProfileTest.php | 8 +- 21 files changed, 166 insertions(+), 153 deletions(-) create mode 100644 app/Actions/ActivityLogger.php rename app/Actions/{ActivityService.php => ActivityQueries.php} (51%) diff --git a/app/Actions/ActivityLogger.php b/app/Actions/ActivityLogger.php new file mode 100644 index 000000000..3a329387f --- /dev/null +++ b/app/Actions/ActivityLogger.php @@ -0,0 +1,95 @@ +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); + } + + /** + * 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) + { + $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); + } +} 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/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/Auth/UserRepo.php b/app/Auth/UserRepo.php index 6d48f1240..ce982d471 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; @@ -215,14 +214,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/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/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/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/WebhookTrackedEventFactory.php b/database/factories/Actions/WebhookTrackedEventFactory.php index 4586f5cca..620776aab 100644 --- a/database/factories/Actions/WebhookTrackedEventFactory.php +++ b/database/factories/Actions/WebhookTrackedEventFactory.php @@ -1,6 +1,6 @@ activityService = app(ActivityService::class); + $this->activityService = app(ActivityLogger::class); } public function test_only_accessible_with_right_permissions() @@ -49,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'); @@ -64,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(); @@ -79,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); @@ -92,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); @@ -105,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')); @@ -129,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/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/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('/') From 917598f7c857a07e8a07f92d71e56e2a214142e2 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 11 Dec 2021 22:29:33 +0000 Subject: [PATCH 07/13] Added webhook call functionality --- app/Actions/ActivityLogger.php | 19 ++- app/Actions/DispatchWebhookJob.php | 120 ++++++++++++++++++ .../views/common/activity-item.blade.php | 2 - 3 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 app/Actions/DispatchWebhookJob.php diff --git a/app/Actions/ActivityLogger.php b/app/Actions/ActivityLogger.php index 3a329387f..ad4ee7375 100644 --- a/app/Actions/ActivityLogger.php +++ b/app/Actions/ActivityLogger.php @@ -5,6 +5,7 @@ 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 @@ -35,6 +36,7 @@ class ActivityLogger $activity->save(); $this->setNotification($type); + $this->dispatchWebhooks($type, $detail); } /** @@ -68,7 +70,7 @@ class ActivityLogger /** * Flashes a notification message to the session if an appropriate message is available. */ - protected function setNotification(string $type) + protected function setNotification(string $type): void { $notificationTextKey = 'activities.' . $type . '_notification'; if (trans()->has($notificationTextKey)) { @@ -77,6 +79,21 @@ class ActivityLogger } } + /** + * @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'); + })->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. diff --git a/app/Actions/DispatchWebhookJob.php b/app/Actions/DispatchWebhookJob.php new file mode 100644 index 000000000..4cc749af3 --- /dev/null +++ b/app/Actions/DispatchWebhookJob.php @@ -0,0 +1,120 @@ +webhook = $webhook; + $this->event = $event; + $this->detail = $detail; + $this->initiator = user(); + $this->initiatedTime = time(); + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $httpClient = new Client([ + 'timeout' => 3, + 'allow_redirects' => ['strict' => true], + ]); + + $request = new Request('POST', $this->webhook->endpoint, [ + 'Content-Type' => 'application/json' + ], json_encode($this->buildWebhookData())); + + try { + $response = $httpClient->send($request); + if ($response->getStatusCode() >= 400) { + Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->getStatusCode()}"); + } + } catch (ClientExceptionInterface $exception) { + Log::error("Received error during webhook call to endpoint {$this->webhook->endpoint}: {$exception->getMessage()}"); + } + } + + 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/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() }} From dbd4281ae80a5be2d631a0c8bd9e8dc29546c92e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 12 Dec 2021 17:39:06 +0000 Subject: [PATCH 08/13] Added active toggle to webhooks To allow easy temporary de-activation without deletion or other workarounds. Updated tests to cover. --- app/Actions/ActivityLogger.php | 11 +++++++---- app/Actions/Webhook.php | 1 + app/Http/Controllers/WebhookController.php | 8 ++++++-- database/factories/Actions/WebhookFactory.php | 1 + .../2021_12_07_111343_create_webhooks_table.php | 2 ++ resources/lang/en/common.php | 3 +++ resources/lang/en/settings.php | 2 ++ resources/views/settings/webhooks/index.blade.php | 8 ++++++-- .../views/settings/webhooks/parts/form.blade.php | 8 ++++++++ tests/Actions/WebhookManagementTest.php | 5 +++++ 10 files changed, 41 insertions(+), 8 deletions(-) diff --git a/app/Actions/ActivityLogger.php b/app/Actions/ActivityLogger.php index ad4ee7375..870e7f96d 100644 --- a/app/Actions/ActivityLogger.php +++ b/app/Actions/ActivityLogger.php @@ -84,10 +84,13 @@ class ActivityLogger */ protected function dispatchWebhooks(string $type, $detail): void { - $webhooks = Webhook::query()->whereHas('trackedEvents', function(Builder $query) use ($type) { - $query->where('event', '=', $type) - ->orWhere('event', '=', 'all'); - })->get(); + $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)); diff --git a/app/Actions/Webhook.php b/app/Actions/Webhook.php index 55bc855ce..ed13856f3 100644 --- a/app/Actions/Webhook.php +++ b/app/Actions/Webhook.php @@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property string $name * @property string $endpoint * @property Collection $trackedEvents + * @property bool $active */ class Webhook extends Model implements Loggable { diff --git a/app/Http/Controllers/WebhookController.php b/app/Http/Controllers/WebhookController.php index 497d623b2..588b256a3 100644 --- a/app/Http/Controllers/WebhookController.php +++ b/app/Http/Controllers/WebhookController.php @@ -43,10 +43,12 @@ class WebhookController extends Controller $validated = $this->validate($request, [ 'name' => ['required', 'max:150'], 'endpoint' => ['required', 'url', 'max:500'], - 'events' => ['required', 'array'] + 'events' => ['required', 'array'], + 'active' => ['required'], ]); $webhook = new Webhook($validated); + $webhook->active = $validated['active'] === 'true'; $webhook->save(); $webhook->updateTrackedEvents(array_values($validated['events'])); @@ -75,12 +77,14 @@ class WebhookController extends Controller $validated = $this->validate($request, [ 'name' => ['required', 'max:150'], 'endpoint' => ['required', 'url', 'max:500'], - 'events' => ['required', 'array'] + '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']); diff --git a/database/factories/Actions/WebhookFactory.php b/database/factories/Actions/WebhookFactory.php index a18ffbbc8..1230d49d1 100644 --- a/database/factories/Actions/WebhookFactory.php +++ b/database/factories/Actions/WebhookFactory.php @@ -20,6 +20,7 @@ class WebhookFactory extends Factory return [ 'name' => 'My webhook for ' . $this->faker->country(), 'endpoint' => $this->faker->url, + 'active' => true, ]; } } diff --git a/database/migrations/2021_12_07_111343_create_webhooks_table.php b/database/migrations/2021_12_07_111343_create_webhooks_table.php index 22b8549f4..be4fc539d 100644 --- a/database/migrations/2021_12_07_111343_create_webhooks_table.php +++ b/database/migrations/2021_12_07_111343_create_webhooks_table.php @@ -16,10 +16,12 @@ class CreateWebhooksTable extends Migration 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) { 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 209702d0e..3556fcf64 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -236,6 +236,7 @@ return [ // 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', @@ -246,6 +247,7 @@ return [ '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.', diff --git a/resources/views/settings/webhooks/index.blade.php b/resources/views/settings/webhooks/index.blade.php index 999a458ec..d6423b6fb 100644 --- a/resources/views/settings/webhooks/index.blade.php +++ b/resources/views/settings/webhooks/index.blade.php @@ -25,6 +25,7 @@ {{ trans('common.name') }} {{ trans('settings.webhook_events_table_header') }} + {{ trans('common.status') }} @foreach($webhooks as $webhook) @@ -39,12 +40,15 @@ {{ $webhook->trackedEvents->count() }} @endif + + {{ trans('common.status_' . ($webhook->active ? 'active' : 'inactive')) }} + @endforeach @else -

- {{ trans('common.no_items') }} +

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

@endif diff --git a/resources/views/settings/webhooks/parts/form.blade.php b/resources/views/settings/webhooks/parts/form.blade.php index e2b3fc34d..458b6767b 100644 --- a/resources/views/settings/webhooks/parts/form.blade.php +++ b/resources/views/settings/webhooks/parts/form.blade.php @@ -9,6 +9,14 @@

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

+
+ @include('form.toggle-switch', [ + 'name' => 'active', + 'value' => old('active') ?? $model->active ?? true, + 'label' => trans('settings.webhooks_active'), + ]) + @include('form.errors', ['name' => 'active']) +
diff --git a/tests/Actions/WebhookManagementTest.php b/tests/Actions/WebhookManagementTest.php index 1423fe6ef..8abf06fc5 100644 --- a/tests/Actions/WebhookManagementTest.php +++ b/tests/Actions/WebhookManagementTest.php @@ -22,6 +22,7 @@ class WebhookManagementTest extends TestCase $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() @@ -38,6 +39,7 @@ class WebhookManagementTest extends TestCase 'name' => 'My first webhook', 'endpoint' => 'https://example.com/webhook', 'events' => ['all'], + 'active' => 'true' ]); $resp->assertRedirect('/settings/webhooks'); @@ -49,6 +51,7 @@ class WebhookManagementTest extends TestCase $this->assertDatabaseHas('webhooks', [ 'name' => 'My first webhook', 'endpoint' => 'https://example.com/webhook', + 'active' => true, ]); /** @var Webhook $webhook */ @@ -79,6 +82,7 @@ class WebhookManagementTest extends TestCase 'name' => 'My updated webhook', 'endpoint' => 'https://example.com/updated-webhook', 'events' => [ActivityType::PAGE_CREATE, ActivityType::PAGE_UPDATE], + 'active' => 'true' ]); $resp->assertRedirect('/settings/webhooks'); @@ -89,6 +93,7 @@ class WebhookManagementTest extends TestCase 'id' => $webhook->id, 'name' => 'My updated webhook', 'endpoint' => 'https://example.com/updated-webhook', + 'active' => true, ]); $trackedEvents = $webhook->trackedEvents()->get(); From 3bf34b6a0dbee3238db3d09ff83613bd9989594d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 12 Dec 2021 18:02:08 +0000 Subject: [PATCH 09/13] Added webhook format example to webhook management views --- resources/lang/en/settings.php | 2 ++ .../views/settings/webhooks/create.blade.php | 2 ++ .../views/settings/webhooks/edit.blade.php | 2 ++ .../webhooks/parts/format-example.blade.php | 34 +++++++++++++++++++ 4 files changed, 40 insertions(+) create mode 100644 resources/views/settings/webhooks/parts/format-example.blade.php diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 3556fcf64..1cca516c8 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -252,6 +252,8 @@ return [ '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/settings/webhooks/create.blade.php b/resources/views/settings/webhooks/create.blade.php index d5fd1d38d..4f20dd077 100644 --- a/resources/views/settings/webhooks/create.blade.php +++ b/resources/views/settings/webhooks/create.blade.php @@ -11,6 +11,8 @@
@include('settings.webhooks.parts.form', ['title' => trans('settings.webhooks_create')])
+ + @include('settings.webhooks.parts.format-example')
@stop diff --git a/resources/views/settings/webhooks/edit.blade.php b/resources/views/settings/webhooks/edit.blade.php index a221b4ce7..3b297eb7b 100644 --- a/resources/views/settings/webhooks/edit.blade.php +++ b/resources/views/settings/webhooks/edit.blade.php @@ -11,6 +11,8 @@ {!! method_field('PUT') !!} @include('settings.webhooks.parts.form', ['model' => $webhook, 'title' => trans('settings.webhooks_edit')]) + + @include('settings.webhooks.parts.format-example')
@stop diff --git a/resources/views/settings/webhooks/parts/format-example.blade.php b/resources/views/settings/webhooks/parts/format-example.blade.php new file mode 100644 index 000000000..135d3193b --- /dev/null +++ b/resources/views/settings/webhooks/parts/format-example.blade.php @@ -0,0 +1,34 @@ +
+

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

+

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

+
{
+    "event": "page_update",
+    "text": "Benny updated page \"My wonderful updated page\"",
+    "triggered_at": "2021-12-11T22:25:10.000000Z",
+    "triggered_by": {
+        "id": 1,
+        "name": "Benny",
+        "slug": "benny"
+    },
+    "triggered_by_profile_url": "https://bookstack.local/user/benny",
+    "webhook_id": 2,
+    "webhook_name": "My page update webhook",
+    "url": "https://bookstack.local/books/my-awesome-book/page/my-wonderful-updated-page",
+    "related_item": {
+        "id": 2432,
+        "book_id": 13,
+        "chapter_id": 554,
+        "name": "My wonderful updated page",
+        "slug": "my-wonderful-updated-page",
+        "priority": 2,
+        "created_at": "2021-12-11T21:53:24.000000Z",
+        "updated_at": "2021-12-11T22:25:10.000000Z",
+        "created_by": 1,
+        "updated_by": 1,
+        "draft": false,
+        "revision_count": 9,
+        "template": false,
+        "owned_by": 1
+    }
+}
+
\ No newline at end of file From 8d8b45860a4227fa1d559ecf672727edd29a4e50 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 12 Dec 2021 18:14:22 +0000 Subject: [PATCH 10/13] Updated REST API docs with links to webhooks & theme-systems --- .../api-docs/parts/getting-started.blade.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/resources/views/api-docs/parts/getting-started.blade.php b/resources/views/api-docs/parts/getting-started.blade.php index ca28a7d90..3bcf29dd4 100644 --- a/resources/views/api-docs/parts/getting-started.blade.php +++ b/resources/views/api-docs/parts/getting-started.blade.php @@ -1,5 +1,27 @@

Getting Started

+

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

+ +
    +
  • + Webhooks - + HTTP POST calls upon events occurring in BookStack. +
  • +
  • + Visual Theme System - + Methods to override views, translations and icons within BookStack. +
  • +
  • + Logical Theme System - + Methods to extend back-end functionality within BookStack. +
  • +
+ +
+
Authentication

To access the API a user has to have the "Access System API" permission enabled on one of their assigned roles. From f27d0d5aebf4bc88a72e6769c2d6b23b6bb2ceb9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 12 Dec 2021 19:01:50 +0000 Subject: [PATCH 11/13] Added testing to cover webhook calling Migrated call logic to Laravel's HTTP client for easier testing capabilities. --- app/Actions/DispatchWebhookJob.php | 25 ++----- tests/Actions/WebhookCallTest.php | 116 +++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 18 deletions(-) create mode 100644 tests/Actions/WebhookCallTest.php diff --git a/app/Actions/DispatchWebhookJob.php b/app/Actions/DispatchWebhookJob.php index 4cc749af3..69d04d36c 100644 --- a/app/Actions/DispatchWebhookJob.php +++ b/app/Actions/DispatchWebhookJob.php @@ -6,16 +6,14 @@ use BookStack\Auth\User; use BookStack\Entities\Models\Entity; use BookStack\Interfaces\Loggable; use BookStack\Model; -use GuzzleHttp\Client; -use GuzzleHttp\Psr7\Request; 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; -use Psr\Http\Client\ClientExceptionInterface; class DispatchWebhookJob implements ShouldQueue { @@ -67,22 +65,13 @@ class DispatchWebhookJob implements ShouldQueue */ public function handle() { - $httpClient = new Client([ - 'timeout' => 3, - 'allow_redirects' => ['strict' => true], - ]); + $response = Http::asJson() + ->withOptions(['allow_redirects' => ['strict' => true]]) + ->timeout(3) + ->post($this->webhook->endpoint, $this->buildWebhookData()); - $request = new Request('POST', $this->webhook->endpoint, [ - 'Content-Type' => 'application/json' - ], json_encode($this->buildWebhookData())); - - try { - $response = $httpClient->send($request); - if ($response->getStatusCode() >= 400) { - Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->getStatusCode()}"); - } - } catch (ClientExceptionInterface $exception) { - Log::error("Received error during webhook call to endpoint {$this->webhook->endpoint}: {$exception->getMessage()}"); + if ($response->failed()) { + Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}"); } } diff --git a/tests/Actions/WebhookCallTest.php b/tests/Actions/WebhookCallTest.php new file mode 100644 index 000000000..958d33d9d --- /dev/null +++ b/tests/Actions/WebhookCallTest.php @@ -0,0 +1,116 @@ +newWebhook([], ['all']); + Bus::fake(); + $this->runEvent(ActivityType::ROLE_CREATE); + Bus::assertDispatched(DispatchWebhookJob::class); + } + + public function test_webhook_listening_to_specific_event_called_on_event() + { + $this->newWebhook([], [ActivityType::ROLE_UPDATE]); + Bus::fake(); + $this->runEvent(ActivityType::ROLE_UPDATE); + Bus::assertDispatched(DispatchWebhookJob::class); + } + + public function test_webhook_listening_to_specific_event_not_called_on_other_event() + { + $this->newWebhook([], [ActivityType::ROLE_UPDATE]); + Bus::fake(); + $this->runEvent(ActivityType::ROLE_CREATE); + Bus::assertNotDispatched(DispatchWebhookJob::class); + } + + public function test_inactive_webhook_not_called_on_event() + { + $this->newWebhook(['active' => false], ['all']); + Bus::fake(); + $this->runEvent(ActivityType::ROLE_CREATE); + Bus::assertNotDispatched(DispatchWebhookJob::class); + } + + public function test_failed_webhook_call_logs_error() + { + $logger = $this->withTestLogger(); + Http::fake([ + '*' => Http::response('', 500), + ]); + $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']); + + $this->runEvent(ActivityType::ROLE_CREATE); + + $this->assertTrue($logger->hasError('Webhook call to endpoint https://wh.example.com failed with status 500')); + } + + public function test_webhook_call_data_format() + { + Http::fake([ + '*' => Http::response('', 200), + ]); + $webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']); + /** @var Page $page */ + $page = Page::query()->first(); + $editor = $this->getEditor(); + + $this->runEvent(ActivityType::PAGE_UPDATE, $page, $editor); + + Http::assertSent(function(Request $request) use ($editor, $page, $webhook) { + $reqData = $request->data(); + return $request->isJson() + && $reqData['event'] === 'page_update' + && $reqData['text'] === ($editor->name . ' updated page "' . $page->name . '"') + && is_string($reqData['triggered_at']) + && $reqData['triggered_by']['name'] === $editor->name + && $reqData['triggered_by_profile_url'] === $editor->getProfileUrl() + && $reqData['webhook_id'] === $webhook->id + && $reqData['webhook_name'] === $webhook->name + && $reqData['url'] === $page->getUrl() + && $reqData['related_item']['name'] === $page->name; + }); + } + + + protected function runEvent(string $event, $detail = '', ?User $user = null) + { + if (is_null($user)) { + $user = $this->getEditor(); + } + + $this->actingAs($user); + + $activityLogger = $this->app->make(ActivityLogger::class); + $activityLogger->add($event, $detail); + } + + protected function newWebhook(array $attrs = [], array $events = ['all']): Webhook + { + /** @var Webhook $webhook */ + $webhook = Webhook::factory()->create($attrs); + + foreach ($events as $event) { + $webhook->trackedEvents()->create(['event' => $event]); + } + + return $webhook; + } + +} \ No newline at end of file From d00ac3101d045afdb92246a042b1c83432266638 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 13 Dec 2021 18:34:18 +0000 Subject: [PATCH 12/13] Allowed database queue usage where desired --- .env.example.complete | 3 +- app/Config/queue.php | 2 +- .../Controllers/MaintenanceController.php | 2 +- .../2021_12_13_152024_create_jobs_table.php | 36 +++++++++++++++++++ ..._12_13_152120_create_failed_jobs_table.php | 36 +++++++++++++++++++ 5 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 database/migrations/2021_12_13_152024_create_jobs_table.php create mode 100644 database/migrations/2021_12_13_152120_create_failed_jobs_table.php 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/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/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/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'); + } +} From 24e29c523b86c4cf36c6f3b2da16f07fced1fe40 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 18 Dec 2021 11:24:58 +0000 Subject: [PATCH 13/13] Aligned notification capitalisation --- resources/lang/en/activities.php | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/resources/lang/en/activities.php b/resources/lang/en/activities.php index 1919df706..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',