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/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index 6efa3df6f..7d1c54646 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -4,13 +4,14 @@ 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; /** * @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 @@ -24,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']; /** @@ -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/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/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..efbd183b3 100644 --- a/database/factories/Activity/Models/CommentFactory.php +++ b/database/factories/Activity/Models/CommentFactory.php @@ -25,8 +25,8 @@ class CommentFactory extends Factory return [ 'html' => $html, - 'text' => $text, 'parent_id' => null, + 'local_id' => 1, ]; } } diff --git a/resources/js/components/page-comment.js b/resources/js/components/page-comment.js index 8284d7f20..79c9d3c2c 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,25 @@ 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) { + this.wysiwygEditor.focus(); + 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]; + setTimeout(() => this.wysiwygEditor.focus(), 50); + }); } async update(event) { @@ -60,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 e2911afc6..cfb0634a9 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; @@ -59,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, }; @@ -86,6 +91,7 @@ export class PageComments extends Component { } resetForm() { + this.removeEditor(); this.formInput.value = ''; this.parentId = null; this.replyToRow.toggleAttribute('hidden', true); @@ -93,12 +99,11 @@ 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'}); - setTimeout(() => { - this.formInput.focus(); - }, 100); + this.loadEditor(); } hideForm() { @@ -112,6 +117,34 @@ export class PageComments extends Component { this.addButtonContainer.toggleAttribute('hidden', false); } + loadEditor() { + if (this.wysiwygEditor) { + this.wysiwygEditor.focus(); + 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]; + 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/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/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/sass/_tinymce.scss b/resources/sass/_tinymce.scss index c4336da7c..b6a2156df 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; + @include lightDark(color, rgba(34,47,62,.5), rgba(155,155,155,.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..b507a810b 100644 --- a/resources/views/comments/comment.blade.php +++ b/resources/views/comments/comment.blade.php @@ -1,9 +1,14 @@ +@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 }}" 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"> @@ -69,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="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') }}">{{ $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/resources/views/comments/comments.blade.php b/resources/views/comments/comments.blade.php index 26d286290..37329c42c 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,14 @@ @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 + @push('post-app-html') + @include('entities.selector-popup') + @endpush + @endif + </section> \ No newline at end of file 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> diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php index cf15e5426..4d4d07dc2 100644 --- a/resources/views/layouts/base.blade.php +++ b/resources/views/layouts/base.blade.php @@ -68,10 +68,13 @@ </div> @yield('bottom') + @stack('post-app-html') + @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> 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..73136235c 100644 --- a/tests/Entity/CommentTest.php +++ b/tests/Entity/CommentTest.php @@ -18,16 +18,16 @@ 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, '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,64 @@ class CommentTest extends TestCase $this->assertActivityExists(ActivityType::COMMENT_DELETE); } - public function test_comments_converts_markdown_input_to_html() + public function test_scripts_cannot_be_injected_via_comment_html() { $page = $this->entities->page(); + + $script = '<script>const a = "script";</script><p onclick="1">My lovely comment</p>'; $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() - { - $this->asAdmin(); - $page = $this->entities->page(); - - $script = '<script>const a = "script";</script>\n\n# sometextinthecomment'; - $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_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_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() @@ -127,15 +145,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 +167,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 +184,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); + } } 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