From a3a30556958e125d33df3c55697c0a8b9273f588 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
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 @@
+<?php
+
+namespace BookStack\Actions;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+class Webhook extends Model
+{
+    use HasFactory;
+}
diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php
index 7ba52d486..25b2a5851 100644
--- a/app/Http/Controllers/RoleController.php
+++ b/app/Http/Controllers/RoleController.php
@@ -23,7 +23,7 @@ class RoleController extends Controller
     /**
      * Show a listing of the roles in the system.
      */
-    public function list()
+    public function index()
     {
         $this->checkPermission('user-roles-manage');
         $roles = $this->permissionsRepo->getAllRoles();
diff --git a/app/Http/Controllers/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 @@
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class WebhookController extends Controller
+{
+    /**
+     * Show all webhooks configured in the system.
+     */
+    public function index()
+    {
+        return view('settings.webhooks.index');
+    }
+}
diff --git a/database/factories/WebhookFactory.php b/database/factories/WebhookFactory.php
new file mode 100644
index 000000000..4d716fc0c
--- /dev/null
+++ b/database/factories/WebhookFactory.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Database\Factories;
+
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+class WebhookFactory extends Factory
+{
+    /**
+     * Define the model's default state.
+     *
+     * @return array
+     */
+    public function definition()
+    {
+        return [
+            'name' => '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 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateWebhooksTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('webhooks', function (Blueprint $table) {
+            $table->increments('id');
+            $table->string('name', 150);
+            $table->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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M10,15l5.88,0c0.27-0.31,0.67-0.5,1.12-0.5c0.83,0,1.5,0.67,1.5,1.5c0,0.83-0.67,1.5-1.5,1.5c-0.44,0-0.84-0.19-1.12-0.5 l-3.98,0c-0.46,2.28-2.48,4-4.9,4c-2.76,0-5-2.24-5-5c0-2.42,1.72-4.44,4-4.9l0,2.07C4.84,13.58,4,14.7,4,16c0,1.65,1.35,3,3,3 s3-1.35,3-3V15z M12.5,4c1.65,0,3,1.35,3,3h2c0-2.76-2.24-5-5-5l0,0c-2.76,0-5,2.24-5,5c0,1.43,0.6,2.71,1.55,3.62l-2.35,3.9 C6.02,14.66,5.5,15.27,5.5,16c0,0.83,0.67,1.5,1.5,1.5s1.5-0.67,1.5-1.5c0-0.16-0.02-0.31-0.07-0.45l3.38-5.63 C10.49,9.61,9.5,8.42,9.5,7C9.5,5.35,10.85,4,12.5,4z M17,13c-0.64,0-1.23,0.2-1.72,0.54l-3.05-5.07C11.53,8.35,11,7.74,11,7 c0-0.83,0.67-1.5,1.5-1.5S14,6.17,14,7c0,0.15-0.02,0.29-0.06,0.43l2.19,3.65C16.41,11.03,16.7,11,17,11l0,0c2.76,0,5,2.24,5,5 c0,2.76-2.24,5-5,5c-1.85,0-3.47-1.01-4.33-2.5l2.67,0C15.82,18.82,16.39,19,17,19c1.65,0,3-1.35,3-3S18.65,13,17,13z"/></svg>
\ No newline at end of file
diff --git a/resources/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 @@
     </div>
 
     <div class="card content-wrap auto-height">
-        <h2 class="list-heading">{{ trans('settings.audit') }}</h2>
+        <h1 class="list-heading">{{ trans('settings.audit') }}</h1>
         <p class="text-muted">{{ trans('settings.audit_desc') }}</p>
 
         <div class="flex-container-row">
diff --git a/resources/views/settings/parts/navbar-with-version.blade.php b/resources/views/settings/parts/navbar-with-version.blade.php
index 09af699a3..bec41146b 100644
--- a/resources/views/settings/parts/navbar-with-version.blade.php
+++ b/resources/views/settings/parts/navbar-with-version.blade.php
@@ -6,10 +6,12 @@ $version - Version of bookstack to display
     <div class="py-m flex fit-content">
         @include('settings.parts.navbar', ['selected' => $selected])
     </div>
-    <div class="flex"></div>
-    <div class="text-right p-m flex fit-content">
-        <a target="_blank" rel="noopener noreferrer" href="https://github.com/BookStackApp/BookStack/releases">
-            BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }}
-        </a>
-    </div>
+</div>
+<div class="px-s">
+    <hr class="darker m-none">
+</div>
+<div class="py-l px-m flex fit-content">
+    <a target="_blank" rel="noopener noreferrer" href="https://github.com/BookStackApp/BookStack/releases">
+        BookStack @if(strpos($version, 'v') !== 0) version @endif {{ $version }}
+    </a>
 </div>
\ No newline at end of file
diff --git a/resources/views/settings/parts/navbar.blade.php b/resources/views/settings/parts/navbar.blade.php
index a472196c5..f2fad378c 100644
--- a/resources/views/settings/parts/navbar.blade.php
+++ b/resources/views/settings/parts/navbar.blade.php
@@ -13,4 +13,7 @@
     @if(userCan('user-roles-manage'))
         <a href="{{ url('/settings/roles') }}" @if($selected == 'roles') class="active" @endif>@icon('lock-open'){{ trans('settings.roles') }}</a>
     @endif
+    @if(userCan('settings-manage'))
+        <a href="{{ url('/settings/webhooks') }}" @if($selected == 'webhooks') class="active" @endif>@icon('webhooks'){{ trans('settings.webhooks') }}</a>
+    @endif
 </nav>
\ No newline at end of file
diff --git a/resources/views/settings/webhooks/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')
+
+    <div class="container small">
+
+        <div class="py-m">
+            @include('settings.parts.navbar', ['selected' => 'webhooks'])
+        </div>
+
+        <div class="card content-wrap auto-height">
+
+            <div class="grid half v-center">
+                <h1 class="list-heading">{{ trans('settings.webhooks') }}</h1>
+
+                <div class="text-right">
+                    <a href="{{ url("/settings/webhooks/new") }}" class="button outline">{{ trans('settings.webhooks_create') }}</a>
+                </div>
+            </div>
+
+
+        </div>
+    </div>
+
+@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 <ssddanbrown@googlemail.com>
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')
+
+    <div class="container small">
+
+        <div class="py-m">
+            @include('settings.parts.navbar', ['selected' => 'webhooks'])
+        </div>
+
+        <form action="{{ url("/settings/webhooks/new") }}" method="POST">
+            @include('settings.webhooks.parts.form', ['title' => trans('settings.webhooks_create')])
+        </form>
+    </div>
+
+@stop
diff --git a/resources/views/settings/webhooks/delete.blade.php b/resources/views/settings/webhooks/delete.blade.php
new file mode 100644
index 000000000..a89b01171
--- /dev/null
+++ b/resources/views/settings/webhooks/delete.blade.php
@@ -0,0 +1,39 @@
+@extends('layouts.simple')
+
+@section('body')
+    <div class="container small">
+
+        <div class="py-m">
+            @include('settings.parts.navbar', ['selected' => 'webhooks'])
+        </div>
+
+        <div class="card content-wrap auto-height">
+            <h1 class="list-heading"> {{ trans('settings.webhooks_delete') }}</h1>
+
+            <p>{{ trans('settings.webhooks_delete_warning', ['webhookName' => $webhook->name]) }}</p>
+
+
+            <form action="{{ url("/settings/webhooks/{$role->id}") }}" method="POST">
+                {!! csrf_field() !!}
+                {!! method_field('DELETE') !!}
+
+                <div class="grid half v-center">
+                    <div>
+                        <p class="text-neg">
+                            <strong>{{ trans('settings.webhooks_delete_confirm') }}</strong>
+                        </p>
+                    </div>
+                    <div>
+                        <div class="form-group text-right">
+                            <a href="{{ url("/settings/webhooks/{$role->id}") }}" class="button outline">{{ trans('common.cancel') }}</a>
+                            <button type="submit" class="button">{{ trans('common.confirm') }}</button>
+                        </div>
+                    </div>
+                </div>
+
+
+            </form>
+        </div>
+
+    </div>
+@stop
diff --git a/resources/views/settings/webhooks/edit.blade.php b/resources/views/settings/webhooks/edit.blade.php
new file mode 100644
index 000000000..d4e60cc14
--- /dev/null
+++ b/resources/views/settings/webhooks/edit.blade.php
@@ -0,0 +1,16 @@
+@extends('layouts.simple')
+
+@section('body')
+
+    <div class="container small">
+        <div class="py-m">
+            @include('settings.parts.navbar', ['selected' => 'webhooks'])
+        </div>
+
+        <form action="{{ url("/settings/webhooks/{$webhook->id}") }}" method="POST">
+            {!! method_field('PUT') !!}
+            @include('settings.webhooks.parts.form', ['model' => $webhook, 'title' => trans('settings.webhooks_edit')])
+        </form>
+    </div>
+
+@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 @@
                 <h1 class="list-heading">{{ trans('settings.webhooks') }}</h1>
 
                 <div class="text-right">
-                    <a href="{{ url("/settings/webhooks/new") }}" class="button outline">{{ trans('settings.webhooks_create') }}</a>
+                    <a href="{{ url("/settings/webhooks/create") }}" class="button outline">{{ trans('settings.webhooks_create') }}</a>
                 </div>
             </div>
 
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() !!}
+
+<div class="card content-wrap auto-height">
+    <h1 class="list-heading">{{ $title }}</h1>
+
+    <div class="setting-list">
+
+        <div class="grid half">
+            <div>
+                <label class="setting-list-label">{{ trans('settings.webhooks_details') }}</label>
+                <p class="small">{{ trans('settings.webhooks_details_desc') }}</p>
+            </div>
+            <div>
+                <div class="form-group">
+                    <label for="name">{{ trans('settings.webhooks_name') }}</label>
+                    @include('form.text', ['name' => 'name'])
+                </div>
+                <div class="form-group">
+                    <label for="endpoint">{{ trans('settings.webhooks_endpoint') }}</label>
+                    @include('form.text', ['name' => 'endpoint'])
+                </div>
+            </div>
+        </div>
+
+        <div component="webhook-events">
+            <label class="setting-list-label">{{ trans('settings.webhooks_events') }}</label>
+            <p class="small">{{ trans('settings.webhooks_events_desc') }}</p>
+            <p class="text-warn small">{{ trans('settings.webhooks_events_warning') }}</p>
+
+            <div>
+                <label><input type="checkbox"
+                              name="events[]"
+                              value="all"
+                              refs="webhook-events@all">
+                    {{ trans('settings.webhooks_events_all') }}</label>
+            </div>
+
+            <hr class="my-m">
+
+            <div class="dual-column-content">
+                @foreach(\BookStack\Actions\ActivityType::all() as $activityType)
+                    <label><input type="checkbox" name="events[]" value="{{ $activityType }}">{{ $activityType }}</label>
+                @endforeach
+            </div>
+        </div>
+
+    </div>
+
+    <div class="form-group text-right">
+        <a href="{{ url("/settings/webhooks") }}" class="button outline">{{ trans('common.cancel') }}</a>
+        @if ($webhook->id ?? false)
+            <a href="{{ url("/settings/roles/delete/{$webhook->id}") }}" class="button outline">{{ trans('settings.webhooks_delete') }}</a>
+        @endif
+        <button type="submit" class="button">{{ trans('settings.webhooks_save') }}</button>
+    </div>
+
+</div>
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 <ssddanbrown@googlemail.com>
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 @@
+<?php
+
+namespace BookStack\Actions;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+
+/**
+ * @property int $id
+ * @property int $webhook_id
+ * @property string $event
+ */
+class WebhookTrackedEvent extends Model
+{
+    protected $fillable = ['event'];
+
+    use HasFactory;
+}
diff --git a/app/Http/Controllers/WebhookController.php b/app/Http/Controllers/WebhookController.php
index 15a31f312..497d623b2 100644
--- a/app/Http/Controllers/WebhookController.php
+++ b/app/Http/Controllers/WebhookController.php
@@ -20,8 +20,11 @@ class WebhookController extends Controller
      */
     public function index()
     {
-        // TODO - Get and pass webhooks
-        return view('settings.webhooks.index');
+        $webhooks = Webhook::query()
+            ->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))
+    <div class="text-neg text-small">{{ $errors->first($name) }}</div>
+@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'])
         </div>
 
