1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-11-06 00:50:36 +03:00

API: Added comment CUD endpoints, drafted tests

Move some checks and made some tweaks to the repo to support consistency
between API and UI.
This commit is contained in:
Dan Brown
2025-10-23 10:21:33 +01:00
parent 3ad1e31fcc
commit cbf27d70c8
7 changed files with 167 additions and 18 deletions

View File

@@ -4,6 +4,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\Entities\Models\Page;
use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\NotifyException;
use BookStack\Facades\Activity as ActivityService; use BookStack\Facades\Activity as ActivityService;
use BookStack\Util\HtmlDescriptionFilter; use BookStack\Util\HtmlDescriptionFilter;
@@ -19,6 +20,15 @@ class CommentRepo
return Comment::query()->findOrFail($id); 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. * 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 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; $userId = user()->id;
$comment = new Comment(); $comment = new Comment();
@@ -67,7 +94,7 @@ class CommentRepo
/** /**
* Archive an existing comment. * Archive an existing comment.
*/ */
public function archive(Comment $comment): Comment public function archive(Comment $comment, bool $log = true): Comment
{ {
if ($comment->parent_id) { if ($comment->parent_id) {
throw new NotifyException('Only top-level comments can be archived.', '/', 400); throw new NotifyException('Only top-level comments can be archived.', '/', 400);
@@ -76,7 +103,9 @@ class CommentRepo
$comment->archived = true; $comment->archived = true;
$comment->save(); $comment->save();
if ($log) {
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
}
return $comment; return $comment;
} }
@@ -84,7 +113,7 @@ class CommentRepo
/** /**
* Un-archive an existing comment. * Un-archive an existing comment.
*/ */
public function unarchive(Comment $comment): Comment public function unarchive(Comment $comment, bool $log = true): Comment
{ {
if ($comment->parent_id) { if ($comment->parent_id) {
throw new NotifyException('Only top-level comments can be un-archived.', '/', 400); throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
@@ -93,7 +122,9 @@ class CommentRepo
$comment->archived = false; $comment->archived = false;
$comment->save(); $comment->save();
if ($log) {
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment); ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
}
return $comment; return $comment;
} }

View File

@@ -6,8 +6,12 @@ namespace BookStack\Activity\Controllers;
use BookStack\Activity\CommentRepo; use BookStack\Activity\CommentRepo;
use BookStack\Activity\Models\Comment; use BookStack\Activity\Models\Comment;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Http\ApiController; use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use Illuminate\Http\JsonResponse; 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 * 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 class CommentApiController extends ApiController
{ {
// TODO - Add tree-style comment listing to page-show responses. // TODO - Add tree-style comment listing to page-show responses.
// TODO - create
// TODO - update
// TODO - delete
// TODO - Test visibility controls // TODO - Test visibility controls
// TODO - Test permissions of each action // 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( public function __construct(
protected CommentRepo $commentRepo, 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. * Read the details of a single comment, along with its direct replies.
*/ */
public function read(string $id): JsonResponse public function read(string $id): JsonResponse
{ {
$comment = $this->commentRepo->getQueryForVisible() $comment = $this->commentRepo->getVisibleById(intval($id));
->where('id', '=', $id)->firstOrFail();
$replies = $this->commentRepo->getQueryForVisible() $replies = $this->commentRepo->getQueryForVisible()
->where('parent_id', '=', $comment->local_id) ->where('parent_id', '=', $comment->local_id)
@@ -67,4 +103,45 @@ class CommentApiController extends ApiController
return response()->json($comment); 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);
}
} }

View File

@@ -22,7 +22,7 @@ class CommentController extends Controller
/** /**
* Save a new comment for a Page. * Save a new comment for a Page.
* *
* @throws ValidationException * @throws ValidationException|\Exception
*/ */
public function savePageComment(Request $request, int $pageId) public function savePageComment(Request $request, int $pageId)
{ {
@@ -37,11 +37,6 @@ class CommentController extends Controller
return response('Not found', 404); 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. // Create a new comment.
$this->checkPermission(Permission::CommentCreateAll); $this->checkPermission(Permission::CommentCreateAll);
$contentRef = $input['content_ref'] ?? ''; $contentRef = $input['content_ref'] ?? '';

View File

@@ -8,6 +8,12 @@ use Illuminate\Http\JsonResponse;
abstract class ApiController extends Controller 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<string, string[]>
*/
protected array $rules = []; protected array $rules = [];
/** /**

View File

@@ -48,9 +48,7 @@ enum Permission: string
case AttachmentUpdateAll = 'attachment-update-all'; case AttachmentUpdateAll = 'attachment-update-all';
case AttachmentUpdateOwn = 'attachment-update-own'; case AttachmentUpdateOwn = 'attachment-update-own';
case CommentCreate = 'comment-create';
case CommentCreateAll = 'comment-create-all'; case CommentCreateAll = 'comment-create-all';
case CommentCreateOwn = 'comment-create-own';
case CommentDelete = 'comment-delete'; case CommentDelete = 'comment-delete';
case CommentDeleteAll = 'comment-delete-all'; case CommentDeleteAll = 'comment-delete-all';
case CommentDeleteOwn = 'comment-delete-own'; case CommentDeleteOwn = 'comment-delete-own';

View File

@@ -0,0 +1,43 @@
<?php
namespace Activity;
use BookStack\Activity\ActivityType;
use BookStack\Facades\Activity;
use Tests\Api\TestsApi;
use Tests\TestCase;
class CommentsApiTest extends TestCase
{
use TestsApi;
public function test_endpoint_permission_controls()
{
// TODO
}
public function test_index()
{
// TODO
}
public function test_create()
{
// TODO
}
public function test_read()
{
// TODO
}
public function test_update()
{
// TODO
}
public function test_destroy()
{
// TODO
}
}

View File

@@ -5,7 +5,6 @@ namespace Tests\Api;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
use BookStack\Entities\Repos\BaseRepo; use BookStack\Entities\Repos\BaseRepo;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Tests\TestCase; use Tests\TestCase;
class BooksApiTest extends TestCase class BooksApiTest extends TestCase