use BookStack\Ownable;
+/**
+ * @property string text
+ * @property string html
+ * @property int|null parent_id
+ * @property int local_id
+ */
class Comment extends Ownable
{
- protected $fillable = ['text', 'html', 'parent_id'];
+ protected $fillable = ['text', 'parent_id'];
protected $appends = ['created', 'updated'];
/**
<?php namespace BookStack\Actions;
use BookStack\Entities\Entity;
+use League\CommonMark\CommonMarkConverter;
/**
* Class CommentRepo
- * @package BookStack\Repos
*/
class CommentRepo
{
/**
- * @var \BookStack\Actions\Comment $comment
+ * @var Comment $comment
*/
protected $comment;
- /**
- * CommentRepo constructor.
- * @param \BookStack\Actions\Comment $comment
- */
+
public function __construct(Comment $comment)
{
$this->comment = $comment;
/**
* Get a comment by ID.
- * @param $id
- * @return \BookStack\Actions\Comment|\Illuminate\Database\Eloquent\Model
*/
- public function getById($id)
+ public function getById(int $id): Comment
{
return $this->comment->newQuery()->findOrFail($id);
}
/**
* Create a new comment on an entity.
- * @param \BookStack\Entities\Entity $entity
- * @param array $data
- * @return \BookStack\Actions\Comment
*/
- public function create(Entity $entity, $data = [])
+ public function create(Entity $entity, string $text, ?int $parent_id): Comment
{
$userId = user()->id;
- $comment = $this->comment->newInstance($data);
+ $comment = $this->comment->newInstance();
+
+ $comment->text = $text;
+ $comment->html = $this->commentToHtml($text);
$comment->created_by = $userId;
$comment->updated_by = $userId;
$comment->local_id = $this->getNextLocalId($entity);
+ $comment->parent_id = $parent_id;
+
$entity->comments()->save($comment);
return $comment;
}
/**
* Update an existing comment.
- * @param \BookStack\Actions\Comment $comment
- * @param array $input
- * @return mixed
*/
- public function update($comment, $input)
+ public function update(Comment $comment, string $text): Comment
{
$comment->updated_by = user()->id;
- $comment->update($input);
+ $comment->text = $text;
+ $comment->html = $this->commentToHtml($text);
+ $comment->save();
return $comment;
}
/**
* Delete a comment from the system.
- * @param \BookStack\Actions\Comment $comment
- * @return mixed
*/
- public function delete($comment)
+ public function delete(Comment $comment)
{
- return $comment->delete();
+ $comment->delete();
+ }
+
+ /**
+ * Convert the given comment markdown text to HTML.
+ */
+ protected function commentToHtml(string $commentText): string
+ {
+ $converter = new CommonMarkConverter([
+ 'html_input' => 'strip',
+ 'max_nesting_level' => 10,
+ 'allow_unsafe_links' => false,
+ ]);
+
+ return $converter->convertToHtml($commentText);
}
/**
* Get the next local ID relative to the linked entity.
- * @param \BookStack\Entities\Entity $entity
- * @return int
*/
- protected function getNextLocalId(Entity $entity)
+ protected function getNextLocalId(Entity $entity): int
{
$comments = $entity->comments(false)->orderBy('local_id', 'desc')->first();
- if ($comments === null) {
- return 1;
- }
- return $comments->local_id + 1;
+ return ($comments->local_id ?? 0) + 1;
}
}
{
protected $commentRepo;
- /**
- * CommentController constructor.
- */
public function __construct(CommentRepo $commentRepo)
{
$this->commentRepo = $commentRepo;
* Save a new comment for a Page
* @throws ValidationException
*/
- public function savePageComment(Request $request, int $pageId, int $commentId = null)
+ public function savePageComment(Request $request, int $pageId)
{
$this->validate($request, [
'text' => 'required|string',
- 'html' => 'required|string',
+ 'parent_id' => 'nullable|integer',
]);
$page = Page::visible()->find($pageId);
return response('Not found', 404);
}
- $this->checkOwnablePermission('page-view', $page);
-
// Prevent adding comments to draft pages
if ($page->draft) {
return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400);
// Create a new comment.
$this->checkPermission('comment-create-all');
- $comment = $this->commentRepo->create($page, $request->only(['html', 'text', 'parent_id']));
+ $comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id'));
Activity::add($page, 'commented_on', $page->book->id);
return view('comments.comment', ['comment' => $comment]);
}
{
$this->validate($request, [
'text' => 'required|string',
- '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->only(['html', 'text']));
+ $comment = $this->commentRepo->update($comment, $request->get('text'));
return view('comments.comment', ['comment' => $comment]);
}
use BookStack\Auth\User;
+/**
+ * @property int created_by
+ * @property int updated_by
+ */
abstract class Ownable extends Model
{
/**
"barryvdh/laravel-dompdf": "^0.8.5",
"barryvdh/laravel-snappy": "^0.4.5",
"doctrine/dbal": "^2.9",
+ "facade/ignition": "^1.4",
"fideloper/proxy": "^4.0",
"gathercontent/htmldiff": "^0.2.1",
"intervention/image": "^2.5",
"laravel/framework": "^6.12",
"laravel/socialite": "^4.3.2",
+ "league/commonmark": "^1.4",
"league/flysystem-aws-s3-v3": "^1.0",
+ "nunomaduro/collision": "^3.0",
"onelogin/php-saml": "^3.3",
"predis/predis": "^1.1",
"socialiteproviders/discord": "^2.0",
"socialiteproviders/microsoft-azure": "^3.0",
"socialiteproviders/okta": "^1.0",
"socialiteproviders/slack": "^3.0",
- "socialiteproviders/twitch": "^5.0",
- "facade/ignition": "^1.4",
- "nunomaduro/collision": "^3.0"
+ "socialiteproviders/twitch": "^5.0"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^3.2.8",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "7836017e48e93254d6ff924b07f84597",
+ "content-hash": "bbe47cff4f167fd6ce7047dff4602a78",
"packages": [
{
"name": "aws/aws-sdk-php",
},
{
"name": "league/commonmark",
- "version": "1.3.2",
+ "version": "1.4.2",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
- "reference": "75542a366ccbe1896ed79fcf3e8e68206d6c4257"
+ "reference": "9e780d972185e4f737a03bade0fd34a9e67bbf31"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/75542a366ccbe1896ed79fcf3e8e68206d6c4257",
- "reference": "75542a366ccbe1896ed79fcf3e8e68206d6c4257",
+ "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9e780d972185e4f737a03bade0fd34a9e67bbf31",
+ "reference": "9e780d972185e4f737a03bade0fd34a9e67bbf31",
"shasum": ""
},
"require": {
"github/gfm": "0.29.0",
"michelf/php-markdown": "~1.4",
"mikehaertl/php-shellcommand": "^1.4",
- "phpstan/phpstan-shim": "^0.11.5",
+ "phpstan/phpstan": "^0.12",
"phpunit/phpunit": "^7.5",
"scrutinizer/ocular": "^1.5",
"symfony/finder": "^4.2"
"md",
"parser"
],
- "time": "2020-03-25T19:55:28+00:00"
+ "funding": [
+ {
+ "url": "https://enjoy.gitstore.app/repositories/thephpleague/commonmark",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.colinodell.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.paypal.me/colinpodell/10.00",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/colinodell",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/colinodell",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/league/commonmark",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2020-04-24T13:39:56+00:00"
},
{
"name": "league/flysystem",
"platform-dev": [],
"platform-overrides": {
"php": "7.2.0"
- }
+ },
+ "plugin-api-version": "1.1.0"
}
-import MarkdownIt from "markdown-it";
import {scrollAndHighlightElement} from "../services/util";
-const md = new MarkdownIt({ html: false });
-
class PageComments {
constructor(elem) {
let text = form.querySelector('textarea').value;
let reqData = {
text: text,
- html: md.render(text),
parent_id: this.parentId || null,
};
this.showLoading(form);
let commentId = this.editingComment.getAttribute('comment');
- window.$http.put(window.baseUrl(`/ajax/comment/${commentId}`), reqData).then(resp => {
+ window.$http.put(`/ajax/comment/${commentId}`, reqData).then(resp => {
let newComment = document.createElement('div');
newComment.innerHTML = resp.data;
this.editingComment.innerHTML = newComment.children[0].innerHTML;
deleteComment(commentElem) {
let id = commentElem.getAttribute('comment');
this.showLoading(commentElem.querySelector('[comment-content]'));
- window.$http.delete(window.baseUrl(`/ajax/comment/${id}`)).then(resp => {
+ window.$http.delete(`/ajax/comment/${id}`).then(resp => {
commentElem.parentNode.removeChild(commentElem);
window.$events.emit('success', window.trans('entities.comment_deleted_success'));
this.updateCount();
let text = this.formInput.value;
let reqData = {
text: text,
- html: md.render(text),
parent_id: this.parentId || null,
};
this.showLoading(this.form);
- window.$http.post(window.baseUrl(`/ajax/page/${this.pageId}/comment`), reqData).then(resp => {
+ window.$http.post(`/ajax/page/${this.pageId}/comment`, reqData).then(resp => {
let newComment = document.createElement('div');
newComment.innerHTML = resp.data;
let newElem = newComment.children[0];
}
showLoading(formElem) {
- let groups = formElem.querySelectorAll('.form-group');
- for (let i = 0, len = groups.length; i < len; i++) {
- groups[i].style.display = 'none';
+ const groups = formElem.querySelectorAll('.form-group');
+ for (let group of groups) {
+ group.style.display = 'none';
}
formElem.querySelector('.form-group.loading').style.display = 'block';
}
hideLoading(formElem) {
- let groups = formElem.querySelectorAll('.form-group');
- for (let i = 0, len = groups.length; i < len; i++) {
- groups[i].style.display = 'block';
+ const groups = formElem.querySelectorAll('.form-group');
+ for (let group of groups) {
+ group.style.display = 'block';
}
formElem.querySelector('.form-group.loading').style.display = 'none';
}
$newText = 'updated text content';
$resp = $this->putJson("/ajax/comment/$comment->id", [
'text' => $newText,
- 'html' => '<p>'.$newText.'</p>',
]);
$resp->assertStatus(200);
'id' => $comment->id
]);
}
+
+ public function test_comments_converts_markdown_input_to_html()
+ {
+ $page = Page::first();
+ $this->asAdmin()->postJson("/ajax/page/$page->id/comment", [
+ '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>');
+ }
+
+ public function test_html_cannot_be_injected_via_comment_content()
+ {
+ $this->asAdmin();
+ $page = Page::first();
+
+ $script = '<script>const a = "script";</script>\n\n# sometextinthecomment';
+ $this->postJson("/ajax/page/$page->id/comment", [
+ 'text' => $script,
+ ]);
+
+ $pageView = $this->get($page->getUrl());
+ $pageView->assertDontSee($script);
+ $pageView->assertSee('sometextinthecomment');
+
+ $comment = $page->comments()->first();
+ $this->putJson("/ajax/comment/$comment->id", [
+ 'text' => $script . 'updated',
+ ]);
+
+ $pageView = $this->get($page->getUrl());
+ $pageView->assertDontSee($script);
+ $pageView->assertSee('sometextinthecommentupdated');
+ }
}