-        <form action="{{ url("/settings/webhooks/new") }}" method="POST">
+        <form action="{{ url("/settings/webhooks/create") }}" method="POST">
             @include('settings.webhooks.parts.form', ['title' => trans('settings.webhooks_create')])
         </form>
     </div>
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 @@
             <p>{{ trans('settings.webhooks_delete_warning', ['webhookName' => $webhook->name]) }}</p>
 
 
-            <form action="{{ url("/settings/webhooks/{$role->id}") }}" method="POST">
+            <form action="{{ $webhook->getUrl() }}" method="POST">
                 {!! csrf_field() !!}
                 {!! method_field('DELETE') !!}
 
@@ -25,7 +25,7 @@
                     </div>
                     <div>
                         <div class="form-group text-right">
-                            <a href="{{ url("/settings/webhooks/{$role->id}") }}" class="button outline">{{ trans('common.cancel') }}</a>
+                            <a href="{{ $webhook->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
                             <button type="submit" class="button">{{ trans('common.confirm') }}</button>
                         </div>
                     </div>
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'])
         </div>
 
-        <form action="{{ url("/settings/webhooks/{$webhook->id}") }}" method="POST">
+        <form action="{{ $webhook->getUrl() }}" method="POST">
             {!! method_field('PUT') !!}
             @include('settings.webhooks.parts.form', ['model' => $webhook, 'title' => trans('settings.webhooks_edit')])
         </form>
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 @@
                 <h1 class="list-heading">{{ trans('settings.webhooks') }}</h1>
 
                 <div class="text-right">
