From 5c92b72fdd419ccb6f77bfdf0a1cb1358c51a9d8 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Tue, 30 Jan 2024 14:27:09 +0000
Subject: [PATCH 1/6] Comments: Added input wysiwyg for creating/updating
 comments

Not supporting old content, existing HTML or updating yet.
---
 app/Activity/Tools/CommentTree.php          | 11 ++++++++
 resources/js/components/page-comment.js     | 27 ++++++++++++++++---
 resources/js/components/page-comments.js    | 30 ++++++++++++++++++---
 resources/js/components/wysiwyg-input.js    |  7 ++---
 resources/sass/_tinymce.scss                |  7 +++++
 resources/views/comments/comment.blade.php  |  2 ++
 resources/views/comments/comments.blade.php | 10 ++++++-
 resources/views/layouts/base.blade.php      |  3 +++
 8 files changed, 85 insertions(+), 12 deletions(-)

diff --git a/app/Activity/Tools/CommentTree.php b/app/Activity/Tools/CommentTree.php
index 3303add39..16f6804ea 100644
--- a/app/Activity/Tools/CommentTree.php
+++ b/app/Activity/Tools/CommentTree.php
@@ -41,6 +41,17 @@ class CommentTree
         return $this->tree;
     }
 
+    public function canUpdateAny(): bool
+    {
+        foreach ($this->comments as $comment) {
+            if (userCan('comment-update', $comment)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
     /**
      * @param Comment[] $comments
      */
diff --git a/resources/js/components/page-comment.js b/resources/js/components/page-comment.js
index 8284d7f20..dc6ca8264 100644
--- a/resources/js/components/page-comment.js
+++ b/resources/js/components/page-comment.js
@@ -1,5 +1,6 @@
 import {Component} from './component';
 import {getLoading, htmlToDom} from '../services/dom';
+import {buildForInput} from "../wysiwyg/config";
 
 export class PageComment extends Component {
 
@@ -11,7 +12,12 @@ export class PageComment extends Component {
         this.deletedText = this.$opts.deletedText;
         this.updatedText = this.$opts.updatedText;
 
-        // Element References
+        // Editor reference and text options
+        this.wysiwygEditor = null;
+        this.wysiwygLanguage = this.$opts.wysiwygLanguage;
+        this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
+
+        // Element references
         this.container = this.$el;
         this.contentContainer = this.$refs.contentContainer;
         this.form = this.$refs.form;
@@ -50,8 +56,23 @@ export class PageComment extends Component {
 
     startEdit() {
         this.toggleEditMode(true);
-        const lineCount = this.$refs.input.value.split('\n').length;
-        this.$refs.input.style.height = `${(lineCount * 20) + 40}px`;
+
+        if (this.wysiwygEditor) {
+            return;
+        }
+
+        const config = buildForInput({
+            language: this.wysiwygLanguage,
+            containerElement: this.input,
+            darkMode: document.documentElement.classList.contains('dark-mode'),
+            textDirection: this.wysiwygTextDirection,
+            translations: {},
+            translationMap: window.editor_translations,
+        });
+
+        window.tinymce.init(config).then(editors => {
+            this.wysiwygEditor = editors[0];
+        });
     }
 
     async update(event) {
diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js
index e2911afc6..ebcc95f07 100644
--- a/resources/js/components/page-comments.js
+++ b/resources/js/components/page-comments.js
@@ -1,5 +1,6 @@
 import {Component} from './component';
 import {getLoading, htmlToDom} from '../services/dom';
+import {buildForInput} from "../wysiwyg/config";
 
 export class PageComments extends Component {
 
@@ -21,6 +22,11 @@ export class PageComments extends Component {
         this.hideFormButton = this.$refs.hideFormButton;
         this.removeReplyToButton = this.$refs.removeReplyToButton;
 
+        // WYSIWYG options
+        this.wysiwygLanguage = this.$opts.wysiwygLanguage;
+        this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
+        this.wysiwygEditor = null;
+
         // Translations
         this.createdText = this.$opts.createdText;
         this.countText = this.$opts.countText;
@@ -96,9 +102,7 @@ export class PageComments extends Component {
         this.formContainer.toggleAttribute('hidden', false);
         this.addButtonContainer.toggleAttribute('hidden', true);
         this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'});
-        setTimeout(() => {
-            this.formInput.focus();
-        }, 100);
+        this.loadEditor();
     }
 
     hideForm() {
@@ -112,6 +116,26 @@ export class PageComments extends Component {
         this.addButtonContainer.toggleAttribute('hidden', false);
     }
 
+    loadEditor() {
+        if (this.wysiwygEditor) {
+            return;
+        }
+
+        const config = buildForInput({
+            language: this.wysiwygLanguage,
+            containerElement: this.formInput,
+            darkMode: document.documentElement.classList.contains('dark-mode'),
+            textDirection: this.wysiwygTextDirection,
+            translations: {},
+            translationMap: window.editor_translations,
+        });
+
+        window.tinymce.init(config).then(editors => {
+            this.wysiwygEditor = editors[0];
+            this.wysiwygEditor.focus();
+        });
+    }
+
     getCommentCount() {
         return this.container.querySelectorAll('[component="page-comment"]').length;
     }
diff --git a/resources/js/components/wysiwyg-input.js b/resources/js/components/wysiwyg-input.js
index 88c06a334..ad964aed2 100644
--- a/resources/js/components/wysiwyg-input.js
+++ b/resources/js/components/wysiwyg-input.js
@@ -10,11 +10,8 @@ export class WysiwygInput extends Component {
             language: this.$opts.language,
             containerElement: this.elem,
             darkMode: document.documentElement.classList.contains('dark-mode'),
-            textDirection: this.textDirection,
-            translations: {
-                imageUploadErrorText: this.$opts.imageUploadErrorText,
-                serverUploadLimitText: this.$opts.serverUploadLimitText,
-            },
+            textDirection: this.$opts.textDirection,
+            translations: {},
             translationMap: window.editor_translations,
         });
 
diff --git a/resources/sass/_tinymce.scss b/resources/sass/_tinymce.scss
index c4336da7c..fb5ea7e6f 100644
--- a/resources/sass/_tinymce.scss
+++ b/resources/sass/_tinymce.scss
@@ -30,6 +30,13 @@
   display: block;
 }
 
+.wysiwyg-input.mce-content-body:before {
+  padding: 1rem;
+  top: 4px;
+  font-style: italic;
+  color: rgba(34,47,62,.5)
+}
+
 // Default styles for our custom root nodes
 .page-content.mce-content-body doc-root {
   display: block;
diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php
index 1cb709160..4340cfdf5 100644
--- a/resources/views/comments/comment.blade.php
+++ b/resources/views/comments/comment.blade.php
@@ -4,6 +4,8 @@
      option:page-comment:comment-parent-id="{{ $comment->parent_id }}"
      option:page-comment:updated-text="{{ trans('entities.comment_updated_success') }}"
      option:page-comment:deleted-text="{{ trans('entities.comment_deleted_success') }}"
+     option:page-comment:wysiwyg-language="{{ $locale->htmlLang() }}"
+     option:page-comment:wysiwyg-text-direction="{{ $locale->htmlDirection() }}"
      id="comment{{$comment->local_id}}"
      class="comment-box">
     <div class="header">
diff --git a/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php
index 26d286290..2c314864b 100644
--- a/resources/views/comments/comments.blade.php
+++ b/resources/views/comments/comments.blade.php
@@ -2,6 +2,8 @@
          option:page-comments:page-id="{{ $page->id }}"
          option:page-comments:created-text="{{ trans('entities.comment_created_success') }}"
          option:page-comments:count-text="{{ trans('entities.comment_count') }}"
+         option:page-comments:wysiwyg-language="{{ $locale->htmlLang() }}"
+         option:page-comments:wysiwyg-text-direction="{{ $locale->htmlDirection() }}"
          class="comments-list"
          aria-label="{{ trans('entities.comments') }}">
 
@@ -24,7 +26,6 @@
 
     @if(userCan('comment-create-all'))
         @include('comments.create')
-
         @if (!$commentTree->empty())
             <div refs="page-comments@addButtonContainer" class="text-right">
                 <button type="button"
@@ -34,4 +35,11 @@
         @endif
     @endif
 
+    @if(userCan('comment-create-all') || $commentTree->canUpdateAny())
+        @push('post-app-scripts')
+            <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
+            @include('form.editor-translations')
+        @endpush
+    @endif
+
 </section>
\ No newline at end of file
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php
index cf15e5426..43cca6b14 100644
--- a/resources/views/layouts/base.blade.php
+++ b/resources/views/layouts/base.blade.php
@@ -68,10 +68,13 @@
     </div>
 
     @yield('bottom')
+
+
     @if($cspNonce ?? false)
         <script src="{{ versioned_asset('dist/app.js') }}" nonce="{{ $cspNonce }}"></script>
     @endif
     @yield('scripts')
+    @stack('post-app-scripts')
 
     @include('layouts.parts.base-body-end')
 </body>

From adf0baebb9ffc61cc944c0572ec6dbb12a5b41a0 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Tue, 30 Jan 2024 15:16:58 +0000
Subject: [PATCH 2/6] Comments: Added back-end HTML support, fixed editor focus

Also fixed handling of editors when moved in DOM, to properly remove
then re-init before & after move to avoid issues.
---
 app/Activity/CommentRepo.php                  | 26 ++++---------------
 .../Controllers/CommentController.php         | 17 +++++++-----
 resources/js/components/page-comment.js       |  6 +++--
 resources/js/components/page-comments.js      | 17 +++++++++---
 resources/views/comments/comment.blade.php    |  2 +-
 resources/views/comments/create.blade.php     |  2 +-
 6 files changed, 34 insertions(+), 36 deletions(-)

diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php
index ce2950e4d..3336e17e9 100644
--- a/app/Activity/CommentRepo.php
+++ b/app/Activity/CommentRepo.php
@@ -5,7 +5,7 @@ namespace BookStack\Activity;
 use BookStack\Activity\Models\Comment;
 use BookStack\Entities\Models\Entity;
 use BookStack\Facades\Activity as ActivityService;
-use League\CommonMark\CommonMarkConverter;
+use BookStack\Util\HtmlDescriptionFilter;
 
 class CommentRepo
 {
@@ -20,13 +20,12 @@ class CommentRepo
     /**
      * Create a new comment on an entity.
      */
-    public function create(Entity $entity, string $text, ?int $parent_id): Comment
+    public function create(Entity $entity, string $html, ?int $parent_id): Comment
     {
         $userId = user()->id;
         $comment = new Comment();
 
-        $comment->text = $text;
-        $comment->html = $this->commentToHtml($text);
+        $comment->html = HtmlDescriptionFilter::filterFromString($html);
         $comment->created_by = $userId;
         $comment->updated_by = $userId;
         $comment->local_id = $this->getNextLocalId($entity);
@@ -42,11 +41,10 @@ class CommentRepo
     /**
      * Update an existing comment.
      */
-    public function update(Comment $comment, string $text): Comment
+    public function update(Comment $comment, string $html): Comment
     {
         $comment->updated_by = user()->id;
-        $comment->text = $text;
-        $comment->html = $this->commentToHtml($text);
+        $comment->html = HtmlDescriptionFilter::filterFromString($html);
         $comment->save();
 
         ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
@@ -64,20 +62,6 @@ class CommentRepo
         ActivityService::add(ActivityType::COMMENT_DELETE, $comment);
     }
 
-    /**
-     * Convert the given comment Markdown to HTML.
-     */
-    public function commentToHtml(string $commentText): string
-    {
-        $converter = new CommonMarkConverter([
-            'html_input'         => 'strip',
-            'max_nesting_level'  => 10,
-            'allow_unsafe_links' => false,
-        ]);
-
-        return $converter->convert($commentText);
-    }
-
     /**
      * Get the next local ID relative to the linked entity.
      */
diff --git a/app/Activity/Controllers/CommentController.php b/app/Activity/Controllers/CommentController.php
index 516bcac75..340524cd0 100644
--- a/app/Activity/Controllers/CommentController.php
+++ b/app/Activity/Controllers/CommentController.php
@@ -22,8 +22,8 @@ class CommentController extends Controller
      */
     public function savePageComment(Request $request, int $pageId)
     {
-        $this->validate($request, [
-            'text'      => ['required', 'string'],
+        $input = $this->validate($request, [
+            'html'      => ['required', 'string'],
             'parent_id' => ['nullable', 'integer'],
         ]);
 
@@ -39,7 +39,7 @@ class CommentController extends Controller
 
         // Create a new comment.
         $this->checkPermission('comment-create-all');
-        $comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id'));
+        $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null);
 
         return view('comments.comment-branch', [
             'readOnly' => false,
@@ -57,17 +57,20 @@ class CommentController extends Controller
      */
     public function update(Request $request, int $commentId)
     {
-        $this->validate($request, [
-            'text' => ['required', 'string'],
+        $input = $this->validate($request, [
+            'html' => ['required', 'string'],
         ]);
 
         $comment = $this->commentRepo->getById($commentId);
         $this->checkOwnablePermission('page-view', $comment->entity);
         $this->checkOwnablePermission('comment-update', $comment);
 
-        $comment = $this->commentRepo->update($comment, $request->get('text'));
+        $comment = $this->commentRepo->update($comment, $input['html']);
 
-        return view('comments.comment', ['comment' => $comment, 'readOnly' => false]);
+        return view('comments.comment', [
+            'comment' => $comment,
+            'readOnly' => false,
+        ]);
     }
 
     /**
diff --git a/resources/js/components/page-comment.js b/resources/js/components/page-comment.js
index dc6ca8264..79c9d3c2c 100644
--- a/resources/js/components/page-comment.js
+++ b/resources/js/components/page-comment.js
@@ -1,6 +1,6 @@
 import {Component} from './component';
 import {getLoading, htmlToDom} from '../services/dom';
-import {buildForInput} from "../wysiwyg/config";
+import {buildForInput} from '../wysiwyg/config';
 
 export class PageComment extends Component {
 
@@ -58,6 +58,7 @@ export class PageComment extends Component {
         this.toggleEditMode(true);
 
         if (this.wysiwygEditor) {
+            this.wysiwygEditor.focus();
             return;
         }
 
@@ -72,6 +73,7 @@ export class PageComment extends Component {
 
         window.tinymce.init(config).then(editors => {
             this.wysiwygEditor = editors[0];
+            setTimeout(() => this.wysiwygEditor.focus(), 50);
         });
     }
 
@@ -81,7 +83,7 @@ export class PageComment extends Component {
         this.form.toggleAttribute('hidden', true);
 
         const reqData = {
-            text: this.input.value,
+            html: this.wysiwygEditor.getContent(),
             parent_id: this.parentId || null,
         };
 
diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js
index ebcc95f07..cfb0634a9 100644
--- a/resources/js/components/page-comments.js
+++ b/resources/js/components/page-comments.js
@@ -1,6 +1,6 @@
 import {Component} from './component';
 import {getLoading, htmlToDom} from '../services/dom';
-import {buildForInput} from "../wysiwyg/config";
+import {buildForInput} from '../wysiwyg/config';
 
 export class PageComments extends Component {
 
@@ -65,9 +65,8 @@ export class PageComments extends Component {
         this.form.after(loading);
         this.form.toggleAttribute('hidden', true);
 
-        const text = this.formInput.value;
         const reqData = {
-            text,
+            html: this.wysiwygEditor.getContent(),
             parent_id: this.parentId || null,
         };
 
@@ -92,6 +91,7 @@ export class PageComments extends Component {
     }
 
     resetForm() {
+        this.removeEditor();
         this.formInput.value = '';
         this.parentId = null;
         this.replyToRow.toggleAttribute('hidden', true);
@@ -99,6 +99,7 @@ export class PageComments extends Component {
     }
 
     showForm() {
+        this.removeEditor();
         this.formContainer.toggleAttribute('hidden', false);
         this.addButtonContainer.toggleAttribute('hidden', true);
         this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'});
@@ -118,6 +119,7 @@ export class PageComments extends Component {
 
     loadEditor() {
         if (this.wysiwygEditor) {
+            this.wysiwygEditor.focus();
             return;
         }
 
@@ -132,10 +134,17 @@ export class PageComments extends Component {
 
         window.tinymce.init(config).then(editors => {
             this.wysiwygEditor = editors[0];
-            this.wysiwygEditor.focus();
+            setTimeout(() => this.wysiwygEditor.focus(), 50);
         });
     }
 
+    removeEditor() {
+        if (this.wysiwygEditor) {
+            this.wysiwygEditor.remove();
+            this.wysiwygEditor = null;
+        }
+    }
+
     getCommentCount() {
         return this.container.querySelectorAll('[component="page-comment"]').length;
     }
diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php
index 4340cfdf5..e00307f0f 100644
--- a/resources/views/comments/comment.blade.php
+++ b/resources/views/comments/comment.blade.php
@@ -77,7 +77,7 @@
     @if(!$readOnly && userCan('comment-update', $comment))
         <form novalidate refs="page-comment@form" hidden class="content pt-s px-s block">
             <div class="form-group description-input">
-                <textarea refs="page-comment@input" name="markdown" rows="3" placeholder="{{ trans('entities.comment_placeholder') }}">{{ $comment->text }}</textarea>
+                <textarea refs="page-comment@input" name="html" rows="3" placeholder="{{ trans('entities.comment_placeholder') }}">{{ $comment->html }}</textarea>
             </div>
             <div class="form-group text-right">
                 <button type="button" class="button outline" refs="page-comment@form-cancel">{{ trans('common.cancel') }}</button>
diff --git a/resources/views/comments/create.blade.php b/resources/views/comments/create.blade.php
index cb7905ddc..417f0c606 100644
--- a/resources/views/comments/create.blade.php
+++ b/resources/views/comments/create.blade.php
@@ -16,7 +16,7 @@
         <div class="content px-s pt-s">
             <form refs="page-comments@form" novalidate>
                 <div class="form-group description-input">
-                <textarea refs="page-comments@form-input" name="markdown"
+                <textarea refs="page-comments@form-input" name="html"
                           rows="3"
                           placeholder="{{ trans('entities.comment_placeholder') }}"></textarea>
                 </div>

From e9a19d587857ba5afcaa411718af61b62aaff1ac Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Wed, 31 Jan 2024 14:22:04 +0000
Subject: [PATCH 3/6] Comments: Added wysiwyg link selector, updated tests,
 removed command

- Updated existing tests with recent back-end changes, mainly to use
  HTML data.
- Removed old comment regen command that's no longer required.
---
 .../RegenerateCommentContentCommand.php       | 49 -----------
 .../Activity/Models/CommentFactory.php        |  1 +
 resources/views/comments/comments.blade.php   |  3 +
 resources/views/layouts/base.blade.php        |  2 +-
 tests/Activity/WatchTest.php                  | 10 +--
 .../RegenerateCommentContentCommandTest.php   | 31 -------
 tests/Entity/CommentTest.php                  | 82 +++++++++++--------
 7 files changed, 58 insertions(+), 120 deletions(-)
 delete mode 100644 app/Console/Commands/RegenerateCommentContentCommand.php
 delete mode 100644 tests/Commands/RegenerateCommentContentCommandTest.php

diff --git a/app/Console/Commands/RegenerateCommentContentCommand.php b/app/Console/Commands/RegenerateCommentContentCommand.php
deleted file mode 100644
index f7ec42620..000000000
--- a/app/Console/Commands/RegenerateCommentContentCommand.php
+++ /dev/null
@@ -1,49 +0,0 @@
-<?php
-
-namespace BookStack\Console\Commands;
-
-use BookStack\Activity\CommentRepo;
-use BookStack\Activity\Models\Comment;
-use Illuminate\Console\Command;
-use Illuminate\Support\Facades\DB;
-
-class RegenerateCommentContentCommand extends Command
-{
-    /**
-     * The name and signature of the console command.
-     *
-     * @var string
-     */
-    protected $signature = 'bookstack:regenerate-comment-content
-                            {--database= : The database connection to use}';
-
-    /**
-     * The console command description.
-     *
-     * @var string
-     */
-    protected $description = 'Regenerate the stored HTML of all comments';
-
-    /**
-     * Execute the console command.
-     */
-    public function handle(CommentRepo $commentRepo): int
-    {
-        $connection = DB::getDefaultConnection();
-        if ($this->option('database') !== null) {
-            DB::setDefaultConnection($this->option('database'));
-        }
-
-        Comment::query()->chunk(100, function ($comments) use ($commentRepo) {
-            foreach ($comments as $comment) {
-                $comment->html = $commentRepo->commentToHtml($comment->text);
-                $comment->save();
-            }
-        });
-
-        DB::setDefaultConnection($connection);
-        $this->comment('Comment HTML content has been regenerated');
-
-        return 0;
-    }
-}
diff --git a/database/factories/Activity/Models/CommentFactory.php b/database/factories/Activity/Models/CommentFactory.php
index 4b76cd11d..35d6db9bd 100644
--- a/database/factories/Activity/Models/CommentFactory.php
+++ b/database/factories/Activity/Models/CommentFactory.php
@@ -27,6 +27,7 @@ class CommentFactory extends Factory
             'html'      => $html,
             'text'      => $text,
             'parent_id' => null,
+            'local_id'  => 1,
         ];
     }
 }
diff --git a/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php
index 2c314864b..37329c42c 100644
--- a/resources/views/comments/comments.blade.php
+++ b/resources/views/comments/comments.blade.php
@@ -40,6 +40,9 @@
             <script src="{{ versioned_asset('libs/tinymce/tinymce.min.js') }}" nonce="{{ $cspNonce }}"></script>
             @include('form.editor-translations')
         @endpush
+        @push('post-app-html')
+            @include('entities.selector-popup')
+        @endpush
     @endif
 
 </section>
\ No newline at end of file
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php
index 43cca6b14..4d4d07dc2 100644
--- a/resources/views/layouts/base.blade.php
+++ b/resources/views/layouts/base.blade.php
@@ -68,7 +68,7 @@
     </div>
 
     @yield('bottom')
-
+    @stack('post-app-html')
 
     @if($cspNonce ?? false)
         <script src="{{ versioned_asset('dist/app.js') }}" nonce="{{ $cspNonce }}"></script>
diff --git a/tests/Activity/WatchTest.php b/tests/Activity/WatchTest.php
index 38935bbf5..605b60fd4 100644
--- a/tests/Activity/WatchTest.php
+++ b/tests/Activity/WatchTest.php
@@ -196,7 +196,7 @@ class WatchTest extends TestCase
         $notifications = Notification::fake();
 
         $this->asAdmin()->post("/comment/{$entities['page']->id}", [
-            'text' => 'My new comment'
+            'html' => '<p>My new comment</p>'
         ]);
         $notifications->assertSentTo($editor, CommentCreationNotification::class);
     }
@@ -217,12 +217,12 @@ class WatchTest extends TestCase
         $notifications = Notification::fake();
 
         $this->actingAs($editor)->post("/comment/{$entities['page']->id}", [
-            'text' => 'My new comment'
+            'html' => '<p>My new comment</p>'
         ]);
         $comment = $entities['page']->comments()->orderBy('id', 'desc')->first();
 
         $this->asAdmin()->post("/comment/{$entities['page']->id}", [
-            'text' => 'My new comment response',
+            'html' => '<p>My new comment response</p>',
             'parent_id' => $comment->local_id,
         ]);
         $notifications->assertSentTo($editor, CommentCreationNotification::class);
@@ -257,7 +257,7 @@ class WatchTest extends TestCase
 
         // Comment post
         $this->actingAs($admin)->post("/comment/{$entities['page']->id}", [
-            'text' => 'My new comment response',
+            'html' => '<p>My new comment response</p>',
         ]);
 
         $notifications->assertSentTo($editor, function (CommentCreationNotification $notification) use ($editor, $admin, $entities) {
@@ -376,7 +376,7 @@ class WatchTest extends TestCase
         $this->permissions->disableEntityInheritedPermissions($page);
 
         $this->asAdmin()->post("/comment/{$page->id}", [
-            'text' => 'My new comment response',
+            'html' => '<p>My new comment response</p>',
         ])->assertOk();
 
         $notifications->assertNothingSentTo($editor);
diff --git a/tests/Commands/RegenerateCommentContentCommandTest.php b/tests/Commands/RegenerateCommentContentCommandTest.php
deleted file mode 100644
index 4940d66c3..000000000
--- a/tests/Commands/RegenerateCommentContentCommandTest.php
+++ /dev/null
@@ -1,31 +0,0 @@
-<?php
-
-namespace Tests\Commands;
-
-use BookStack\Activity\Models\Comment;
-use Tests\TestCase;
-
-class RegenerateCommentContentCommandTest extends TestCase
-{
-    public function test_regenerate_comment_content_command()
-    {
-        Comment::query()->forceCreate([
-            'html' => 'some_old_content',
-            'text' => 'some_fresh_content',
-        ]);
-
-        $this->assertDatabaseHas('comments', [
-            'html' => 'some_old_content',
-        ]);
-
-        $exitCode = \Artisan::call('bookstack:regenerate-comment-content');
-        $this->assertTrue($exitCode === 0, 'Command executed successfully');
-
-        $this->assertDatabaseMissing('comments', [
-            'html' => 'some_old_content',
-        ]);
-        $this->assertDatabaseHas('comments', [
-            'html' => "<p>some_fresh_content</p>\n",
-        ]);
-    }
-}
diff --git a/tests/Entity/CommentTest.php b/tests/Entity/CommentTest.php
index 23fc68197..c080359bc 100644
--- a/tests/Entity/CommentTest.php
+++ b/tests/Entity/CommentTest.php
@@ -27,7 +27,7 @@ class CommentTest extends TestCase
             'local_id'    => 1,
             'entity_id'   => $page->id,
             'entity_type' => Page::newModelInstance()->getMorphClass(),
-            'text'        => $comment->text,
+            'text'        => null,
             'parent_id'   => 2,
         ]);
 
@@ -43,17 +43,17 @@ class CommentTest extends TestCase
         $this->postJson("/comment/$page->id", $comment->getAttributes());
 
         $comment = $page->comments()->first();
-        $newText = 'updated text content';
+        $newHtml = '<p>updated text content</p>';
         $resp = $this->putJson("/comment/$comment->id", [
-            'text' => $newText,
+            'html' => $newHtml,
         ]);
 
         $resp->assertStatus(200);
-        $resp->assertSee($newText);
-        $resp->assertDontSee($comment->text);
+        $resp->assertSee($newHtml, false);
+        $resp->assertDontSee($comment->html, false);
 
         $this->assertDatabaseHas('comments', [
-            'text'      => $newText,
+            'html'      => $newHtml,
             'entity_id' => $page->id,
         ]);
 
@@ -80,46 +80,28 @@ class CommentTest extends TestCase
         $this->assertActivityExists(ActivityType::COMMENT_DELETE);
     }
 
-    public function test_comments_converts_markdown_input_to_html()
-    {
-        $page = $this->entities->page();
-        $this->asAdmin()->postJson("/comment/$page->id", [
-            'text' => '# My Title',
-        ]);
-
-        $this->assertDatabaseHas('comments', [
-            'entity_id'   => $page->id,
-            'entity_type' => $page->getMorphClass(),
-            'text'        => '# My Title',
-            'html'        => "<h1>My Title</h1>\n",
-        ]);
-
-        $pageView = $this->get($page->getUrl());
-        $pageView->assertSee('<h1>My Title</h1>', false);
-    }
-
-    public function test_html_cannot_be_injected_via_comment_content()
+    public function test_scripts_cannot_be_injected_via_comment_html()
     {
         $this->asAdmin();
         $page = $this->entities->page();
 
-        $script = '<script>const a = "script";</script>\n\n# sometextinthecomment';
+        $script = '<script>const a = "script";</script><p onclick="1">My lovely comment</p>';
         $this->postJson("/comment/$page->id", [
-            'text' => $script,
+            'html' => $script,
         ]);
 
         $pageView = $this->get($page->getUrl());
         $pageView->assertDontSee($script, false);
-        $pageView->assertSee('sometextinthecomment');
+        $pageView->assertSee('<p>My lovely comment</p>', false);
 
         $comment = $page->comments()->first();
         $this->putJson("/comment/$comment->id", [
-            'text' => $script . 'updated',
+            'html' => $script . '<p>updated</p>',
         ]);
 
         $pageView = $this->get($page->getUrl());
         $pageView->assertDontSee($script, false);
-        $pageView->assertSee('sometextinthecommentupdated');
+        $pageView->assertSee('<p>My lovely comment</p><p>updated</p>');
     }
 
     public function test_reply_comments_are_nested()
@@ -127,15 +109,17 @@ class CommentTest extends TestCase
         $this->asAdmin();
         $page = $this->entities->page();
 
-        $this->postJson("/comment/$page->id", ['text' => 'My new comment']);
-        $this->postJson("/comment/$page->id", ['text' => 'My new comment']);
+        $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
+        $this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
 
         $respHtml = $this->withHtml($this->get($page->getUrl()));
         $respHtml->assertElementCount('.comment-branch', 3);
         $respHtml->assertElementNotExists('.comment-branch .comment-branch');
 
         $comment = $page->comments()->first();
-        $resp = $this->postJson("/comment/$page->id", ['text' => 'My nested comment', 'parent_id' => $comment->local_id]);
+        $resp = $this->postJson("/comment/$page->id", [
+            'html' => '<p>My nested comment</p>', 'parent_id' => $comment->local_id
+        ]);
         $resp->assertStatus(200);
 
         $respHtml = $this->withHtml($this->get($page->getUrl()));
@@ -147,7 +131,7 @@ class CommentTest extends TestCase
     {
         $page = $this->entities->page();
 
-        $this->asAdmin()->postJson("/comment/$page->id", ['text' => 'My great comment to see in the editor']);
+        $this->asAdmin()->postJson("/comment/$page->id", ['html' => '<p>My great comment to see in the editor</p>']);
 
         $respHtml = $this->withHtml($this->get($page->getUrl('/edit')));
         $respHtml->assertElementContains('.comment-box .content', 'My great comment to see in the editor');
@@ -164,4 +148,34 @@ class CommentTest extends TestCase
         $pageResp = $this->asAdmin()->get($page->getUrl());
         $pageResp->assertSee('Wolfeschlegels…');
     }
+
+    public function test_comment_editor_js_loaded_with_create_or_edit_permissions()
+    {
+        $editor = $this->users->editor();
+        $page = $this->entities->page();
+
+        $resp = $this->actingAs($editor)->get($page->getUrl());
+        $resp->assertSee('tinymce.min.js?', false);
+        $resp->assertSee('window.editor_translations', false);
+        $resp->assertSee('component="entity-selector"', false);
+
+        $this->permissions->removeUserRolePermissions($editor, ['comment-create-all']);
+        $this->permissions->grantUserRolePermissions($editor, ['comment-update-own']);
+
+        $resp = $this->actingAs($editor)->get($page->getUrl());
+        $resp->assertDontSee('tinymce.min.js?', false);
+        $resp->assertDontSee('window.editor_translations', false);
+        $resp->assertDontSee('component="entity-selector"', false);
+
+        Comment::factory()->create([
+            'created_by'  => $editor->id,
+            'entity_type' => 'page',
+            'entity_id'   => $page->id,
+        ]);
+
+        $resp = $this->actingAs($editor)->get($page->getUrl());
+        $resp->assertSee('tinymce.min.js?', false);
+        $resp->assertSee('window.editor_translations', false);
+        $resp->assertSee('component="entity-selector"', false);
+    }
 }

From 06901b878f2c8057a6f9b7d2e0adfda425c68dee Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Wed, 31 Jan 2024 16:20:22 +0000
Subject: [PATCH 4/6] Comments: Added HTML filter on load, tinymce elem
 filtering

- Added filter on load to help prevent potentially dangerous comment
  HTML in DB at load time (if it gets passed input filtering, or is
  existing).
- Added TinyMCE valid_elements for input wysiwygs, to gracefully degrade
  content at point of user-view, rather than surprising the user by
  stripping content, which TinyMCE would show, post-save.
---
 app/Activity/Models/Comment.php            |  6 ++++++
 resources/js/wysiwyg/config.js             |  1 +
 resources/views/comments/comment.blade.php |  7 +++++--
 tests/Entity/CommentTest.php               | 17 +++++++++++++++--
 4 files changed, 27 insertions(+), 4 deletions(-)

diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php
index 6efa3df6f..038788afb 100644
--- a/app/Activity/Models/Comment.php
+++ b/app/Activity/Models/Comment.php
@@ -4,6 +4,7 @@ namespace BookStack\Activity\Models;
 
 use BookStack\App\Model;
 use BookStack\Users\Models\HasCreatorAndUpdater;
+use BookStack\Util\HtmlContentFilter;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -73,4 +74,9 @@ class Comment extends Model implements Loggable
     {
         return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
     }
+
+    public function safeHtml(): string
+    {
+        return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
+    }
 }
diff --git a/resources/js/wysiwyg/config.js b/resources/js/wysiwyg/config.js
index 36d78b325..fa2df9c11 100644
--- a/resources/js/wysiwyg/config.js
+++ b/resources/js/wysiwyg/config.js
@@ -339,6 +339,7 @@ export function buildForInput(options) {
         toolbar: 'bold italic link bullist numlist',
         content_style: getContentStyle(options),
         file_picker_types: 'file',
+        valid_elements: 'p,a[href|title],ol,ul,li,strong,em,br',
         file_picker_callback: filePickerCallback,
         init_instance_callback(editor) {
             addCustomHeadContent(editor.getDoc());
diff --git a/resources/views/comments/comment.blade.php b/resources/views/comments/comment.blade.php
index e00307f0f..b507a810b 100644
--- a/resources/views/comments/comment.blade.php
+++ b/resources/views/comments/comment.blade.php
@@ -1,3 +1,6 @@
+@php
+    $commentHtml = $comment->safeHtml();
+@endphp
 <div component="{{ $readOnly ? '' : 'page-comment' }}"
      option:page-comment:comment-id="{{ $comment->id }}"
      option:page-comment:comment-local-id="{{ $comment->local_id }}"
@@ -71,13 +74,13 @@
                 <a class="text-muted text-small" href="#comment{{ $comment->parent_id }}">@icon('reply'){{ trans('entities.comment_in_reply_to', ['commentId' => '#' . $comment->parent_id]) }}</a>
             </p>
         @endif
-        {!! $comment->html  !!}
+        {!! $commentHtml  !!}
     </div>
 
     @if(!$readOnly && userCan('comment-update', $comment))
         <form novalidate refs="page-comment@form" hidden class="content pt-s px-s block">
             <div class="form-group description-input">
-                <textarea refs="page-comment@input" name="html" rows="3" placeholder="{{ trans('entities.comment_placeholder') }}">{{ $comment->html }}</textarea>
+                <textarea refs="page-comment@input" name="html" rows="3" placeholder="{{ trans('entities.comment_placeholder') }}">{{ $commentHtml }}</textarea>
             </div>
             <div class="form-group text-right">
                 <button type="button" class="button outline" refs="page-comment@form-cancel">{{ trans('common.cancel') }}</button>
diff --git a/tests/Entity/CommentTest.php b/tests/Entity/CommentTest.php
index c080359bc..76e014e80 100644
--- a/tests/Entity/CommentTest.php
+++ b/tests/Entity/CommentTest.php
@@ -82,11 +82,10 @@ class CommentTest extends TestCase
 
     public function test_scripts_cannot_be_injected_via_comment_html()
     {
-        $this->asAdmin();
         $page = $this->entities->page();
 
         $script = '<script>const a = "script";</script><p onclick="1">My lovely comment</p>';
-        $this->postJson("/comment/$page->id", [
+        $this->asAdmin()->postJson("/comment/$page->id", [
             'html' => $script,
         ]);
 
@@ -104,6 +103,20 @@ class CommentTest extends TestCase
         $pageView->assertSee('<p>My lovely comment</p><p>updated</p>');
     }
 
+    public function test_scripts_are_removed_even_if_already_in_db()
+    {
+        $page = $this->entities->page();
+        Comment::factory()->create([
+            'html' => '<script>superbadscript</script><p onclick="superbadonclick">scriptincommentest</p>',
+            'entity_type' => 'page', 'entity_id' => $page
+        ]);
+
+        $resp = $this->asAdmin()->get($page->getUrl());
+        $resp->assertSee('scriptincommentest', false);
+        $resp->assertDontSee('superbadscript', false);
+        $resp->assertDontSee('superbadonclick', false);
+    }
+
     public function test_reply_comments_are_nested()
     {
         $this->asAdmin();

From fee9045dacd5deca3ae88b17a22b3fff60c01195 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Wed, 31 Jan 2024 16:35:58 +0000
Subject: [PATCH 5/6] Comments: Removed remaining uses of redundant 'text'
 field

Opened #4821 to remove the DB field in a few releases time.
---
 app/Activity/Models/Comment.php                       | 4 ++--
 database/factories/Activity/Models/CommentFactory.php | 1 -
 tests/Entity/CommentTest.php                          | 4 ++--
 tests/Permissions/RolePermissionsTest.php             | 8 ++------
 4 files changed, 6 insertions(+), 11 deletions(-)

diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php
index 038788afb..7d1c54646 100644
--- a/app/Activity/Models/Comment.php
+++ b/app/Activity/Models/Comment.php
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
 
 /**
  * @property int      $id
- * @property string   $text
+ * @property string   $text - Deprecated & now unused (#4821)
  * @property string   $html
  * @property int|null $parent_id  - Relates to local_id, not id
  * @property int      $local_id
@@ -25,7 +25,7 @@ class Comment extends Model implements Loggable
     use HasFactory;
     use HasCreatorAndUpdater;
 
-    protected $fillable = ['text', 'parent_id'];
+    protected $fillable = ['parent_id'];
     protected $appends = ['created', 'updated'];
 
     /**
diff --git a/database/factories/Activity/Models/CommentFactory.php b/database/factories/Activity/Models/CommentFactory.php
index 35d6db9bd..efbd183b3 100644
--- a/database/factories/Activity/Models/CommentFactory.php
+++ b/database/factories/Activity/Models/CommentFactory.php
@@ -25,7 +25,6 @@ class CommentFactory extends Factory
 
         return [
             'html'      => $html,
-            'text'      => $text,
             'parent_id' => null,
             'local_id'  => 1,
         ];
diff --git a/tests/Entity/CommentTest.php b/tests/Entity/CommentTest.php
index 76e014e80..eb4bccb7c 100644
--- a/tests/Entity/CommentTest.php
+++ b/tests/Entity/CommentTest.php
@@ -18,10 +18,10 @@ class CommentTest extends TestCase
         $resp = $this->postJson("/comment/$page->id", $comment->getAttributes());
 
         $resp->assertStatus(200);
-        $resp->assertSee($comment->text);
+        $resp->assertSee($comment->html, false);
 
         $pageResp = $this->get($page->getUrl());
-        $pageResp->assertSee($comment->text);
+        $pageResp->assertSee($comment->html, false);
 
         $this->assertDatabaseHas('comments', [
             'local_id'    => 1,
diff --git a/tests/Permissions/RolePermissionsTest.php b/tests/Permissions/RolePermissionsTest.php
index ccb158faf..d3146bd47 100644
--- a/tests/Permissions/RolePermissionsTest.php
+++ b/tests/Permissions/RolePermissionsTest.php
@@ -738,16 +738,12 @@ class RolePermissionsTest extends TestCase
 
     private function addComment(Page $page): TestResponse
     {
-        $comment = Comment::factory()->make();
-
-        return $this->postJson("/comment/$page->id", $comment->only('text', 'html'));
+        return $this->postJson("/comment/$page->id", ['html' => '<p>New comment content</p>']);
     }
 
     private function updateComment(Comment $comment): TestResponse
     {
-        $commentData = Comment::factory()->make();
-
-        return $this->putJson("/comment/{$comment->id}", $commentData->only('text', 'html'));
+        return $this->putJson("/comment/{$comment->id}", ['html' => '<p>Updated comment content</p>']);
     }
 
     private function deleteComment(Comment $comment): TestResponse

From 47f082c085ab8440b294f701b57289f101ddd1f4 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Wed, 31 Jan 2024 16:47:58 +0000
Subject: [PATCH 6/6] Comments: Added HTML filter test, fixed placeholder in
 dark mode

---
 resources/sass/_tinymce.scss |  2 +-
 tests/Entity/CommentTest.php | 23 +++++++++++++++++++++++
 2 files changed, 24 insertions(+), 1 deletion(-)

diff --git a/resources/sass/_tinymce.scss b/resources/sass/_tinymce.scss
index fb5ea7e6f..b6a2156df 100644
--- a/resources/sass/_tinymce.scss
+++ b/resources/sass/_tinymce.scss
@@ -34,7 +34,7 @@
   padding: 1rem;
   top: 4px;
   font-style: italic;
-  color: rgba(34,47,62,.5)
+  @include lightDark(color, rgba(34,47,62,.5), rgba(155,155,155,.5))
 }
 
 // Default styles for our custom root nodes
diff --git a/tests/Entity/CommentTest.php b/tests/Entity/CommentTest.php
index eb4bccb7c..73136235c 100644
--- a/tests/Entity/CommentTest.php
+++ b/tests/Entity/CommentTest.php
@@ -117,6 +117,29 @@ class CommentTest extends TestCase
         $resp->assertDontSee('superbadonclick', false);
     }
 
+    public function test_comment_html_is_limited()
+    {
+        $page = $this->entities->page();
+        $input = '<h1>Test</h1><p id="abc" href="beans">Content<a href="#cat" data-a="b">a</a><section>Hello</section></p>';
+        $expected = '<p>Content<a href="#cat">a</a></p>';
+
+        $resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]);
+        $resp->assertOk();
+        $this->assertDatabaseHas('comments', [
+           'entity_type' => 'page',
+           'entity_id' => $page->id,
+           'html' => $expected,
+        ]);
+
+        $comment = $page->comments()->first();
+        $resp = $this->put("/comment/{$comment->id}", ['html' => $input]);
+        $resp->assertOk();
+        $this->assertDatabaseHas('comments', [
+            'id'   => $comment->id,
+            'html' => $expected,
+        ]);
+    }
+
     public function test_reply_comments_are_nested()
     {
         $this->asAdmin();