diff --git a/app/Activity/CommentRepo.php b/app/Activity/CommentRepo.php index 1c2333cae..ba12f4d33 100644 --- a/app/Activity/CommentRepo.php +++ b/app/Activity/CommentRepo.php @@ -4,6 +4,7 @@ namespace BookStack\Activity; use BookStack\Activity\Models\Comment; use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\Page; use BookStack\Exceptions\NotifyException; use BookStack\Facades\Activity as ActivityService; use BookStack\Util\HtmlDescriptionFilter; @@ -19,6 +20,15 @@ class CommentRepo return Comment::query()->findOrFail($id); } + /** + * Get a comment by ID, ensuring it is visible to the user based upon access to the page + * which the comment is attached to. + */ + public function getVisibleById(int $id): Comment + { + return $this->getQueryForVisible()->findOrFail($id); + } + /** * Start a query for comments visible to the user. */ @@ -32,6 +42,23 @@ class CommentRepo */ public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment { + // Prevent comments being added to draft pages + if ($entity instanceof Page && $entity->draft) { + throw new \Exception(trans('errors.cannot_add_comment_to_draft')); + } + + // Validate parent ID + if ($parentId !== null) { + $parentCommentExists = Comment::query() + ->where('entity_id', '=', $entity->id) + ->where('entity_type', '=', $entity->getMorphClass()) + ->where('local_id', '=', $parentId) + ->exists(); + if (!$parentCommentExists) { + $parentId = null; + } + } + $userId = user()->id; $comment = new Comment(); @@ -67,7 +94,7 @@ class CommentRepo /** * Archive an existing comment. */ - public function archive(Comment $comment): Comment + public function archive(Comment $comment, bool $log = true): Comment { if ($comment->parent_id) { throw new NotifyException('Only top-level comments can be archived.', '/', 400); @@ -76,7 +103,9 @@ class CommentRepo $comment->archived = true; $comment->save(); - ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); + if ($log) { + ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); + } return $comment; } @@ -84,7 +113,7 @@ class CommentRepo /** * Un-archive an existing comment. */ - public function unarchive(Comment $comment): Comment + public function unarchive(Comment $comment, bool $log = true): Comment { if ($comment->parent_id) { throw new NotifyException('Only top-level comments can be un-archived.', '/', 400); @@ -93,7 +122,9 @@ class CommentRepo $comment->archived = false; $comment->save(); - ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); + if ($log) { + ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); + } return $comment; } diff --git a/app/Activity/Controllers/CommentApiController.php b/app/Activity/Controllers/CommentApiController.php index 3a4c33cd6..7ba9b5b64 100644 --- a/app/Activity/Controllers/CommentApiController.php +++ b/app/Activity/Controllers/CommentApiController.php @@ -6,8 +6,12 @@ namespace BookStack\Activity\Controllers; use BookStack\Activity\CommentRepo; use BookStack\Activity\Models\Comment; +use BookStack\Entities\Queries\PageQueries; use BookStack\Http\ApiController; +use BookStack\Permissions\Permission; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; +use Illuminate\Http\Response; /** * The comment data model has a 'local_id' property, which is a unique integer ID @@ -18,15 +22,26 @@ use Illuminate\Http\JsonResponse; class CommentApiController extends ApiController { // TODO - Add tree-style comment listing to page-show responses. - // TODO - create - // TODO - update - // TODO - delete // TODO - Test visibility controls // TODO - Test permissions of each action + protected array $rules = [ + 'create' => [ + 'page_id' => ['required', 'integer'], + 'reply_to' => ['nullable', 'integer'], + 'html' => ['required', 'string'], + 'content_ref' => ['string'], + ], + 'update' => [ + 'html' => ['required', 'string'], + 'archived' => ['boolean'], + ] + ]; + public function __construct( protected CommentRepo $commentRepo, + protected PageQueries $pageQueries, ) { } @@ -42,13 +57,34 @@ class CommentApiController extends ApiController ]); } + /** + * Create a new comment on a page. + * If commenting as a reply to an existing comment, the 'reply_to' parameter + * should be provided, set to the 'local_id' of the comment being replied to. + */ + public function create(Request $request): JsonResponse + { + $this->checkPermission(Permission::CommentCreateAll); + + $input = $this->validate($request, $this->rules()['create']); + $page = $this->pageQueries->findVisibleByIdOrFail($input['page_id']); + + $comment = $this->commentRepo->create( + $page, + $input['html'], + $input['reply_to'] ?? null, + $input['content_ref'] ?? '', + ); + + return response()->json($comment); + } + /** * Read the details of a single comment, along with its direct replies. */ public function read(string $id): JsonResponse { - $comment = $this->commentRepo->getQueryForVisible() - ->where('id', '=', $id)->firstOrFail(); + $comment = $this->commentRepo->getVisibleById(intval($id)); $replies = $this->commentRepo->getQueryForVisible() ->where('parent_id', '=', $comment->local_id) @@ -67,4 +103,45 @@ class CommentApiController extends ApiController return response()->json($comment); } + + + /** + * Update the content or archived status of an existing comment. + * + * Only provide a new archived status if needing to actively change the archive state. + * Only top-level comments (non-replies) can be archived or unarchived. + */ + public function update(Request $request, string $id): JsonResponse + { + $comment = $this->commentRepo->getVisibleById(intval($id)); + $this->checkOwnablePermission(Permission::CommentUpdate, $comment); + + $input = $this->validate($request, $this->rules()['update']); + + if (isset($input['archived'])) { + $archived = $input['archived']; + if ($archived) { + $this->commentRepo->archive($comment, false); + } else { + $this->commentRepo->unarchive($comment, false); + } + } + + $comment = $this->commentRepo->update($comment, $input['html']); + + return response()->json($comment); + } + + /** + * Delete a single comment from the system. + */ + public function delete(string $id): Response + { + $comment = $this->commentRepo->getVisibleById(intval($id)); + $this->checkOwnablePermission(Permission::CommentDelete, $comment); + + $this->commentRepo->delete($comment); + + return response('', 204); + } } diff --git a/app/Activity/Controllers/CommentController.php b/app/Activity/Controllers/CommentController.php index fd5463dff..f61a2c8df 100644 --- a/app/Activity/Controllers/CommentController.php +++ b/app/Activity/Controllers/CommentController.php @@ -22,7 +22,7 @@ class CommentController extends Controller /** * Save a new comment for a Page. * - * @throws ValidationException + * @throws ValidationException|\Exception */ public function savePageComment(Request $request, int $pageId) { @@ -37,11 +37,6 @@ class CommentController extends Controller return response('Not found', 404); } - // 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(Permission::CommentCreateAll); $contentRef = $input['content_ref'] ?? ''; diff --git a/app/Http/ApiController.php b/app/Http/ApiController.php index 1a92afa33..ac8844b81 100644 --- a/app/Http/ApiController.php +++ b/app/Http/ApiController.php @@ -8,6 +8,12 @@ use Illuminate\Http\JsonResponse; abstract class ApiController extends Controller { + /** + * The validation rules for this controller. + * Can alternative be defined in a rules() method is they need to be dynamic. + * + * @var array + */ protected array $rules = []; /** diff --git a/app/Permissions/Permission.php b/app/Permissions/Permission.php index a434e54fd..04878ada0 100644 --- a/app/Permissions/Permission.php +++ b/app/Permissions/Permission.php @@ -48,9 +48,7 @@ enum Permission: string case AttachmentUpdateAll = 'attachment-update-all'; case AttachmentUpdateOwn = 'attachment-update-own'; - case CommentCreate = 'comment-create'; case CommentCreateAll = 'comment-create-all'; - case CommentCreateOwn = 'comment-create-own'; case CommentDelete = 'comment-delete'; case CommentDeleteAll = 'comment-delete-all'; case CommentDeleteOwn = 'comment-delete-own'; diff --git a/tests/Activity/CommentsApiTest.php b/tests/Activity/CommentsApiTest.php new file mode 100644 index 000000000..29769a260 --- /dev/null +++ b/tests/Activity/CommentsApiTest.php @@ -0,0 +1,43 @@ +