-                    <a href="{{ url("/settings/webhooks/create") }}" class="button outline">{{ trans('settings.webhooks_create') }}</a>
+                    <a href="{{ url("/settings/webhooks/create") }}"
+                       class="button outline">{{ trans('settings.webhooks_create') }}</a>
                 </div>
             </div>
 
+            @if(count($webhooks) > 0)
+
+                <table class="table">
+                    <tr>
+                        <th>{{ trans('common.name') }}</th>
+                        <th>{{ trans('settings.webhook_events_table_header') }}</th>
+                    </tr>
+                    @foreach($webhooks as $webhook)
+                        <tr>
+                            <td>
+                                <a href="{{ $webhook->getUrl() }}">{{ $webhook->name }}</a> <br>
+                                <span class="small text-muted italic">{{ $webhook->endpoint }}</span>
+                            </td>
+                            <td>
+                                @if($webhook->tracksEvent('all'))
+                                    {{ trans('settings.webhooks_events_all') }}
+                                @else
+                                    {{ $webhook->trackedEvents->count() }}
+                                @endif
+                            </td>
+                        </tr>
+                    @endforeach
+                </table>
+            @else
+                <p class="text-muted empty-text">
+                    {{ trans('common.no_items') }}
+                </p>
+            @endif
+
 
         </div>
     </div>
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 @@
 
         <div component="webhook-events">
             <label class="setting-list-label">{{ trans('settings.webhooks_events') }}</label>
