]> BookStack Code Mirror - bookstack/commitdiff
Updated comment md rendering to be server-side
authorDan Brown <redacted>
Fri, 1 May 2020 22:24:11 +0000 (23:24 +0100)
committerDan Brown <redacted>
Fri, 1 May 2020 22:24:11 +0000 (23:24 +0100)
app/Actions/Comment.php
app/Actions/CommentRepo.php
app/Http/Controllers/CommentController.php
app/Ownable.php
composer.json
composer.lock
resources/js/components/page-comments.js
tests/Entity/CommentTest.php

index f138ee4a1ffed5da7bb1ead3a581d9045901179f..655d452219b8200da95cab132b605d0fc4ea1543 100644 (file)
@@ -2,9 +2,15 @@
 
 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'];
 
     /**
index 5ff639e2eb7275aefb281ef7b6e5c029a4e4ae62..0d8a12ce9ed60f6b911458b944e9c95fcd6e21ac 100644 (file)
@@ -1,23 +1,20 @@
 <?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;
@@ -25,65 +22,71 @@ class CommentRepo
 
     /**
      * 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;
     }
 }
index 068358d72d10af92de45327e0b207093c9c501fd..4eb56a4b0cd0720cfcc110e5c22662b958231f4b 100644 (file)
@@ -10,9 +10,6 @@ class CommentController extends Controller
 {
     protected $commentRepo;
 
-    /**
-     * CommentController constructor.
-     */
     public function __construct(CommentRepo $commentRepo)
     {
         $this->commentRepo = $commentRepo;
@@ -23,11 +20,11 @@ class CommentController extends Controller
      * 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);
@@ -35,8 +32,6 @@ class CommentController extends Controller
             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);
@@ -44,7 +39,7 @@ class CommentController extends Controller
 
         // 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]);
     }
@@ -57,14 +52,13 @@ class CommentController extends Controller
     {
         $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]);
     }
 
index e660a0500de165cbc7233bacf3bf9e3b65f9debf..bf24fad5d8676c5198783aa4b547a19d76ca13bd 100644 (file)
@@ -2,6 +2,10 @@
 
 use BookStack\Auth\User;
 
+/**
+ * @property int created_by
+ * @property int updated_by
+ */
 abstract class Ownable extends Model
 {
     /**
index 6da651898e3dc56e74f49221c0626f307d47ccea..80e8b0a619036d9a460452fb1fd62cadb0e99a45 100644 (file)
         "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",
@@ -29,9 +32,7 @@
         "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",
index fd444ffa83ee07f2ac82bd421ef6c48a243d89a5..3ddd28e5a857542eb14c3cf77ad19e07db10eb85 100644 (file)
@@ -4,7 +4,7 @@
         "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"
 }
index 5d8d169589804a5c92fd355281353bdcdaf6ed88..5d826cba13d1bb12c0e95e4ba2020db66d487985 100644 (file)
@@ -1,8 +1,5 @@
-import MarkdownIt from "markdown-it";
 import {scrollAndHighlightElement} from "../services/util";
 
-const md = new MarkdownIt({ html: false });
-
 class PageComments {
 
     constructor(elem) {
@@ -68,12 +65,11 @@ class PageComments {
         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;
@@ -88,7 +84,7 @@ class PageComments {
     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();
@@ -102,11 +98,10 @@ class PageComments {
         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];
@@ -171,17 +166,17 @@ class PageComments {
     }
 
     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';
     }
index a2126407b43d121b10f6c55cad07eda7d0455b25..2562f7e7de9e7aabfe5add97647b1dcee117064e 100644 (file)
@@ -42,7 +42,6 @@ class CommentTest extends TestCase
         $newText = 'updated text content';
         $resp = $this->putJson("/ajax/comment/$comment->id", [
             'text' => $newText,
-            'html' => '<p>'.$newText.'</p>',
         ]);
 
         $resp->assertStatus(200);
@@ -72,4 +71,46 @@ class CommentTest extends TestCase
             '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');
+    }
 }
Morty Proxy This is a proxified and sanitized view of the page, visit original site.