Skip to content

Commit c21c36e

Browse files
authored
Merge pull request #5850 from BookStackApp/comments_api
API: Started building comments API endpoints
2 parents abe9c1e + a949900 commit c21c36e

30 files changed

+813
-55
lines changed

app/Activity/CommentRepo.php

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44

55
use BookStack\Activity\Models\Comment;
66
use BookStack\Entities\Models\Entity;
7+
use BookStack\Entities\Models\Page;
78
use BookStack\Exceptions\NotifyException;
8-
use BookStack\Exceptions\PrettyException;
99
use BookStack\Facades\Activity as ActivityService;
1010
use BookStack\Util\HtmlDescriptionFilter;
11+
use Illuminate\Database\Eloquent\Builder;
1112

1213
class CommentRepo
1314
{
@@ -19,11 +20,46 @@ public function getById(int $id): Comment
1920
return Comment::query()->findOrFail($id);
2021
}
2122

23+
/**
24+
* Get a comment by ID, ensuring it is visible to the user based upon access to the page
25+
* which the comment is attached to.
26+
*/
27+
public function getVisibleById(int $id): Comment
28+
{
29+
return $this->getQueryForVisible()->findOrFail($id);
30+
}
31+
32+
/**
33+
* Start a query for comments visible to the user.
34+
* @return Builder<Comment>
35+
*/
36+
public function getQueryForVisible(): Builder
37+
{
38+
return Comment::query()->scopes('visible');
39+
}
40+
2241
/**
2342
* Create a new comment on an entity.
2443
*/
2544
public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment
2645
{
46+
// Prevent comments being added to draft pages
47+
if ($entity instanceof Page && $entity->draft) {
48+
throw new \Exception(trans('errors.cannot_add_comment_to_draft'));
49+
}
50+
51+
// Validate parent ID
52+
if ($parentId !== null) {
53+
$parentCommentExists = Comment::query()
54+
->where('commentable_id', '=', $entity->id)
55+
->where('commentable_type', '=', $entity->getMorphClass())
56+
->where('local_id', '=', $parentId)
57+
->exists();
58+
if (!$parentCommentExists) {
59+
$parentId = null;
60+
}
61+
}
62+
2763
$userId = user()->id;
2864
$comment = new Comment();
2965

@@ -38,6 +74,7 @@ public function create(Entity $entity, string $html, ?int $parentId, string $con
3874
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
3975
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
4076

77+
$comment->refresh()->unsetRelations();
4178
return $comment;
4279
}
4380

@@ -59,7 +96,7 @@ public function update(Comment $comment, string $html): Comment
5996
/**
6097
* Archive an existing comment.
6198
*/
62-
public function archive(Comment $comment): Comment
99+
public function archive(Comment $comment, bool $log = true): Comment
63100
{
64101
if ($comment->parent_id) {
65102
throw new NotifyException('Only top-level comments can be archived.', '/', 400);
@@ -68,15 +105,17 @@ public function archive(Comment $comment): Comment
68105
$comment->archived = true;
69106
$comment->save();
70107

71-
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
108+
if ($log) {
109+
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
110+
}
72111

73112
return $comment;
74113
}
75114

76115
/**
77116
* Un-archive an existing comment.
78117
*/
79-
public function unarchive(Comment $comment): Comment
118+
public function unarchive(Comment $comment, bool $log = true): Comment
80119
{
81120
if ($comment->parent_id) {
82121
throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
@@ -85,7 +124,9 @@ public function unarchive(Comment $comment): Comment
85124
$comment->archived = false;
86125
$comment->save();
87126

88-
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
127+
if ($log) {
128+
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
129+
}
89130

90131
return $comment;
91132
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BookStack\Activity\Controllers;
6+
7+
use BookStack\Activity\CommentRepo;
8+
use BookStack\Activity\Models\Comment;
9+
use BookStack\Entities\Queries\PageQueries;
10+
use BookStack\Http\ApiController;
11+
use BookStack\Permissions\Permission;
12+
use Illuminate\Http\JsonResponse;
13+
use Illuminate\Http\Request;
14+
use Illuminate\Http\Response;
15+
16+
/**
17+
* The comment data model has a 'local_id' property, which is a unique integer ID
18+
* scoped to the page which the comment is on. The 'parent_id' is used for replies
19+
* and refers to the 'local_id' of the parent comment on the same page, not the main
20+
* globally unique 'id'.
21+
*
22+
* If you want to get all comments for a page in a tree-like structure, as reflected in
23+
* the UI, then that is provided on pages-read API responses.
24+
*/
25+
class CommentApiController extends ApiController
26+
{
27+
protected array $rules = [
28+
'create' => [
29+
'page_id' => ['required', 'integer'],
30+
'reply_to' => ['nullable', 'integer'],
31+
'html' => ['required', 'string'],
32+
'content_ref' => ['string'],
33+
],
34+
'update' => [
35+
'html' => ['string'],
36+
'archived' => ['boolean'],
37+
]
38+
];
39+
40+
public function __construct(
41+
protected CommentRepo $commentRepo,
42+
protected PageQueries $pageQueries,
43+
) {
44+
}
45+
46+
/**
47+
* Get a listing of comments visible to the user.
48+
*/
49+
public function list(): JsonResponse
50+
{
51+
$query = $this->commentRepo->getQueryForVisible();
52+
53+
return $this->apiListingResponse($query, [
54+
'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at'
55+
]);
56+
}
57+
58+
/**
59+
* Create a new comment on a page.
60+
* If commenting as a reply to an existing comment, the 'reply_to' parameter
61+
* should be provided, set to the 'local_id' of the comment being replied to.
62+
*/
63+
public function create(Request $request): JsonResponse
64+
{
65+
$this->checkPermission(Permission::CommentCreateAll);
66+
67+
$input = $this->validate($request, $this->rules()['create']);
68+
$page = $this->pageQueries->findVisibleByIdOrFail($input['page_id']);
69+
70+
$comment = $this->commentRepo->create(
71+
$page,
72+
$input['html'],
73+
$input['reply_to'] ?? null,
74+
$input['content_ref'] ?? '',
75+
);
76+
77+
return response()->json($comment);
78+
}
79+
80+
/**
81+
* Read the details of a single comment, along with its direct replies.
82+
*/
83+
public function read(string $id): JsonResponse
84+
{
85+
$comment = $this->commentRepo->getVisibleById(intval($id));
86+
$comment->load('createdBy', 'updatedBy');
87+
88+
$replies = $this->commentRepo->getQueryForVisible()
89+
->where('parent_id', '=', $comment->local_id)
90+
->where('commentable_id', '=', $comment->commentable_id)
91+
->where('commentable_type', '=', $comment->commentable_type)
92+
->get();
93+
94+
/** @var Comment[] $toProcess */
95+
$toProcess = [$comment, ...$replies];
96+
foreach ($toProcess as $commentToProcess) {
97+
$commentToProcess->setAttribute('html', $commentToProcess->safeHtml());
98+
$commentToProcess->makeVisible('html');
99+
}
100+
101+
$comment->setRelation('replies', $replies);
102+
103+
return response()->json($comment);
104+
}
105+
106+
107+
/**
108+
* Update the content or archived status of an existing comment.
109+
*
110+
* Only provide a new archived status if needing to actively change the archive state.
111+
* Only top-level comments (non-replies) can be archived or unarchived.
112+
*/
113+
public function update(Request $request, string $id): JsonResponse
114+
{
115+
$comment = $this->commentRepo->getVisibleById(intval($id));
116+
$this->checkOwnablePermission(Permission::CommentUpdate, $comment);
117+
118+
$input = $this->validate($request, $this->rules()['update']);
119+
$hasHtml = isset($input['html']);
120+
121+
if (isset($input['archived'])) {
122+
if ($input['archived']) {
123+
$this->commentRepo->archive($comment, !$hasHtml);
124+
} else {
125+
$this->commentRepo->unarchive($comment, !$hasHtml);
126+
}
127+
}
128+
129+
if ($hasHtml) {
130+
$comment = $this->commentRepo->update($comment, $input['html']);
131+
}
132+
133+
return response()->json($comment);
134+
}
135+
136+
/**
137+
* Delete a single comment from the system.
138+
*/
139+
public function delete(string $id): Response
140+
{
141+
$comment = $this->commentRepo->getVisibleById(intval($id));
142+
$this->checkOwnablePermission(Permission::CommentDelete, $comment);
143+
144+
$this->commentRepo->delete($comment);
145+
146+
return response('', 204);
147+
}
148+
}

app/Activity/Controllers/CommentController.php

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public function __construct(
2222
/**
2323
* Save a new comment for a Page.
2424
*
25-
* @throws ValidationException
25+
* @throws ValidationException|\Exception
2626
*/
2727
public function savePageComment(Request $request, int $pageId)
2828
{
@@ -37,11 +37,6 @@ public function savePageComment(Request $request, int $pageId)
3737
return response('Not found', 404);
3838
}
3939

40-
// Prevent adding comments to draft pages
41-
if ($page->draft) {
42-
return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400);
43-
}
44-
4540
// Create a new comment.
4641
$this->checkPermission(Permission::CommentCreateAll);
4742
$contentRef = $input['content_ref'] ?? '';

app/Activity/Models/Comment.php

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,24 @@
33
namespace BookStack\Activity\Models;
44

55
use BookStack\App\Model;
6+
use BookStack\Permissions\Models\JointPermission;
7+
use BookStack\Permissions\PermissionApplicator;
68
use BookStack\Users\Models\HasCreatorAndUpdater;
79
use BookStack\Users\Models\OwnableInterface;
8-
use BookStack\Users\Models\User;
910
use BookStack\Util\HtmlContentFilter;
11+
use Illuminate\Database\Eloquent\Builder;
1012
use Illuminate\Database\Eloquent\Factories\HasFactory;
1113
use Illuminate\Database\Eloquent\Relations\BelongsTo;
14+
use Illuminate\Database\Eloquent\Relations\HasMany;
1215
use Illuminate\Database\Eloquent\Relations\MorphTo;
1316

1417
/**
1518
* @property int $id
16-
* @property string $text - Deprecated & now unused (#4821)
1719
* @property string $html
1820
* @property int|null $parent_id - Relates to local_id, not id
1921
* @property int $local_id
20-
* @property string $entity_type
21-
* @property int $entity_id
22+
* @property string $commentable_type
23+
* @property int $commentable_id
2224
* @property string $content_ref
2325
* @property bool $archived
2426
*/
@@ -28,13 +30,18 @@ class Comment extends Model implements Loggable, OwnableInterface
2830
use HasCreatorAndUpdater;
2931

3032
protected $fillable = ['parent_id'];
33+
protected $hidden = ['html'];
34+
35+
protected $casts = [
36+
'archived' => 'boolean',
37+
];
3138

3239
/**
3340
* Get the entity that this comment belongs to.
3441
*/
3542
public function entity(): MorphTo
3643
{
37-
return $this->morphTo('entity');
44+
return $this->morphTo('commentable');
3845
}
3946

4047
/**
@@ -44,8 +51,8 @@ public function entity(): MorphTo
4451
public function parent(): BelongsTo
4552
{
4653
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
47-
->where('entity_type', '=', $this->entity_type)
48-
->where('entity_id', '=', $this->entity_id);
54+
->where('commentable_type', '=', $this->commentable_type)
55+
->where('commentable_id', '=', $this->commentable_id);
4956
}
5057

5158
/**
@@ -58,11 +65,27 @@ public function isUpdated(): bool
5865

5966
public function logDescriptor(): string
6067
{
61-
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
68+
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->commentable_type} (ID: {$this->commentable_id})";
6269
}
6370

6471
public function safeHtml(): string
6572
{
6673
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
6774
}
75+
76+
public function jointPermissions(): HasMany
77+
{
78+
return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id')
79+
->whereColumn('joint_permissions.entity_type', '=', 'comments.commentable_type');
80+
}
81+
82+
/**
83+
* Scope the query to just the comments visible to the user based upon the
84+
* user visibility of what has been commented on.
85+
*/
86+
public function scopeVisible(Builder $query): Builder
87+
{
88+
return app()->make(PermissionApplicator::class)
89+
->restrictEntityRelationQuery($query, 'comments', 'commentable_id', 'commentable_type');
90+
}
6891
}

0 commit comments

Comments
 (0)