+            @include('form.errors', ['name' => 'events'])
+
             <p class="small">{{ trans('settings.webhooks_events_desc') }}</p>
             <p class="text-warn small">{{ trans('settings.webhooks_events_warning') }}</p>
 
-            <div>
-                <label><input type="checkbox"
-                              name="events[]"
-                              value="all"
-                              refs="webhook-events@all">
-                    {{ trans('settings.webhooks_events_all') }}</label>
+            <div class="toggle-switch-list">
+                @include('form.custom-checkbox', [
+                    'name' => 'events[]',
+                    'value' => 'all',
+                    'label' => trans('settings.webhooks_events_all'),
+                    'checked' => old('events') ? in_array('all', old('events')) : (isset($webhook) ? $webhook->tracksEvent('all') : false),
+                ])
             </div>
 
-            <hr class="my-m">
+            <hr class="my-s">
 
-            <div class="dual-column-content">
+            <div class="dual-column-content toggle-switch-list">
                 @foreach(\BookStack\Actions\ActivityType::all() as $activityType)
-                    <label><input type="checkbox" name="events[]" value="{{ $activityType }}">{{ $activityType }}</label>
+                    <div>
+                        @include('form.custom-checkbox', [
+                           'name' => 'events[]',
+                           'value' => $activityType,
+                           'label' => $activityType,
+                           'checked' => old('events') ? in_array($activityType, old('events')) : (isset($webhook) ? $webhook->tracksEvent($activityType) : false),
+                       ])
+                    </div>
                 @endforeach
             </div>
         </div>
