mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-05-02 15:30:06 +00:00
Merge pull request #4815 from BookStackApp/comment_wysiwyg
Comment WYSIWYG Inputs
This commit is contained in:
commit
16af833124
19 changed files with 228 additions and 178 deletions
app
Activity
Console/Commands
database/factories/Activity/Models
resources
js
sass
views
tests
Activity
Commands
Entity
Permissions
|
@ -5,7 +5,7 @@ namespace BookStack\Activity;
|
||||||
use BookStack\Activity\Models\Comment;
|
use BookStack\Activity\Models\Comment;
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Facades\Activity as ActivityService;
|
use BookStack\Facades\Activity as ActivityService;
|
||||||
use League\CommonMark\CommonMarkConverter;
|
use BookStack\Util\HtmlDescriptionFilter;
|
||||||
|
|
||||||
class CommentRepo
|
class CommentRepo
|
||||||
{
|
{
|
||||||
|
@ -20,13 +20,12 @@ class CommentRepo
|
||||||
/**
|
/**
|
||||||
* Create a new comment on an entity.
|
* 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;
|
$userId = user()->id;
|
||||||
$comment = new Comment();
|
$comment = new Comment();
|
||||||
|
|
||||||
$comment->text = $text;
|
$comment->html = HtmlDescriptionFilter::filterFromString($html);
|
||||||
$comment->html = $this->commentToHtml($text);
|
|
||||||
$comment->created_by = $userId;
|
$comment->created_by = $userId;
|
||||||
$comment->updated_by = $userId;
|
$comment->updated_by = $userId;
|
||||||
$comment->local_id = $this->getNextLocalId($entity);
|
$comment->local_id = $this->getNextLocalId($entity);
|
||||||
|
@ -42,11 +41,10 @@ class CommentRepo
|
||||||
/**
|
/**
|
||||||
* Update an existing comment.
|
* 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->updated_by = user()->id;
|
||||||
$comment->text = $text;
|
$comment->html = HtmlDescriptionFilter::filterFromString($html);
|
||||||
$comment->html = $this->commentToHtml($text);
|
|
||||||
$comment->save();
|
$comment->save();
|
||||||
|
|
||||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||||
|
@ -64,20 +62,6 @@ class CommentRepo
|
||||||
ActivityService::add(ActivityType::COMMENT_DELETE, $comment);
|
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.
|
* Get the next local ID relative to the linked entity.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -22,8 +22,8 @@ class CommentController extends Controller
|
||||||
*/
|
*/
|
||||||
public function savePageComment(Request $request, int $pageId)
|
public function savePageComment(Request $request, int $pageId)
|
||||||
{
|
{
|
||||||
$this->validate($request, [
|
$input = $this->validate($request, [
|
||||||
'text' => ['required', 'string'],
|
'html' => ['required', 'string'],
|
||||||
'parent_id' => ['nullable', 'integer'],
|
'parent_id' => ['nullable', 'integer'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@ -39,7 +39,7 @@ class CommentController extends Controller
|
||||||
|
|
||||||
// Create a new comment.
|
// Create a new comment.
|
||||||
$this->checkPermission('comment-create-all');
|
$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', [
|
return view('comments.comment-branch', [
|
||||||
'readOnly' => false,
|
'readOnly' => false,
|
||||||
|
@ -57,17 +57,20 @@ class CommentController extends Controller
|
||||||
*/
|
*/
|
||||||
public function update(Request $request, int $commentId)
|
public function update(Request $request, int $commentId)
|
||||||
{
|
{
|
||||||
$this->validate($request, [
|
$input = $this->validate($request, [
|
||||||
'text' => ['required', 'string'],
|
'html' => ['required', 'string'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$comment = $this->commentRepo->getById($commentId);
|
$comment = $this->commentRepo->getById($commentId);
|
||||||
$this->checkOwnablePermission('page-view', $comment->entity);
|
$this->checkOwnablePermission('page-view', $comment->entity);
|
||||||
$this->checkOwnablePermission('comment-update', $comment);
|
$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,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -4,13 +4,14 @@ namespace BookStack\Activity\Models;
|
||||||
|
|
||||||
use BookStack\App\Model;
|
use BookStack\App\Model;
|
||||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||||
|
use BookStack\Util\HtmlContentFilter;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property string $text
|
* @property string $text - Deprecated & now unused (#4821)
|
||||||
* @property string $html
|
* @property string $html
|
||||||
* @property int|null $parent_id - Relates to local_id, not id
|
* @property int|null $parent_id - Relates to local_id, not id
|
||||||
* @property int $local_id
|
* @property int $local_id
|
||||||
|
@ -24,7 +25,7 @@ class Comment extends Model implements Loggable
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use HasCreatorAndUpdater;
|
use HasCreatorAndUpdater;
|
||||||
|
|
||||||
protected $fillable = ['text', 'parent_id'];
|
protected $fillable = ['parent_id'];
|
||||||
protected $appends = ['created', 'updated'];
|
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})";
|
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 ?? '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,17 @@ class CommentTree
|
||||||
return $this->tree;
|
return $this->tree;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function canUpdateAny(): bool
|
||||||
|
{
|
||||||
|
foreach ($this->comments as $comment) {
|
||||||
|
if (userCan('comment-update', $comment)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Comment[] $comments
|
* @param Comment[] $comments
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -25,8 +25,8 @@ class CommentFactory extends Factory
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'html' => $html,
|
'html' => $html,
|
||||||
'text' => $text,
|
|
||||||
'parent_id' => null,
|
'parent_id' => null,
|
||||||
|
'local_id' => 1,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import {Component} from './component';
|
import {Component} from './component';
|
||||||
import {getLoading, htmlToDom} from '../services/dom';
|
import {getLoading, htmlToDom} from '../services/dom';
|
||||||
|
import {buildForInput} from '../wysiwyg/config';
|
||||||
|
|
||||||
export class PageComment extends Component {
|
export class PageComment extends Component {
|
||||||
|
|
||||||
|
@ -11,7 +12,12 @@ export class PageComment extends Component {
|
||||||
this.deletedText = this.$opts.deletedText;
|
this.deletedText = this.$opts.deletedText;
|
||||||
this.updatedText = this.$opts.updatedText;
|
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.container = this.$el;
|
||||||
this.contentContainer = this.$refs.contentContainer;
|
this.contentContainer = this.$refs.contentContainer;
|
||||||
this.form = this.$refs.form;
|
this.form = this.$refs.form;
|
||||||
|
@ -50,8 +56,25 @@ export class PageComment extends Component {
|
||||||
|
|
||||||
startEdit() {
|
startEdit() {
|
||||||
this.toggleEditMode(true);
|
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) {
|
async update(event) {
|
||||||
|
@ -60,7 +83,7 @@ export class PageComment extends Component {
|
||||||
this.form.toggleAttribute('hidden', true);
|
this.form.toggleAttribute('hidden', true);
|
||||||
|
|
||||||
const reqData = {
|
const reqData = {
|
||||||
text: this.input.value,
|
html: this.wysiwygEditor.getContent(),
|
||||||
parent_id: this.parentId || null,
|
parent_id: this.parentId || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import {Component} from './component';
|
import {Component} from './component';
|
||||||
import {getLoading, htmlToDom} from '../services/dom';
|
import {getLoading, htmlToDom} from '../services/dom';
|
||||||
|
import {buildForInput} from '../wysiwyg/config';
|
||||||
|
|
||||||
export class PageComments extends Component {
|
export class PageComments extends Component {
|
||||||
|
|
||||||
|
@ -21,6 +22,11 @@ export class PageComments extends Component {
|
||||||
this.hideFormButton = this.$refs.hideFormButton;
|
this.hideFormButton = this.$refs.hideFormButton;
|
||||||
this.removeReplyToButton = this.$refs.removeReplyToButton;
|
this.removeReplyToButton = this.$refs.removeReplyToButton;
|
||||||
|
|
||||||
|
// WYSIWYG options
|
||||||
|
this.wysiwygLanguage = this.$opts.wysiwygLanguage;
|
||||||
|
this.wysiwygTextDirection = this.$opts.wysiwygTextDirection;
|
||||||
|
this.wysiwygEditor = null;
|
||||||
|
|
||||||
// Translations
|
// Translations
|
||||||
this.createdText = this.$opts.createdText;
|
this.createdText = this.$opts.createdText;
|
||||||
this.countText = this.$opts.countText;
|
this.countText = this.$opts.countText;
|
||||||
|
@ -59,9 +65,8 @@ export class PageComments extends Component {
|
||||||
this.form.after(loading);
|
this.form.after(loading);
|
||||||
this.form.toggleAttribute('hidden', true);
|
this.form.toggleAttribute('hidden', true);
|
||||||
|
|
||||||
const text = this.formInput.value;
|
|
||||||
const reqData = {
|
const reqData = {
|
||||||
text,
|
html: this.wysiwygEditor.getContent(),
|
||||||
parent_id: this.parentId || null,
|
parent_id: this.parentId || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -86,6 +91,7 @@ export class PageComments extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
resetForm() {
|
resetForm() {
|
||||||
|
this.removeEditor();
|
||||||
this.formInput.value = '';
|
this.formInput.value = '';
|
||||||
this.parentId = null;
|
this.parentId = null;
|
||||||
this.replyToRow.toggleAttribute('hidden', true);
|
this.replyToRow.toggleAttribute('hidden', true);
|
||||||
|
@ -93,12 +99,11 @@ export class PageComments extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
showForm() {
|
showForm() {
|
||||||
|
this.removeEditor();
|
||||||
this.formContainer.toggleAttribute('hidden', false);
|
this.formContainer.toggleAttribute('hidden', false);
|
||||||
this.addButtonContainer.toggleAttribute('hidden', true);
|
this.addButtonContainer.toggleAttribute('hidden', true);
|
||||||
this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'});
|
this.formContainer.scrollIntoView({behavior: 'smooth', block: 'nearest'});
|
||||||
setTimeout(() => {
|
this.loadEditor();
|
||||||
this.formInput.focus();
|
|
||||||
}, 100);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hideForm() {
|
hideForm() {
|
||||||
|
@ -112,6 +117,34 @@ export class PageComments extends Component {
|
||||||
this.addButtonContainer.toggleAttribute('hidden', false);
|
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() {
|
getCommentCount() {
|
||||||
return this.container.querySelectorAll('[component="page-comment"]').length;
|
return this.container.querySelectorAll('[component="page-comment"]').length;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,11 +10,8 @@ export class WysiwygInput extends Component {
|
||||||
language: this.$opts.language,
|
language: this.$opts.language,
|
||||||
containerElement: this.elem,
|
containerElement: this.elem,
|
||||||
darkMode: document.documentElement.classList.contains('dark-mode'),
|
darkMode: document.documentElement.classList.contains('dark-mode'),
|
||||||
textDirection: this.textDirection,
|
textDirection: this.$opts.textDirection,
|
||||||
translations: {
|
translations: {},
|
||||||
imageUploadErrorText: this.$opts.imageUploadErrorText,
|
|
||||||
serverUploadLimitText: this.$opts.serverUploadLimitText,
|
|
||||||
},
|
|
||||||
translationMap: window.editor_translations,
|
translationMap: window.editor_translations,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -339,6 +339,7 @@ export function buildForInput(options) {
|
||||||
toolbar: 'bold italic link bullist numlist',
|
toolbar: 'bold italic link bullist numlist',
|
||||||
content_style: getContentStyle(options),
|
content_style: getContentStyle(options),
|
||||||
file_picker_types: 'file',
|
file_picker_types: 'file',
|
||||||
|
valid_elements: 'p,a[href|title],ol,ul,li,strong,em,br',
|
||||||
file_picker_callback: filePickerCallback,
|
file_picker_callback: filePickerCallback,
|
||||||
init_instance_callback(editor) {
|
init_instance_callback(editor) {
|
||||||
addCustomHeadContent(editor.getDoc());
|
addCustomHeadContent(editor.getDoc());
|
||||||
|
|
|
@ -30,6 +30,13 @@
|
||||||
display: block;
|
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
|
// Default styles for our custom root nodes
|
||||||
.page-content.mce-content-body doc-root {
|
.page-content.mce-content-body doc-root {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
|
@php
|
||||||
|
$commentHtml = $comment->safeHtml();
|
||||||
|
@endphp
|
||||||
<div component="{{ $readOnly ? '' : 'page-comment' }}"
|
<div component="{{ $readOnly ? '' : 'page-comment' }}"
|
||||||
option:page-comment:comment-id="{{ $comment->id }}"
|
option:page-comment:comment-id="{{ $comment->id }}"
|
||||||
option:page-comment:comment-local-id="{{ $comment->local_id }}"
|
option:page-comment:comment-local-id="{{ $comment->local_id }}"
|
||||||
option:page-comment:comment-parent-id="{{ $comment->parent_id }}"
|
option:page-comment:comment-parent-id="{{ $comment->parent_id }}"
|
||||||
option:page-comment:updated-text="{{ trans('entities.comment_updated_success') }}"
|
option:page-comment:updated-text="{{ trans('entities.comment_updated_success') }}"
|
||||||
option:page-comment:deleted-text="{{ trans('entities.comment_deleted_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}}"
|
id="comment{{$comment->local_id}}"
|
||||||
class="comment-box">
|
class="comment-box">
|
||||||
<div class="header">
|
<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>
|
<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>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
{!! $comment->html !!}
|
{!! $commentHtml !!}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(!$readOnly && userCan('comment-update', $comment))
|
@if(!$readOnly && userCan('comment-update', $comment))
|
||||||
<form novalidate refs="page-comment@form" hidden class="content pt-s px-s block">
|
<form novalidate refs="page-comment@form" hidden class="content pt-s px-s block">
|
||||||
<div class="form-group description-input">
|
<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>
|
||||||
<div class="form-group text-right">
|
<div class="form-group text-right">
|
||||||
<button type="button" class="button outline" refs="page-comment@form-cancel">{{ trans('common.cancel') }}</button>
|
<button type="button" class="button outline" refs="page-comment@form-cancel">{{ trans('common.cancel') }}</button>
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
option:page-comments:page-id="{{ $page->id }}"
|
option:page-comments:page-id="{{ $page->id }}"
|
||||||
option:page-comments:created-text="{{ trans('entities.comment_created_success') }}"
|
option:page-comments:created-text="{{ trans('entities.comment_created_success') }}"
|
||||||
option:page-comments:count-text="{{ trans('entities.comment_count') }}"
|
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"
|
class="comments-list"
|
||||||
aria-label="{{ trans('entities.comments') }}">
|
aria-label="{{ trans('entities.comments') }}">
|
||||||
|
|
||||||
|
@ -24,7 +26,6 @@
|
||||||
|
|
||||||
@if(userCan('comment-create-all'))
|
@if(userCan('comment-create-all'))
|
||||||
@include('comments.create')
|
@include('comments.create')
|
||||||
|
|
||||||
@if (!$commentTree->empty())
|
@if (!$commentTree->empty())
|
||||||
<div refs="page-comments@addButtonContainer" class="text-right">
|
<div refs="page-comments@addButtonContainer" class="text-right">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
|
@ -34,4 +35,14 @@
|
||||||
@endif
|
@endif
|
||||||
@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>
|
</section>
|
|
@ -16,7 +16,7 @@
|
||||||
<div class="content px-s pt-s">
|
<div class="content px-s pt-s">
|
||||||
<form refs="page-comments@form" novalidate>
|
<form refs="page-comments@form" novalidate>
|
||||||
<div class="form-group description-input">
|
<div class="form-group description-input">
|
||||||
<textarea refs="page-comments@form-input" name="markdown"
|
<textarea refs="page-comments@form-input" name="html"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="{{ trans('entities.comment_placeholder') }}"></textarea>
|
placeholder="{{ trans('entities.comment_placeholder') }}"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -68,10 +68,13 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@yield('bottom')
|
@yield('bottom')
|
||||||
|
@stack('post-app-html')
|
||||||
|
|
||||||
@if($cspNonce ?? false)
|
@if($cspNonce ?? false)
|
||||||
<script src="{{ versioned_asset('dist/app.js') }}" nonce="{{ $cspNonce }}"></script>
|
<script src="{{ versioned_asset('dist/app.js') }}" nonce="{{ $cspNonce }}"></script>
|
||||||
@endif
|
@endif
|
||||||
@yield('scripts')
|
@yield('scripts')
|
||||||
|
@stack('post-app-scripts')
|
||||||
|
|
||||||
@include('layouts.parts.base-body-end')
|
@include('layouts.parts.base-body-end')
|
||||||
</body>
|
</body>
|
||||||
|
|
|
@ -196,7 +196,7 @@ class WatchTest extends TestCase
|
||||||
$notifications = Notification::fake();
|
$notifications = Notification::fake();
|
||||||
|
|
||||||
$this->asAdmin()->post("/comment/{$entities['page']->id}", [
|
$this->asAdmin()->post("/comment/{$entities['page']->id}", [
|
||||||
'text' => 'My new comment'
|
'html' => '<p>My new comment</p>'
|
||||||
]);
|
]);
|
||||||
$notifications->assertSentTo($editor, CommentCreationNotification::class);
|
$notifications->assertSentTo($editor, CommentCreationNotification::class);
|
||||||
}
|
}
|
||||||
|
@ -217,12 +217,12 @@ class WatchTest extends TestCase
|
||||||
$notifications = Notification::fake();
|
$notifications = Notification::fake();
|
||||||
|
|
||||||
$this->actingAs($editor)->post("/comment/{$entities['page']->id}", [
|
$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();
|
$comment = $entities['page']->comments()->orderBy('id', 'desc')->first();
|
||||||
|
|
||||||
$this->asAdmin()->post("/comment/{$entities['page']->id}", [
|
$this->asAdmin()->post("/comment/{$entities['page']->id}", [
|
||||||
'text' => 'My new comment response',
|
'html' => '<p>My new comment response</p>',
|
||||||
'parent_id' => $comment->local_id,
|
'parent_id' => $comment->local_id,
|
||||||
]);
|
]);
|
||||||
$notifications->assertSentTo($editor, CommentCreationNotification::class);
|
$notifications->assertSentTo($editor, CommentCreationNotification::class);
|
||||||
|
@ -257,7 +257,7 @@ class WatchTest extends TestCase
|
||||||
|
|
||||||
// Comment post
|
// Comment post
|
||||||
$this->actingAs($admin)->post("/comment/{$entities['page']->id}", [
|
$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) {
|
$notifications->assertSentTo($editor, function (CommentCreationNotification $notification) use ($editor, $admin, $entities) {
|
||||||
|
@ -376,7 +376,7 @@ class WatchTest extends TestCase
|
||||||
$this->permissions->disableEntityInheritedPermissions($page);
|
$this->permissions->disableEntityInheritedPermissions($page);
|
||||||
|
|
||||||
$this->asAdmin()->post("/comment/{$page->id}", [
|
$this->asAdmin()->post("/comment/{$page->id}", [
|
||||||
'text' => 'My new comment response',
|
'html' => '<p>My new comment response</p>',
|
||||||
])->assertOk();
|
])->assertOk();
|
||||||
|
|
||||||
$notifications->assertNothingSentTo($editor);
|
$notifications->assertNothingSentTo($editor);
|
||||||
|
|
|
@ -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",
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -18,16 +18,16 @@ class CommentTest extends TestCase
|
||||||
$resp = $this->postJson("/comment/$page->id", $comment->getAttributes());
|
$resp = $this->postJson("/comment/$page->id", $comment->getAttributes());
|
||||||
|
|
||||||
$resp->assertStatus(200);
|
$resp->assertStatus(200);
|
||||||
$resp->assertSee($comment->text);
|
$resp->assertSee($comment->html, false);
|
||||||
|
|
||||||
$pageResp = $this->get($page->getUrl());
|
$pageResp = $this->get($page->getUrl());
|
||||||
$pageResp->assertSee($comment->text);
|
$pageResp->assertSee($comment->html, false);
|
||||||
|
|
||||||
$this->assertDatabaseHas('comments', [
|
$this->assertDatabaseHas('comments', [
|
||||||
'local_id' => 1,
|
'local_id' => 1,
|
||||||
'entity_id' => $page->id,
|
'entity_id' => $page->id,
|
||||||
'entity_type' => Page::newModelInstance()->getMorphClass(),
|
'entity_type' => Page::newModelInstance()->getMorphClass(),
|
||||||
'text' => $comment->text,
|
'text' => null,
|
||||||
'parent_id' => 2,
|
'parent_id' => 2,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@ -43,17 +43,17 @@ class CommentTest extends TestCase
|
||||||
$this->postJson("/comment/$page->id", $comment->getAttributes());
|
$this->postJson("/comment/$page->id", $comment->getAttributes());
|
||||||
|
|
||||||
$comment = $page->comments()->first();
|
$comment = $page->comments()->first();
|
||||||
$newText = 'updated text content';
|
$newHtml = '<p>updated text content</p>';
|
||||||
$resp = $this->putJson("/comment/$comment->id", [
|
$resp = $this->putJson("/comment/$comment->id", [
|
||||||
'text' => $newText,
|
'html' => $newHtml,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$resp->assertStatus(200);
|
$resp->assertStatus(200);
|
||||||
$resp->assertSee($newText);
|
$resp->assertSee($newHtml, false);
|
||||||
$resp->assertDontSee($comment->text);
|
$resp->assertDontSee($comment->html, false);
|
||||||
|
|
||||||
$this->assertDatabaseHas('comments', [
|
$this->assertDatabaseHas('comments', [
|
||||||
'text' => $newText,
|
'html' => $newHtml,
|
||||||
'entity_id' => $page->id,
|
'entity_id' => $page->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@ -80,46 +80,64 @@ class CommentTest extends TestCase
|
||||||
$this->assertActivityExists(ActivityType::COMMENT_DELETE);
|
$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();
|
$page = $this->entities->page();
|
||||||
|
|
||||||
|
$script = '<script>const a = "script";</script><p onclick="1">My lovely comment</p>';
|
||||||
$this->asAdmin()->postJson("/comment/$page->id", [
|
$this->asAdmin()->postJson("/comment/$page->id", [
|
||||||
'text' => '# My Title',
|
'html' => $script,
|
||||||
]);
|
|
||||||
|
|
||||||
$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,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$pageView = $this->get($page->getUrl());
|
$pageView = $this->get($page->getUrl());
|
||||||
$pageView->assertDontSee($script, false);
|
$pageView->assertDontSee($script, false);
|
||||||
$pageView->assertSee('sometextinthecomment');
|
$pageView->assertSee('<p>My lovely comment</p>', false);
|
||||||
|
|
||||||
$comment = $page->comments()->first();
|
$comment = $page->comments()->first();
|
||||||
$this->putJson("/comment/$comment->id", [
|
$this->putJson("/comment/$comment->id", [
|
||||||
'text' => $script . 'updated',
|
'html' => $script . '<p>updated</p>',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$pageView = $this->get($page->getUrl());
|
$pageView = $this->get($page->getUrl());
|
||||||
$pageView->assertDontSee($script, false);
|
$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()
|
public function test_reply_comments_are_nested()
|
||||||
|
@ -127,15 +145,17 @@ class CommentTest extends TestCase
|
||||||
$this->asAdmin();
|
$this->asAdmin();
|
||||||
$page = $this->entities->page();
|
$page = $this->entities->page();
|
||||||
|
|
||||||
$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", ['text' => 'My new comment']);
|
$this->postJson("/comment/$page->id", ['html' => '<p>My new comment</p>']);
|
||||||
|
|
||||||
$respHtml = $this->withHtml($this->get($page->getUrl()));
|
$respHtml = $this->withHtml($this->get($page->getUrl()));
|
||||||
$respHtml->assertElementCount('.comment-branch', 3);
|
$respHtml->assertElementCount('.comment-branch', 3);
|
||||||
$respHtml->assertElementNotExists('.comment-branch .comment-branch');
|
$respHtml->assertElementNotExists('.comment-branch .comment-branch');
|
||||||
|
|
||||||
$comment = $page->comments()->first();
|
$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);
|
$resp->assertStatus(200);
|
||||||
|
|
||||||
$respHtml = $this->withHtml($this->get($page->getUrl()));
|
$respHtml = $this->withHtml($this->get($page->getUrl()));
|
||||||
|
@ -147,7 +167,7 @@ class CommentTest extends TestCase
|
||||||
{
|
{
|
||||||
$page = $this->entities->page();
|
$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 = $this->withHtml($this->get($page->getUrl('/edit')));
|
||||||
$respHtml->assertElementContains('.comment-box .content', 'My great comment to see in the editor');
|
$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 = $this->asAdmin()->get($page->getUrl());
|
||||||
$pageResp->assertSee('Wolfeschlegels…');
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -738,16 +738,12 @@ class RolePermissionsTest extends TestCase
|
||||||
|
|
||||||
private function addComment(Page $page): TestResponse
|
private function addComment(Page $page): TestResponse
|
||||||
{
|
{
|
||||||
$comment = Comment::factory()->make();
|
return $this->postJson("/comment/$page->id", ['html' => '<p>New comment content</p>']);
|
||||||
|
|
||||||
return $this->postJson("/comment/$page->id", $comment->only('text', 'html'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function updateComment(Comment $comment): TestResponse
|
private function updateComment(Comment $comment): TestResponse
|
||||||
{
|
{
|
||||||
$commentData = Comment::factory()->make();
|
return $this->putJson("/comment/{$comment->id}", ['html' => '<p>Updated comment content</p>']);
|
||||||
|
|
||||||
return $this->putJson("/comment/{$comment->id}", $commentData->only('text', 'html'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function deleteComment(Comment $comment): TestResponse
|
private function deleteComment(Comment $comment): TestResponse
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue