1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-12-16 10:02:22 +03:00

Merge pull request #5850 from BookStackApp/comments_api

API: Started building comments API endpoints
This commit is contained in:
Dan Brown
2025-10-24 15:26:55 +01:00
committed by GitHub
30 changed files with 813 additions and 55 deletions

View File

@@ -4,10 +4,11 @@ namespace BookStack\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PrettyException;
use BookStack\Facades\Activity as ActivityService;
use BookStack\Util\HtmlDescriptionFilter;
use Illuminate\Database\Eloquent\Builder;
class CommentRepo
{
@@ -19,11 +20,46 @@ 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.
* @return Builder<Comment>
*/
public function getQueryForVisible(): Builder
{
return Comment::query()->scopes('visible');
}
/**
* Create a new comment on an entity.
*/
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('commentable_id', '=', $entity->id)
->where('commentable_type', '=', $entity->getMorphClass())
->where('local_id', '=', $parentId)
->exists();
if (!$parentCommentExists) {
$parentId = null;
}
}
$userId = user()->id;
$comment = new Comment();
@@ -38,6 +74,7 @@ class CommentRepo
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
$comment->refresh()->unsetRelations();
return $comment;
}
@@ -59,7 +96,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);
@@ -68,7 +105,9 @@ class CommentRepo
$comment->archived = true;
$comment->save();
if ($log) {
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
}
return $comment;
}
@@ -76,7 +115,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);
@@ -85,7 +124,9 @@ class CommentRepo
$comment->archived = false;
$comment->save();
if ($log) {
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
}
return $comment;
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
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
* scoped to the page which the comment is on. The 'parent_id' is used for replies
* and refers to the 'local_id' of the parent comment on the same page, not the main
* globally unique 'id'.
*
* If you want to get all comments for a page in a tree-like structure, as reflected in
* the UI, then that is provided on pages-read API responses.
*/
class CommentApiController extends ApiController
{
protected array $rules = [
'create' => [
'page_id' => ['required', 'integer'],
'reply_to' => ['nullable', 'integer'],
'html' => ['required', 'string'],
'content_ref' => ['string'],
],
'update' => [
'html' => ['string'],
'archived' => ['boolean'],
]
];
public function __construct(
protected CommentRepo $commentRepo,
protected PageQueries $pageQueries,
) {
}
/**
* Get a listing of comments visible to the user.
*/
public function list(): JsonResponse
{
$query = $this->commentRepo->getQueryForVisible();
return $this->apiListingResponse($query, [
'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at'
]);
}
/**
* 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->getVisibleById(intval($id));
$comment->load('createdBy', 'updatedBy');
$replies = $this->commentRepo->getQueryForVisible()
->where('parent_id', '=', $comment->local_id)
->where('commentable_id', '=', $comment->commentable_id)
->where('commentable_type', '=', $comment->commentable_type)
->get();
/** @var Comment[] $toProcess */
$toProcess = [$comment, ...$replies];
foreach ($toProcess as $commentToProcess) {
$commentToProcess->setAttribute('html', $commentToProcess->safeHtml());
$commentToProcess->makeVisible('html');
}
$comment->setRelation('replies', $replies);
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']);
$hasHtml = isset($input['html']);
if (isset($input['archived'])) {
if ($input['archived']) {
$this->commentRepo->archive($comment, !$hasHtml);
} else {
$this->commentRepo->unarchive($comment, !$hasHtml);
}
}
if ($hasHtml) {
$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.
*
* @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'] ?? '';

View File

@@ -3,22 +3,24 @@
namespace BookStack\Activity\Models;
use BookStack\App\Model;
use BookStack\Permissions\Models\JointPermission;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\HasCreatorAndUpdater;
use BookStack\Users\Models\OwnableInterface;
use BookStack\Users\Models\User;
use BookStack\Util\HtmlContentFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property string $text - Deprecated & now unused (#4821)
* @property string $html
* @property int|null $parent_id - Relates to local_id, not id
* @property int $local_id
* @property string $entity_type
* @property int $entity_id
* @property string $commentable_type
* @property int $commentable_id
* @property string $content_ref
* @property bool $archived
*/
@@ -28,13 +30,18 @@ class Comment extends Model implements Loggable, OwnableInterface
use HasCreatorAndUpdater;
protected $fillable = ['parent_id'];
protected $hidden = ['html'];
protected $casts = [
'archived' => 'boolean',
];
/**
* Get the entity that this comment belongs to.
*/
public function entity(): MorphTo
{
return $this->morphTo('entity');
return $this->morphTo('commentable');
}
/**
@@ -44,8 +51,8 @@ class Comment extends Model implements Loggable, OwnableInterface
public function parent(): BelongsTo
{
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
->where('entity_type', '=', $this->entity_type)
->where('entity_id', '=', $this->entity_id);
->where('commentable_type', '=', $this->commentable_type)
->where('commentable_id', '=', $this->commentable_id);
}
/**
@@ -58,11 +65,27 @@ class Comment extends Model implements Loggable, OwnableInterface
public function logDescriptor(): string
{
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->commentable_type} (ID: {$this->commentable_id})";
}
public function safeHtml(): string
{
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id')
->whereColumn('joint_permissions.entity_type', '=', 'comments.commentable_type');
}
/**
* Scope the query to just the comments visible to the user based upon the
* user visibility of what has been commented on.
*/
public function scopeVisible(Builder $query): Builder
{
return app()->make(PermissionApplicator::class)
->restrictEntityRelationQuery($query, 'comments', 'commentable_id', 'commentable_type');
}
}

View File

@@ -13,6 +13,11 @@ class CommentTree
* @var CommentTreeNode[]
*/
protected array $tree;
/**
* A linear array of loaded comments.
* @var Comment[]
*/
protected array $comments;
public function __construct(
@@ -39,7 +44,7 @@ class CommentTree
public function getActive(): array
{
return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived);
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived));
}
public function activeThreadCount(): int
@@ -49,7 +54,7 @@ class CommentTree
public function getArchived(): array
{
return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived);
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived));
}
public function archivedThreadCount(): int
@@ -79,6 +84,14 @@ class CommentTree
return false;
}
public function loadVisibleHtml(): void
{
foreach ($this->comments as $comment) {
$comment->setAttribute('html', $comment->safeHtml());
$comment->makeVisible('html');
}
}
/**
* @param Comment[] $comments
* @return CommentTreeNode[]
@@ -123,6 +136,9 @@ class CommentTree
return new CommentTreeNode($byId[$id], $depth, $children);
}
/**
* @return Comment[]
*/
protected function loadComments(): array
{
if (!$this->enabled()) {

View File

@@ -83,11 +83,19 @@ class ApiDocsGenerator
protected function loadDetailsFromControllers(Collection $routes): Collection
{
return $routes->map(function (array $route) {
$class = $this->getReflectionClass($route['controller']);
$method = $this->getReflectionMethod($route['controller'], $route['controller_method']);
$comment = $method->getDocComment();
$route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null;
$route['description'] = $comment ? $this->parseDescriptionFromDocBlockComment($comment) : null;
$route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']);
// Load class description for the model
// Not ideal to have it here on each route, but adding it in a more structured manner would break
// docs resulting JSON format and therefore be an API break.
// Save refactoring for a more significant set of changes.
$classComment = $class->getDocComment();
$route['model_description'] = $classComment ? $this->parseDescriptionFromDocBlockComment($classComment) : null;
return $route;
});
}
@@ -140,7 +148,7 @@ class ApiDocsGenerator
/**
* Parse out the description text from a class method comment.
*/
protected function parseDescriptionFromMethodComment(string $comment): string
protected function parseDescriptionFromDocBlockComment(string $comment): string
{
$matches = [];
preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches);
@@ -155,6 +163,16 @@ class ApiDocsGenerator
* @throws ReflectionException
*/
protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod
{
return $this->getReflectionClass($className)->getMethod($methodName);
}
/**
* Get a reflection class from the given class name.
*
* @throws ReflectionException
*/
protected function getReflectionClass(string $className): ReflectionClass
{
$class = $this->reflectionClasses[$className] ?? null;
if ($class === null) {
@@ -162,7 +180,7 @@ class ApiDocsGenerator
$this->reflectionClasses[$className] = $class;
}
return $class->getMethod($methodName);
return $class;
}
/**

View File

@@ -58,7 +58,7 @@ class BookApiController extends ApiController
/**
* View the details of a single book.
* The response data will contain 'content' property listing the chapter and pages directly within, in
* The response data will contain a 'content' property listing the chapter and pages directly within, in
* the same structure as you'd see within the BookStack interface when viewing a book. Top-level
* contents will have a 'type' property to distinguish between pages & chapters.
*/

View File

@@ -2,6 +2,7 @@
namespace BookStack\Entities\Controllers;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\PageRepo;
@@ -88,21 +89,32 @@ class PageApiController extends ApiController
/**
* View the details of a single page.
* Pages will always have HTML content. They may have markdown content
* if the markdown editor was used to last update the page.
* if the Markdown editor was used to last update the page.
*
* The 'html' property is the fully rendered & escaped HTML content that BookStack
* The 'html' property is the fully rendered and escaped HTML content that BookStack
* would show on page view, with page includes handled.
* The 'raw_html' property is the direct database stored HTML content, which would be
* what BookStack shows on page edit.
*
* See the "Content Security" section of these docs for security considerations when using
* the page content returned from this endpoint.
*
* Comments for the page are provided in a tree-structure representing the hierarchy of top-level
* comments and replies, for both archived and active comments.
*/
public function read(string $id)
{
$page = $this->queries->findVisibleByIdOrFail($id);
return response()->json($page->forJsonDisplay());
$page = $page->forJsonDisplay();
$commentTree = (new CommentTree($page));
$commentTree->loadVisibleHtml();
$page->setAttribute('comments', [
'active' => $commentTree->getActive(),
'archived' => $commentTree->getArchived(),
]);
return response()->json($page);
}
/**

View File

@@ -237,10 +237,11 @@ abstract class Entity extends Model implements
/**
* Get the comments for an entity.
* @return MorphMany<Comment, $this>
*/
public function comments(bool $orderByCreated = true): MorphMany
{
$query = $this->morphMany(Comment::class, 'entity');
$query = $this->morphMany(Comment::class, 'commentable');
return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
}

View File

@@ -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<string, array<string, string[]>>
*/
protected array $rules = [];
/**

View File

@@ -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';

View File

@@ -461,9 +461,9 @@ class SearchRunner
{
$commentsTable = DB::getTablePrefix() . 'comments';
$morphClass = str_replace('\\', '\\\\', $model->getMorphClass());
$commentQuery = DB::raw('(SELECT c1.entity_id, c1.entity_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.entity_id = c2.entity_id AND c1.entity_type = c2.entity_type AND c1.created_at < c2.created_at) WHERE c1.entity_type = \'' . $morphClass . '\' AND c2.created_at IS NULL) as comments');
$commentQuery = DB::raw('(SELECT c1.commentable_id, c1.commentable_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.commentable_id = c2.commentable_id AND c1.commentable_type = c2.commentable_type AND c1.created_at < c2.created_at) WHERE c1.commentable_type = \'' . $morphClass . '\' AND c2.created_at IS NULL) as comments');
$query->join($commentQuery, $model->getTable() . '.id', '=', DB::raw('comments.entity_id'))
$query->join($commentQuery, $model->getTable() . '.id', '=', DB::raw('comments.commentable_id'))
->orderBy('last_commented', $negated ? 'asc' : 'desc');
}
}

View File

@@ -2,6 +2,7 @@
namespace Database\Factories\Activity\Models;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class CommentFactory extends Factory
@@ -13,6 +14,11 @@ class CommentFactory extends Factory
*/
protected $model = \BookStack\Activity\Models\Comment::class;
/**
* A static counter to provide a unique local_id for each comment.
*/
protected static int $nextLocalId = 1000;
/**
* Define the model's default state.
*
@@ -22,13 +28,18 @@ class CommentFactory extends Factory
{
$text = $this->faker->paragraph(1);
$html = '<p>' . $text . '</p>';
$nextLocalId = static::$nextLocalId++;
$user = User::query()->first();
return [
'html' => $html,
'parent_id' => null,
'local_id' => 1,
'local_id' => $nextLocalId,
'content_ref' => '',
'archived' => false,
'created_by' => $user ?? User::factory(),
'updated_by' => $user ?? User::factory(),
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('comments', function (Blueprint $table) {
$table->renameColumn('entity_id', 'commentable_id');
$table->renameColumn('entity_type', 'commentable_type');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('comments', function (Blueprint $table) {
$table->renameColumn('commentable_id', 'entity_id');
$table->renameColumn('commentable_type', 'entity_type');
});
}
};

View File

@@ -0,0 +1,5 @@
{
"page_id": 2646,
"html": "<p>Can the title be updated?</p>",
"content_ref": "bkmrk-page-title:7341676876991010:3-14"
}

View File

@@ -0,0 +1,4 @@
{
"html": "<p>Can this comment be updated??????</p>",
"archived": true
}

View File

@@ -0,0 +1,13 @@
{
"id": 167,
"commentable_id": 2646,
"commentable_type": "page",
"parent_id": null,
"local_id": 29,
"created_by": 1,
"updated_by": 1,
"created_at": "2025-10-24T14:05:41.000000Z",
"updated_at": "2025-10-24T14:05:41.000000Z",
"content_ref": "bkmrk-page-title:7341676876991010:3-14",
"archived": false
}

View File

@@ -0,0 +1,29 @@
{
"data": [
{
"id": 1,
"commentable_id": 2607,
"commentable_type": "page",
"parent_id": null,
"local_id": 1,
"content_ref": "",
"created_by": 1,
"updated_by": 1,
"created_at": "2022-04-20T08:43:27.000000Z",
"updated_at": "2022-04-20T08:43:27.000000Z"
},
{
"id": 18,
"commentable_id": 2607,
"commentable_type": "page",
"parent_id": 1,
"local_id": 2,
"content_ref": "",
"created_by": 3,
"updated_by": 3,
"created_at": "2022-11-15T08:12:35.000000Z",
"updated_at": "2022-11-15T08:12:35.000000Z"
}
],
"total": 88
}

View File

@@ -0,0 +1,38 @@
{
"id": 22,
"commentable_id": 2646,
"commentable_type": "page",
"html": "<p>This page looks great!<\/p>\n",
"parent_id": null,
"local_id": 2,
"created_by": {
"id": 1,
"name": "Admin",
"slug": "admin"
},
"updated_by": {
"id": 1,
"name": "Admin",
"slug": "admin"
},
"created_at": "2023-06-07T07:50:56.000000Z",
"updated_at": "2023-06-07T07:50:56.000000Z",
"content_ref": "",
"archived": false,
"replies": [
{
"id": 34,
"commentable_id": 2646,
"commentable_type": "page",
"html": "<p>Thanks for the comment!<\/p>\n",
"parent_id": 2,
"local_id": 10,
"created_by": 2,
"updated_by": 2,
"created_at": "2023-06-07T13:46:25.000000Z",
"updated_at": "2023-06-07T13:46:25.000000Z",
"content_ref": "",
"archived": false
}
]
}

View File

@@ -0,0 +1,13 @@
{
"id": 167,
"commentable_id": 2646,
"commentable_type": "page",
"parent_id": null,
"local_id": 29,
"created_by": 1,
"updated_by": 1,
"created_at": "2025-10-24T14:05:41.000000Z",
"updated_at": "2025-10-24T14:09:56.000000Z",
"content_ref": "bkmrk-page-title:7341676876991010:3-14",
"archived": true
}

View File

@@ -29,6 +29,79 @@
"revision_count": 5,
"template": false,
"editor": "wysiwyg",
"comments": {
"active": [
{
"comment": {
"id": 22,
"commentable_id": 306,
"commentable_type": "page",
"html": "<p>Does this need revising?<\/p>\n",
"parent_id": null,
"local_id": 1,
"created_by": {
"id": 1,
"name": "Admin",
"slug": "admin"
},
"updated_by": 1,
"created_at": "2023-06-07T07:50:56.000000Z",
"updated_at": "2023-06-07T07:50:56.000000Z",
"content_ref": "",
"archived": false
},
"depth": 0,
"children": [
{
"comment": {
"id": 34,
"commentable_id": 2646,
"commentable_type": "page",
"html": "<p>I think it's okay!<\/p>\n",
"parent_id": 1,
"local_id": 2,
"created_by": {
"id": 2,
"name": "Editor",
"slug": "editor"
},
"updated_by": 1,
"created_at": "2023-06-07T13:46:25.000000Z",
"updated_at": "2023-06-07T13:46:25.000000Z",
"content_ref": "",
"archived": false
},
"depth": 1,
"children": []
}
]
}
],
"archived": [
{
"comment": {
"id": 21,
"commentable_id": 2646,
"commentable_type": "page",
"html": "<p>The title needs to be fixed<\/p>\n",
"parent_id": null,
"local_id": 3,
"created_by": {
"id": 2,
"name": "Editor",
"slug": "editor"
},
"updated_by": 1,
"created_at": "2023-06-07T07:50:49.000000Z",
"updated_at": "2025-10-24T08:37:22.000000Z",
"content_ref": "",
"archived": true
},
"depth": 0,
"children": []
}
]
},
"tags": [
{
"name": "Category",

View File

@@ -45,7 +45,9 @@
@foreach($docs as $model => $endpoints)
<section class="card content-wrap auto-height">
<h1 class="list-heading text-capitals">{{ $model }}</h1>
@if($endpoints[0]['model_description'])
<p>{{ $endpoints[0]['model_description'] }}</p>
@endif
@foreach($endpoints as $endpoint)
@include('api-docs.parts.endpoint', ['endpoint' => $endpoint, 'loop' => $loop])
@endforeach

View File

@@ -6,7 +6,7 @@
* Controllers all end with "ApiController"
*/
use BookStack\Activity\Controllers\AuditLogApiController;
use BookStack\Activity\Controllers as ActivityControllers;
use BookStack\Api\ApiDocsController;
use BookStack\App\SystemApiController;
use BookStack\Entities\Controllers as EntityControllers;
@@ -70,6 +70,12 @@ Route::delete('image-gallery/{id}', [ImageGalleryApiController::class, 'delete']
Route::get('search', [SearchApiController::class, 'all']);
Route::get('comments', [ActivityControllers\CommentApiController::class, 'list']);
Route::post('comments', [ActivityControllers\CommentApiController::class, 'create']);
Route::get('comments/{id}', [ActivityControllers\CommentApiController::class, 'read']);
Route::put('comments/{id}', [ActivityControllers\CommentApiController::class, 'update']);
Route::delete('comments/{id}', [ActivityControllers\CommentApiController::class, 'delete']);
Route::get('shelves', [EntityControllers\BookshelfApiController::class, 'list']);
Route::post('shelves', [EntityControllers\BookshelfApiController::class, 'create']);
Route::get('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'read']);
@@ -101,6 +107,6 @@ Route::delete('recycle-bin/{deletionId}', [EntityControllers\RecycleBinApiContro
Route::get('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'read']);
Route::put('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'update']);
Route::get('audit-log', [AuditLogApiController::class, 'list']);
Route::get('audit-log', [ActivityControllers\AuditLogApiController::class, 'list']);
Route::get('system', [SystemApiController::class, 'read']);

View File

@@ -0,0 +1,250 @@
<?php
namespace Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Permissions\Permission;
use Tests\Api\TestsApi;
use Tests\TestCase;
class CommentsApiTest extends TestCase
{
use TestsApi;
public function test_endpoint_permission_controls()
{
$user = $this->users->editor();
$this->permissions->grantUserRolePermissions($user, [Permission::CommentDeleteAll, Permission::CommentUpdateAll]);
$page = $this->entities->page();
$comment = Comment::factory()->make();
$page->comments()->save($comment);
$this->actingAsForApi($user);
$actions = [
['GET', '/api/comments'],
['GET', "/api/comments/{$comment->id}"],
['POST', "/api/comments"],
['PUT', "/api/comments/{$comment->id}"],
['DELETE', "/api/comments/{$comment->id}"],
];
foreach ($actions as [$method, $endpoint]) {
$resp = $this->call($method, $endpoint);
$this->assertNotPermissionError($resp);
}
$comment = Comment::factory()->make();
$page->comments()->save($comment);
$this->getJson("/api/comments")->assertSee(['id' => $comment->id]);
$this->permissions->removeUserRolePermissions($user, [
Permission::CommentDeleteAll, Permission::CommentDeleteOwn,
Permission::CommentUpdateAll, Permission::CommentUpdateOwn,
Permission::CommentCreateAll
]);
$this->assertPermissionError($this->json('delete', "/api/comments/{$comment->id}"));
$this->assertPermissionError($this->json('put', "/api/comments/{$comment->id}"));
$this->assertPermissionError($this->json('post', "/api/comments"));
$this->assertNotPermissionError($this->json('get', "/api/comments/{$comment->id}"));
$this->permissions->disableEntityInheritedPermissions($page);
$this->json('get', "/api/comments/{$comment->id}")->assertStatus(404);
$this->getJson("/api/comments")->assertDontSee(['id' => $comment->id]);
}
public function test_index()
{
$page = $this->entities->page();
Comment::query()->delete();
$comments = Comment::factory()->count(10)->make();
$page->comments()->saveMany($comments);
$firstComment = $comments->first();
$resp = $this->actingAsApiEditor()->getJson('/api/comments');
$resp->assertJson([
'data' => [
[
'id' => $firstComment->id,
'commentable_id' => $page->id,
'commentable_type' => 'page',
'parent_id' => null,
'local_id' => $firstComment->local_id,
],
],
]);
$resp->assertJsonCount(10, 'data');
$resp->assertJson(['total' => 10]);
$filtered = $this->getJson("/api/comments?filter[id]={$firstComment->id}");
$filtered->assertJsonCount(1, 'data');
$filtered->assertJson(['total' => 1]);
}
public function test_create()
{
$page = $this->entities->page();
$resp = $this->actingAsApiEditor()->postJson('/api/comments', [
'page_id' => $page->id,
'html' => '<p>My wonderful comment</p>',
'content_ref' => 'test-content-ref',
]);
$resp->assertOk();
$id = $resp->json('id');
$this->assertDatabaseHas('comments', [
'id' => $id,
'commentable_id' => $page->id,
'commentable_type' => 'page',
'html' => '<p>My wonderful comment</p>',
]);
$comment = Comment::query()->findOrFail($id);
$this->assertIsInt($comment->local_id);
$reply = $this->actingAsApiEditor()->postJson('/api/comments', [
'page_id' => $page->id,
'html' => '<p>My wonderful reply</p>',
'content_ref' => 'test-content-ref',
'reply_to' => $comment->local_id,
]);
$reply->assertOk();
$this->assertDatabaseHas('comments', [
'id' => $reply->json('id'),
'commentable_id' => $page->id,
'commentable_type' => 'page',
'html' => '<p>My wonderful reply</p>',
'parent_id' => $comment->local_id,
]);
}
public function test_read()
{
$page = $this->entities->page();
$user = $this->users->viewer();
$comment = Comment::factory()->make([
'html' => '<p>A lovely comment <script>hello</script></p>',
'created_by' => $user->id,
'updated_by' => $user->id,
]);
$page->comments()->save($comment);
$comment->refresh();
$reply = Comment::factory()->make([
'parent_id' => $comment->local_id,
'html' => '<p>A lovely<script>angry</script>reply</p>',
]);
$page->comments()->save($reply);
$resp = $this->actingAsApiEditor()->getJson("/api/comments/{$comment->id}");
$resp->assertJson([
'id' => $comment->id,
'commentable_id' => $page->id,
'commentable_type' => 'page',
'html' => '<p>A lovely comment </p>',
'archived' => false,
'created_by' => [
'id' => $user->id,
'name' => $user->name,
],
'updated_by' => [
'id' => $user->id,
'name' => $user->name,
],
'replies' => [
[
'id' => $reply->id,
'html' => '<p>A lovelyreply</p>'
]
]
]);
}
public function test_update()
{
$page = $this->entities->page();
$user = $this->users->editor();
$this->permissions->grantUserRolePermissions($user, [Permission::CommentUpdateAll]);
$comment = Comment::factory()->make([
'html' => '<p>A lovely comment</p>',
'created_by' => $this->users->viewer()->id,
'updated_by' => $this->users->viewer()->id,
'parent_id' => null,
]);
$page->comments()->save($comment);
$this->actingAsForApi($user)->putJson("/api/comments/{$comment->id}", [
'html' => '<p>A lovely updated comment</p>',
])->assertOk();
$this->assertDatabaseHas('comments', [
'id' => $comment->id,
'html' => '<p>A lovely updated comment</p>',
'archived' => 0,
]);
$this->putJson("/api/comments/{$comment->id}", [
'archived' => true,
]);
$this->assertDatabaseHas('comments', [
'id' => $comment->id,
'html' => '<p>A lovely updated comment</p>',
'archived' => 1,
]);
$this->putJson("/api/comments/{$comment->id}", [
'archived' => false,
'html' => '<p>A lovely updated again comment</p>',
]);
$this->assertDatabaseHas('comments', [
'id' => $comment->id,
'html' => '<p>A lovely updated again comment</p>',
'archived' => 0,
]);
}
public function test_update_cannot_archive_replies()
{
$page = $this->entities->page();
$user = $this->users->editor();
$this->permissions->grantUserRolePermissions($user, [Permission::CommentUpdateAll]);
$comment = Comment::factory()->make([
'html' => '<p>A lovely comment</p>',
'created_by' => $this->users->viewer()->id,
'updated_by' => $this->users->viewer()->id,
'parent_id' => 90,
]);
$page->comments()->save($comment);
$resp = $this->actingAsForApi($user)->putJson("/api/comments/{$comment->id}", [
'archived' => true,
]);
$this->assertEquals($this->errorResponse('Only top-level comments can be archived.', 400), $resp->json());
$this->assertDatabaseHas('comments', [
'id' => $comment->id,
'archived' => 0,
]);
}
public function test_destroy()
{
$page = $this->entities->page();
$user = $this->users->editor();
$this->permissions->grantUserRolePermissions($user, [Permission::CommentDeleteAll]);
$comment = Comment::factory()->make([
'html' => '<p>A lovely comment</p>',
]);
$page->comments()->save($comment);
$this->actingAsForApi($user)->deleteJson("/api/comments/{$comment->id}")->assertStatus(204);
$this->assertDatabaseMissing('comments', [
'id' => $comment->id,
]);
}
}

View File

@@ -340,8 +340,8 @@ class WatchTest extends TestCase
ActivityType::PAGE_CREATE => $entities['page'],
ActivityType::PAGE_UPDATE => $entities['page'],
ActivityType::COMMENT_CREATE => Comment::factory()->make([
'entity_id' => $entities['page']->id,
'entity_type' => $entities['page']->getMorphClass(),
'commentable_id' => $entities['page']->id,
'commentable_type' => $entities['page']->getMorphClass(),
]),
];

View File

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

View File

@@ -2,6 +2,7 @@
namespace Tests\Api;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use Carbon\Carbon;
@@ -199,6 +200,31 @@ class PagesApiTest extends TestCase
$this->assertSame(404, $resp->json('error')['code']);
}
public function test_read_endpoint_includes_page_comments_tree_structure()
{
$this->actingAsApiEditor();
$page = $this->entities->page();
$relation = ['commentable_type' => 'page', 'commentable_id' => $page->id];
$active = Comment::factory()->create([...$relation, 'html' => '<p>My active<script>cat</script> comment</p>']);
Comment::factory()->count(5)->create([...$relation, 'parent_id' => $active->local_id]);
$archived = Comment::factory()->create([...$relation, 'archived' => true]);
Comment::factory()->count(2)->create([...$relation, 'parent_id' => $archived->local_id]);
$resp = $this->getJson("{$this->baseEndpoint}/{$page->id}");
$resp->assertOk();
$resp->assertJsonCount(1, 'comments.active');
$resp->assertJsonCount(1, 'comments.archived');
$resp->assertJsonCount(5, 'comments.active.0.children');
$resp->assertJsonCount(2, 'comments.archived.0.children');
$resp->assertJsonFragment([
'id' => $active->id,
'local_id' => $active->local_id,
'html' => '<p>My active comment</p>',
]);
}
public function test_update_endpoint()
{
$this->actingAsApiEditor();

View File

@@ -72,8 +72,8 @@ class CommentDisplayTest extends TestCase
Comment::factory()->create([
'created_by' => $editor->id,
'entity_type' => 'page',
'entity_id' => $page->id,
'commentable_type' => 'page',
'commentable_id' => $page->id,
]);
$resp = $this->actingAs($editor)->get($page->getUrl());
@@ -84,7 +84,7 @@ class CommentDisplayTest extends TestCase
public function test_comment_displays_relative_times()
{
$page = $this->entities->page();
$comment = Comment::factory()->create(['entity_id' => $page->id, 'entity_type' => $page->getMorphClass()]);
$comment = Comment::factory()->create(['commentable_id' => $page->id, 'commentable_type' => $page->getMorphClass()]);
$comment->created_at = now()->subWeek();
$comment->updated_at = now()->subDay();
$comment->save();

View File

@@ -14,6 +14,7 @@ class CommentStoreTest extends TestCase
$this->asAdmin();
$page = $this->entities->page();
Comment::factory()->create(['commentable_id' => $page->id, 'commentable_type' => 'page', 'local_id' => 2]);
$comment = Comment::factory()->make(['parent_id' => 2]);
$resp = $this->postJson("/comment/$page->id", $comment->getAttributes());
@@ -24,9 +25,9 @@ class CommentStoreTest extends TestCase
$pageResp->assertSee($comment->html, false);
$this->assertDatabaseHas('comments', [
'local_id' => 1,
'entity_id' => $page->id,
'entity_type' => Page::newModelInstance()->getMorphClass(),
'local_id' => 3,
'commentable_id' => $page->id,
'commentable_type' => 'page',
'parent_id' => 2,
]);
@@ -52,9 +53,9 @@ class CommentStoreTest extends TestCase
]);
if ($valid) {
$this->assertDatabaseHas('comments', ['entity_id' => $page->id, 'content_ref' => $ref]);
$this->assertDatabaseHas('comments', ['commentable_id' => $page->id, 'content_ref' => $ref]);
} else {
$this->assertDatabaseMissing('comments', ['entity_id' => $page->id, 'content_ref' => $ref]);
$this->assertDatabaseMissing('comments', ['commentable_id' => $page->id, 'content_ref' => $ref]);
}
}
}
@@ -79,7 +80,7 @@ class CommentStoreTest extends TestCase
$this->assertDatabaseHas('comments', [
'html' => $newHtml,
'entity_id' => $page->id,
'commentable_id' => $page->id,
]);
$this->assertActivityExists(ActivityType::COMMENT_UPDATE);
@@ -218,7 +219,7 @@ class CommentStoreTest extends TestCase
$page = $this->entities->page();
Comment::factory()->create([
'html' => '<script>superbadscript</script><script>superbadscript</script><p onclick="superbadonclick">scriptincommentest</p>',
'entity_type' => 'page', 'entity_id' => $page
'commentable_type' => 'page', 'commentable_id' => $page
]);
$resp = $this->asAdmin()->get($page->getUrl());
@@ -236,8 +237,8 @@ class CommentStoreTest extends TestCase
$resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]);
$resp->assertOk();
$this->assertDatabaseHas('comments', [
'entity_type' => 'page',
'entity_id' => $page->id,
'commentable_type' => 'page',
'commentable_id' => $page->id,
'html' => $expected,
]);
@@ -259,8 +260,8 @@ class CommentStoreTest extends TestCase
$resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]);
$resp->assertOk();
$this->assertDatabaseHas('comments', [
'entity_type' => 'page',
'entity_id' => $page->id,
'commentable_type' => 'page',
'commentable_id' => $page->id,
'html' => $expected,
]);

View File

@@ -199,7 +199,7 @@ abstract class TestCase extends BaseTestCase
{
if ($response->status() === 403 && $response instanceof JsonResponse) {
$errMessage = $response->getData(true)['error']['message'] ?? '';
return $errMessage === 'You do not have permission to perform the requested action.';
return str_contains($errMessage, 'do not have permission');
}
return $response->status() === 302