@@ -49,7 +59,7 @@
     <div class="form-group text-right">
         <a href="{{ url("/settings/webhooks") }}" class="button outline">{{ trans('common.cancel') }}</a>
         @if ($webhook->id ?? false)
-            <a href="{{ url("/settings/roles/delete/{$webhook->id}") }}" class="button outline">{{ trans('settings.webhooks_delete') }}</a>
+            <a href="{{ $webhook->getUrl('/delete') }}" class="button outline">{{ trans('settings.webhooks_delete') }}</a>
         @endif
         <button type="submit" class="button">{{ trans('settings.webhooks_save') }}</button>
     </div>

From 638104125283209d6e81035741fdbb3d412cf334 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
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 @@
 <?php
 
-namespace Database\Factories;
+namespace Database\Factories\Actions;
 
+use BookStack\Actions\Webhook;
 use Illuminate\Database\Eloquent\Factories\Factory;
 
 class WebhookFactory extends Factory
 {
+
+    protected $model = Webhook::class;
+
     /**
      * Define the model's default state.
      *
diff --git a/database/factories/Actions/WebhookTrackedEventFactory.php b/database/factories/Actions/WebhookTrackedEventFactory.php
new file mode 100644
index 000000000..4586f5cca
--- /dev/null
+++ b/database/factories/Actions/WebhookTrackedEventFactory.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Database\Factories;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Actions\Webhook;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+class WebhookTrackedEventFactory extends Factory
+{
+    /**
+     * Define the model's default state.
+     *
+     * @return array
+     */
+    public function definition()
+    {
+        return [
+            'webhook_id' => Webhook::factory(),
+            'event' => ActivityType::all()[array_rand(ActivityType::all())],
+        ];
+    }
+}
diff --git a/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 @@
 <?php
 
-namespace Tests;
+namespace Tests\Actions;
 
 use BookStack\Actions\Activity;
 use BookStack\Actions\ActivityService;
@@ -11,6 +11,9 @@ use BookStack\Entities\Models\Page;
 use BookStack\Entities\Repos\PageRepo;
 use BookStack\Entities\Tools\TrashCan;
 use Carbon\Carbon;
+use Tests\TestCase;
+use function app;
+use function config;
 
 class AuditLogTest extends TestCase
 {
diff --git a/tests/Actions/WebhookManagementTest.php b/tests/Actions/WebhookManagementTest.php
new file mode 100644
index 000000000..1423fe6ef
--- /dev/null
+++ b/tests/Actions/WebhookManagementTest.php
@@ -0,0 +1,168 @@
+<?php
+
+namespace Tests\Actions;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Actions\Webhook;
+use Tests\TestCase;
+
+class WebhookManagementTest extends TestCase
+{
+
+    public function test_index_view()
+    {
+        $webhook = $this->newWebhook([
+            'name' => 'My awesome webhook',
+            'endpoint' => 'https://example.com/donkey/webhook',
+        ], ['all']);
+
+        $resp = $this->asAdmin()->get('/settings/webhooks');
+        $resp->assertOk();
+        $resp->assertElementContains('a[href$="/settings/webhooks/create"]', 'Create New Webhook');
+        $resp->assertElementExists('a[href="' . $webhook->getUrl() . '"]', $webhook->name);
+        $resp->assertSee($webhook->endpoint);
+        $resp->assertSee('All system events');
+    }
+
+    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 @@
 <?php
 
+namespace Tests;
+
+use Exception;
 use Illuminate\Cache\ArrayStore;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Session;
-use Tests\TestCase;
+use Mockery;
 
 class StatusTest extends TestCase
 {

From f2cb3b94f90a33dec2a680d73e02df7a8ddb1fb9 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
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 <ssddanbrown@googlemail.com>
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 @@
+<?php
+
+namespace BookStack\Actions;
+
+use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Entities\Models\Entity;
+use BookStack\Interfaces\Loggable;
+use Illuminate\Support\Facades\Log;
+
+class ActivityLogger
+{
+    protected $permissionService;
+
+    public function __construct(PermissionService $permissionService)
+    {
+        $this->permissionService = $permissionService;
+    }
+
+    /**
+     * Add a generic activity event to the database.
+     *
+     * @param string|Loggable $detail
+     */
+    public function add(string $type, $detail = '')
+    {
+        $detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail;
+
+        $activity = $this->newActivityForUser($type);
+        $activity->detail = $detailToStore;
+
+        if ($detail instanceof Entity) {
+            $activity->entity_id = $detail->id;
+            $activity->entity_type = $detail->getMorphClass();
+        }
+
+        $activity->save();
+        $this->setNotification($type);
+    }
+
+    /**
+     * 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 @@
 <?php
 
-namespace Database\Factories;
+namespace Database\Factories\Actions;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Actions\Webhook;
diff --git a/tests/Actions/AuditLogTest.php b/tests/Actions/AuditLogTest.php
index 3f314a98c..3bdfc3d1a 100644
--- a/tests/Actions/AuditLogTest.php
+++ b/tests/Actions/AuditLogTest.php
@@ -3,7 +3,7 @@
 namespace Tests\Actions;
 
 use BookStack\Actions\Activity;
-use BookStack\Actions\ActivityService;
+use BookStack\Actions\ActivityLogger;
 use BookStack\Actions\ActivityType;
 use BookStack\Auth\UserRepo;
 use BookStack\Entities\Models\Chapter;
@@ -17,13 +17,13 @@ use function config;
 
 class AuditLogTest extends TestCase
 {
-    /** @var ActivityService */
+    /** @var ActivityLogger */
     protected $activityService;
 
     protected function setUp(): void
     {
         parent::setUp();
-        $this->activityService = app(ActivityService::class);
+        $this->activityService = app(ActivityLogger::class);
     }
 
     public function test_only_accessible_with_right_permissions()
@@ -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 <ssddanbrown@googlemail.com>
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 @@
+<?php
+
+namespace BookStack\Actions;
+
+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\Log;
+use Psr\Http\Client\ClientExceptionInterface;
+
+class DispatchWebhookJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    /**
+     * @var Webhook
+     */
+    protected $webhook;
+
+    /**
+     * @var string
+     */
+    protected $event;
+
+    /**
+     * @var string|Loggable
+     */
+    protected $detail;
+
+    /**
+     * @var User
+     */
+    protected $initiator;
+
+    /**
+     * @var int
+     */
+    protected $initiatedTime;
+
+    /**
+     * Create a new job instance.
+     *
+     * @return void
+     */
+    public function __construct(Webhook $webhook, string $event, $detail)
+    {
+        $this->webhook = $webhook;
+        $this->event = $event;
+        $this->detail = $detail;
+        $this->initiator = user();
+        $this->initiatedTime = time();
+    }
+
+    /**
+     * Execute the job.
+     *
+     * @return void
+     */
+    public function handle()
+    {
+        $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
-
     <br>
 
     <span class="text-muted"><small>@icon('time'){{ $activity->created_at->diffForHumans() }}</small></span>

From dbd4281ae80a5be2d631a0c8bd9e8dc29546c92e Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
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 @@
                     <tr>
                         <th>{{ trans('common.name') }}</th>
                         <th>{{ trans('settings.webhook_events_table_header') }}</th>
+                        <th>{{ trans('common.status') }}</th>
                     </tr>
                     @foreach($webhooks as $webhook)
                         <tr>
@@ -39,12 +40,15 @@
                                     {{ $webhook->trackedEvents->count() }}
                                 @endif
                             </td>
+                            <td>
+                                {{ trans('common.status_' . ($webhook->active ? 'active' : 'inactive')) }}
+                            </td>
                         </tr>
                     @endforeach
                 </table>
             @else
-                <p class="text-muted empty-text">
-                    {{ trans('common.no_items') }}
+                <p class="text-muted empty-text px-none">
+                    {{ trans('settings.webhooks_none_created') }}
                 </p>
             @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 @@
             <div>
                 <label class="setting-list-label">{{ trans('settings.webhooks_details') }}</label>
                 <p class="small">{{ trans('settings.webhooks_details_desc') }}</p>
+                <div>
+                    @include('form.toggle-switch', [
+                        'name' => 'active',
+                        'value' => old('active') ?? $model->active ?? true,
+                        'label' => trans('settings.webhooks_active'),
+                    ])
+                    @include('form.errors', ['name' => 'active'])
+                </div>
             </div>
             <div>
                 <div class="form-group">
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 <ssddanbrown@googlemail.com>
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 @@
         <form action="{{ url("/settings/webhooks/create") }}" method="POST">
             @include('settings.webhooks.parts.form', ['title' => trans('settings.webhooks_create')])
         </form>
+
+        @include('settings.webhooks.parts.format-example')
     </div>
 
 @stop
diff --git a/resources/views/settings/webhooks/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')])
         </form>
+
+        @include('settings.webhooks.parts.format-example')
     </div>
 
 @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 @@
+<div component="code-highlighter" class="card content-wrap auto-height">
+    <h2 class="list-heading">{{ trans('settings.webhooks_format_example') }}</h2>
+    <p>{{ trans('settings.webhooks_format_example_desc') }}</p>
+    <pre><code class="language-json">{
+    "event": "page_update",
+    "text": "Benny updated page \"My wonderful updated page\"",
+    "triggered_at": "2021-12-11T22:25:10.000000Z",
+    "triggered_by": {
+        "id": 1,
+        "name": "Benny",
+        "slug": "benny"
+    },
+    "triggered_by_profile_url": "https://bookstack.local/user/benny",
+    "webhook_id": 2,
+    "webhook_name": "My page update webhook",
+    "url": "https://bookstack.local/books/my-awesome-book/page/my-wonderful-updated-page",
+    "related_item": {
+        "id": 2432,
+        "book_id": 13,
+        "chapter_id": 554,
+        "name": "My wonderful updated page",
+        "slug": "my-wonderful-updated-page",
+        "priority": 2,
+        "created_at": "2021-12-11T21:53:24.000000Z",
+        "updated_at": "2021-12-11T22:25:10.000000Z",
+        "created_by": 1,
+        "updated_by": 1,
+        "draft": false,
+        "revision_count": 9,
+        "template": false,
+        "owned_by": 1
+    }
+}</code></pre>
+</div>
\ No newline at end of file

From 8d8b45860a4227fa1d559ecf672727edd29a4e50 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
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 @@
 <h1 class="list-heading text-capitals mb-l">Getting Started</h1>
 
+<p class="mb-none">
+    This documentation covers use of the REST API. <br>
+    Some alternative options for extension and customization can be found below:
+</p>
+
+<ul>
+    <li>
+        <a href="{{ url('/settings/webhooks') }}" target="_blank" rel="noopener noreferrer">Webhooks</a> -
+        HTTP POST calls upon events occurring in BookStack.
+    </li>
+    <li>
+        <a href="https://github.com/BookStackApp/BookStack/blob/master/dev/docs/visual-theme-system.md" target="_blank" rel="noopener noreferrer">Visual Theme System</a> -
+        Methods to override views, translations and icons within BookStack.
+    </li>
+    <li>
+        <a href="https://github.com/BookStackApp/BookStack/blob/master/dev/docs/logical-theme-system.md" target="_blank" rel="noopener noreferrer">Logical Theme System</a> -
+        Methods to extend back-end functionality within BookStack.
+    </li>
+</ul>
+
+<hr>
+
 <h5 id="authentication" class="text-mono mb-m">Authentication</h5>
 <p>
     To access the API a user has to have the <em>"Access System API"</em> permission enabled on one of their assigned roles.

From f27d0d5aebf4bc88a72e6769c2d6b23b6bb2ceb9 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
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 @@
+<?php
+
+namespace Tests\Actions;
+
+use BookStack\Actions\ActivityLogger;
+use BookStack\Actions\ActivityType;
+use BookStack\Actions\DispatchWebhookJob;
+use BookStack\Actions\Webhook;
+use BookStack\Auth\User;
+use BookStack\Entities\Models\Page;
+use Illuminate\Http\Client\Request;
+use Illuminate\Support\Facades\Bus;
+use Illuminate\Support\Facades\Http;
+use Tests\TestCase;
+
+class WebhookCallTest extends TestCase
+{
+
+    public function test_webhook_listening_to_all_called_on_event()
+    {
+        $this->newWebhook([], ['all']);
+        Bus::fake();
+        $this->runEvent(ActivityType::ROLE_CREATE);
+        Bus::assertDispatched(DispatchWebhookJob::class);
+    }
+
+    public function test_webhook_listening_to_specific_event_called_on_event()
+    {
+        $this->newWebhook([], [ActivityType::ROLE_UPDATE]);
+        Bus::fake();
+        $this->runEvent(ActivityType::ROLE_UPDATE);
+        Bus::assertDispatched(DispatchWebhookJob::class);
+    }
+
+    public function test_webhook_listening_to_specific_event_not_called_on_other_event()
+    {
+        $this->newWebhook([], [ActivityType::ROLE_UPDATE]);
+        Bus::fake();
+        $this->runEvent(ActivityType::ROLE_CREATE);
+        Bus::assertNotDispatched(DispatchWebhookJob::class);
+    }
+
+    public function test_inactive_webhook_not_called_on_event()
+    {
+        $this->newWebhook(['active' => false], ['all']);
+        Bus::fake();
+        $this->runEvent(ActivityType::ROLE_CREATE);
+        Bus::assertNotDispatched(DispatchWebhookJob::class);
+    }
+
+    public function test_failed_webhook_call_logs_error()
+    {
+        $logger = $this->withTestLogger();
+        Http::fake([
+            '*' => Http::response('', 500),
+        ]);
+        $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
+
+        $this->runEvent(ActivityType::ROLE_CREATE);
+
+        $this->assertTrue($logger->hasError('Webhook call to endpoint https://wh.example.com failed with status 500'));
+    }
+
+    public function test_webhook_call_data_format()
+    {
+        Http::fake([
+            '*' => Http::response('', 200),
+        ]);
+        $webhook = $this->newWebhook(['active' => true, 'endpoint' => 'https://wh.example.com'], ['all']);
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $editor = $this->getEditor();
+
+        $this->runEvent(ActivityType::PAGE_UPDATE, $page, $editor);
+
+        Http::assertSent(function(Request $request) use ($editor, $page, $webhook) {
+            $reqData = $request->data();
+            return $request->isJson()
+                && $reqData['event'] === 'page_update'
+                && $reqData['text'] === ($editor->name . ' updated page "' . $page->name . '"')
+                && is_string($reqData['triggered_at'])
+                && $reqData['triggered_by']['name'] === $editor->name
+                && $reqData['triggered_by_profile_url'] === $editor->getProfileUrl()
+                && $reqData['webhook_id'] === $webhook->id
+                && $reqData['webhook_name'] === $webhook->name
+                && $reqData['url'] === $page->getUrl()
+                && $reqData['related_item']['name'] === $page->name;
+        });
+    }
+
+
+    protected function runEvent(string $event, $detail = '', ?User $user = null)
+    {
+        if (is_null($user)) {
+            $user = $this->getEditor();
+        }
+
+        $this->actingAs($user);
+
+        $activityLogger = $this->app->make(ActivityLogger::class);
+        $activityLogger->add($event, $detail);
+    }
+
+    protected function newWebhook(array $attrs = [], array $events = ['all']): Webhook
+    {
+        /** @var Webhook $webhook */
+        $webhook = Webhook::factory()->create($attrs);
+
+        foreach ($events as $event) {
+            $webhook->trackedEvents()->create(['event' => $event]);
+        }
+
+        return $webhook;
+    }
+
+}
\ No newline at end of file

From d00ac3101d045afdb92246a042b1c83432266638 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
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 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateJobsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('jobs', function (Blueprint $table) {
+            $table->bigIncrements('id');
+            $table->string('queue')->index();
+            $table->longText('payload');
+            $table->unsignedTinyInteger('attempts');
+            $table->unsignedInteger('reserved_at')->nullable();
+            $table->unsignedInteger('available_at');
+            $table->unsignedInteger('created_at');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('jobs');
+    }
+}
diff --git a/database/migrations/2021_12_13_152120_create_failed_jobs_table.php b/database/migrations/2021_12_13_152120_create_failed_jobs_table.php
new file mode 100644
index 000000000..6aa6d743e
--- /dev/null
+++ b/database/migrations/2021_12_13_152120_create_failed_jobs_table.php
@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateFailedJobsTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('failed_jobs', function (Blueprint $table) {
+            $table->id();
+            $table->string('uuid')->unique();
+            $table->text('connection');
+            $table->text('queue');
+            $table->longText('payload');
+            $table->longText('exception');
+            $table->timestamp('failed_at')->useCurrent();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::dropIfExists('failed_jobs');
+    }
+}

From 24e29c523b86c4cf36c6f3b2da16f07fced1fe40 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
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',