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:
@@ -4,10 +4,11 @@ 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\Exceptions\PrettyException;
|
|
||||||
use BookStack\Facades\Activity as ActivityService;
|
use BookStack\Facades\Activity as ActivityService;
|
||||||
use BookStack\Util\HtmlDescriptionFilter;
|
use BookStack\Util\HtmlDescriptionFilter;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
class CommentRepo
|
class CommentRepo
|
||||||
{
|
{
|
||||||
@@ -19,11 +20,46 @@ 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.
|
||||||
|
* @return Builder<Comment>
|
||||||
|
*/
|
||||||
|
public function getQueryForVisible(): Builder
|
||||||
|
{
|
||||||
|
return Comment::query()->scopes('visible');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new comment on an entity.
|
* Create a new comment on an entity.
|
||||||
*/
|
*/
|
||||||
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('commentable_id', '=', $entity->id)
|
||||||
|
->where('commentable_type', '=', $entity->getMorphClass())
|
||||||
|
->where('local_id', '=', $parentId)
|
||||||
|
->exists();
|
||||||
|
if (!$parentCommentExists) {
|
||||||
|
$parentId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$userId = user()->id;
|
$userId = user()->id;
|
||||||
$comment = new Comment();
|
$comment = new Comment();
|
||||||
|
|
||||||
@@ -38,6 +74,7 @@ class CommentRepo
|
|||||||
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
|
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
|
||||||
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
|
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
|
||||||
|
|
||||||
|
$comment->refresh()->unsetRelations();
|
||||||
return $comment;
|
return $comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +96,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);
|
||||||
@@ -68,7 +105,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;
|
||||||
}
|
}
|
||||||
@@ -76,7 +115,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);
|
||||||
@@ -85,7 +124,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;
|
||||||
}
|
}
|
||||||
|
|||||||
148
app/Activity/Controllers/CommentApiController.php
Normal file
148
app/Activity/Controllers/CommentApiController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'] ?? '';
|
||||||
|
|||||||
@@ -3,22 +3,24 @@
|
|||||||
namespace BookStack\Activity\Models;
|
namespace BookStack\Activity\Models;
|
||||||
|
|
||||||
use BookStack\App\Model;
|
use BookStack\App\Model;
|
||||||
|
use BookStack\Permissions\Models\JointPermission;
|
||||||
|
use BookStack\Permissions\PermissionApplicator;
|
||||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||||
use BookStack\Users\Models\OwnableInterface;
|
use BookStack\Users\Models\OwnableInterface;
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use BookStack\Util\HtmlContentFilter;
|
use BookStack\Util\HtmlContentFilter;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property string $text - Deprecated & now unused (#4821)
|
|
||||||
* @property string $html
|
* @property string $html
|
||||||
* @property int|null $parent_id - Relates to local_id, not id
|
* @property int|null $parent_id - Relates to local_id, not id
|
||||||
* @property int $local_id
|
* @property int $local_id
|
||||||
* @property string $entity_type
|
* @property string $commentable_type
|
||||||
* @property int $entity_id
|
* @property int $commentable_id
|
||||||
* @property string $content_ref
|
* @property string $content_ref
|
||||||
* @property bool $archived
|
* @property bool $archived
|
||||||
*/
|
*/
|
||||||
@@ -28,13 +30,18 @@ class Comment extends Model implements Loggable, OwnableInterface
|
|||||||
use HasCreatorAndUpdater;
|
use HasCreatorAndUpdater;
|
||||||
|
|
||||||
protected $fillable = ['parent_id'];
|
protected $fillable = ['parent_id'];
|
||||||
|
protected $hidden = ['html'];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'archived' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the entity that this comment belongs to.
|
* Get the entity that this comment belongs to.
|
||||||
*/
|
*/
|
||||||
public function entity(): MorphTo
|
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
|
public function parent(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
|
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
|
||||||
->where('entity_type', '=', $this->entity_type)
|
->where('commentable_type', '=', $this->commentable_type)
|
||||||
->where('entity_id', '=', $this->entity_id);
|
->where('commentable_id', '=', $this->commentable_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,11 +65,27 @@ class Comment extends Model implements Loggable, OwnableInterface
|
|||||||
|
|
||||||
public function logDescriptor(): string
|
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
|
public function safeHtml(): string
|
||||||
{
|
{
|
||||||
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ class CommentTree
|
|||||||
* @var CommentTreeNode[]
|
* @var CommentTreeNode[]
|
||||||
*/
|
*/
|
||||||
protected array $tree;
|
protected array $tree;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A linear array of loaded comments.
|
||||||
|
* @var Comment[]
|
||||||
|
*/
|
||||||
protected array $comments;
|
protected array $comments;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -39,7 +44,7 @@ class CommentTree
|
|||||||
|
|
||||||
public function getActive(): array
|
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
|
public function activeThreadCount(): int
|
||||||
@@ -49,7 +54,7 @@ class CommentTree
|
|||||||
|
|
||||||
public function getArchived(): array
|
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
|
public function archivedThreadCount(): int
|
||||||
@@ -79,6 +84,14 @@ class CommentTree
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function loadVisibleHtml(): void
|
||||||
|
{
|
||||||
|
foreach ($this->comments as $comment) {
|
||||||
|
$comment->setAttribute('html', $comment->safeHtml());
|
||||||
|
$comment->makeVisible('html');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Comment[] $comments
|
* @param Comment[] $comments
|
||||||
* @return CommentTreeNode[]
|
* @return CommentTreeNode[]
|
||||||
@@ -123,6 +136,9 @@ class CommentTree
|
|||||||
return new CommentTreeNode($byId[$id], $depth, $children);
|
return new CommentTreeNode($byId[$id], $depth, $children);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Comment[]
|
||||||
|
*/
|
||||||
protected function loadComments(): array
|
protected function loadComments(): array
|
||||||
{
|
{
|
||||||
if (!$this->enabled()) {
|
if (!$this->enabled()) {
|
||||||
|
|||||||
@@ -83,11 +83,19 @@ class ApiDocsGenerator
|
|||||||
protected function loadDetailsFromControllers(Collection $routes): Collection
|
protected function loadDetailsFromControllers(Collection $routes): Collection
|
||||||
{
|
{
|
||||||
return $routes->map(function (array $route) {
|
return $routes->map(function (array $route) {
|
||||||
|
$class = $this->getReflectionClass($route['controller']);
|
||||||
$method = $this->getReflectionMethod($route['controller'], $route['controller_method']);
|
$method = $this->getReflectionMethod($route['controller'], $route['controller_method']);
|
||||||
$comment = $method->getDocComment();
|
$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']);
|
$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;
|
return $route;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -140,7 +148,7 @@ class ApiDocsGenerator
|
|||||||
/**
|
/**
|
||||||
* Parse out the description text from a class method comment.
|
* Parse out the description text from a class method comment.
|
||||||
*/
|
*/
|
||||||
protected function parseDescriptionFromMethodComment(string $comment): string
|
protected function parseDescriptionFromDocBlockComment(string $comment): string
|
||||||
{
|
{
|
||||||
$matches = [];
|
$matches = [];
|
||||||
preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches);
|
preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches);
|
||||||
@@ -155,6 +163,16 @@ class ApiDocsGenerator
|
|||||||
* @throws ReflectionException
|
* @throws ReflectionException
|
||||||
*/
|
*/
|
||||||
protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod
|
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;
|
$class = $this->reflectionClasses[$className] ?? null;
|
||||||
if ($class === null) {
|
if ($class === null) {
|
||||||
@@ -162,7 +180,7 @@ class ApiDocsGenerator
|
|||||||
$this->reflectionClasses[$className] = $class;
|
$this->reflectionClasses[$className] = $class;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $class->getMethod($methodName);
|
return $class;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class BookApiController extends ApiController
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* View the details of a single book.
|
* 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
|
* 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.
|
* contents will have a 'type' property to distinguish between pages & chapters.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace BookStack\Entities\Controllers;
|
namespace BookStack\Entities\Controllers;
|
||||||
|
|
||||||
|
use BookStack\Activity\Tools\CommentTree;
|
||||||
use BookStack\Entities\Queries\EntityQueries;
|
use BookStack\Entities\Queries\EntityQueries;
|
||||||
use BookStack\Entities\Queries\PageQueries;
|
use BookStack\Entities\Queries\PageQueries;
|
||||||
use BookStack\Entities\Repos\PageRepo;
|
use BookStack\Entities\Repos\PageRepo;
|
||||||
@@ -88,21 +89,32 @@ class PageApiController extends ApiController
|
|||||||
/**
|
/**
|
||||||
* View the details of a single page.
|
* View the details of a single page.
|
||||||
* Pages will always have HTML content. They may have markdown content
|
* 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.
|
* would show on page view, with page includes handled.
|
||||||
* The 'raw_html' property is the direct database stored HTML content, which would be
|
* The 'raw_html' property is the direct database stored HTML content, which would be
|
||||||
* what BookStack shows on page edit.
|
* what BookStack shows on page edit.
|
||||||
*
|
*
|
||||||
* See the "Content Security" section of these docs for security considerations when using
|
* See the "Content Security" section of these docs for security considerations when using
|
||||||
* the page content returned from this endpoint.
|
* 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)
|
public function read(string $id)
|
||||||
{
|
{
|
||||||
$page = $this->queries->findVisibleByIdOrFail($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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -237,10 +237,11 @@ abstract class Entity extends Model implements
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the comments for an entity.
|
* Get the comments for an entity.
|
||||||
|
* @return MorphMany<Comment, $this>
|
||||||
*/
|
*/
|
||||||
public function comments(bool $orderByCreated = true): MorphMany
|
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;
|
return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, array<string, string[]>>
|
||||||
|
*/
|
||||||
protected array $rules = [];
|
protected array $rules = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -461,9 +461,9 @@ class SearchRunner
|
|||||||
{
|
{
|
||||||
$commentsTable = DB::getTablePrefix() . 'comments';
|
$commentsTable = DB::getTablePrefix() . 'comments';
|
||||||
$morphClass = str_replace('\\', '\\\\', $model->getMorphClass());
|
$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');
|
->orderBy('last_commented', $negated ? 'asc' : 'desc');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Database\Factories\Activity\Models;
|
namespace Database\Factories\Activity\Models;
|
||||||
|
|
||||||
|
use BookStack\Users\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
class CommentFactory extends Factory
|
class CommentFactory extends Factory
|
||||||
@@ -13,6 +14,11 @@ class CommentFactory extends Factory
|
|||||||
*/
|
*/
|
||||||
protected $model = \BookStack\Activity\Models\Comment::class;
|
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.
|
* Define the model's default state.
|
||||||
*
|
*
|
||||||
@@ -22,13 +28,18 @@ class CommentFactory extends Factory
|
|||||||
{
|
{
|
||||||
$text = $this->faker->paragraph(1);
|
$text = $this->faker->paragraph(1);
|
||||||
$html = '<p>' . $text . '</p>';
|
$html = '<p>' . $text . '</p>';
|
||||||
|
$nextLocalId = static::$nextLocalId++;
|
||||||
|
|
||||||
|
$user = User::query()->first();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'html' => $html,
|
'html' => $html,
|
||||||
'parent_id' => null,
|
'parent_id' => null,
|
||||||
'local_id' => 1,
|
'local_id' => $nextLocalId,
|
||||||
'content_ref' => '',
|
'content_ref' => '',
|
||||||
'archived' => false,
|
'archived' => false,
|
||||||
|
'created_by' => $user ?? User::factory(),
|
||||||
|
'updated_by' => $user ?? User::factory(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
5
dev/api/requests/comments-create.json
Normal file
5
dev/api/requests/comments-create.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"page_id": 2646,
|
||||||
|
"html": "<p>Can the title be updated?</p>",
|
||||||
|
"content_ref": "bkmrk-page-title:7341676876991010:3-14"
|
||||||
|
}
|
||||||
4
dev/api/requests/comments-update.json
Normal file
4
dev/api/requests/comments-update.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"html": "<p>Can this comment be updated??????</p>",
|
||||||
|
"archived": true
|
||||||
|
}
|
||||||
13
dev/api/responses/comments-create.json
Normal file
13
dev/api/responses/comments-create.json
Normal 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
|
||||||
|
}
|
||||||
29
dev/api/responses/comments-list.json
Normal file
29
dev/api/responses/comments-list.json
Normal 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
|
||||||
|
}
|
||||||
38
dev/api/responses/comments-read.json
Normal file
38
dev/api/responses/comments-read.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
13
dev/api/responses/comments-update.json
Normal file
13
dev/api/responses/comments-update.json
Normal 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
|
||||||
|
}
|
||||||
@@ -29,6 +29,79 @@
|
|||||||
"revision_count": 5,
|
"revision_count": 5,
|
||||||
"template": false,
|
"template": false,
|
||||||
"editor": "wysiwyg",
|
"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": [
|
"tags": [
|
||||||
{
|
{
|
||||||
"name": "Category",
|
"name": "Category",
|
||||||
|
|||||||
@@ -45,7 +45,9 @@
|
|||||||
@foreach($docs as $model => $endpoints)
|
@foreach($docs as $model => $endpoints)
|
||||||
<section class="card content-wrap auto-height">
|
<section class="card content-wrap auto-height">
|
||||||
<h1 class="list-heading text-capitals">{{ $model }}</h1>
|
<h1 class="list-heading text-capitals">{{ $model }}</h1>
|
||||||
|
@if($endpoints[0]['model_description'])
|
||||||
|
<p>{{ $endpoints[0]['model_description'] }}</p>
|
||||||
|
@endif
|
||||||
@foreach($endpoints as $endpoint)
|
@foreach($endpoints as $endpoint)
|
||||||
@include('api-docs.parts.endpoint', ['endpoint' => $endpoint, 'loop' => $loop])
|
@include('api-docs.parts.endpoint', ['endpoint' => $endpoint, 'loop' => $loop])
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* Controllers all end with "ApiController"
|
* Controllers all end with "ApiController"
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use BookStack\Activity\Controllers\AuditLogApiController;
|
use BookStack\Activity\Controllers as ActivityControllers;
|
||||||
use BookStack\Api\ApiDocsController;
|
use BookStack\Api\ApiDocsController;
|
||||||
use BookStack\App\SystemApiController;
|
use BookStack\App\SystemApiController;
|
||||||
use BookStack\Entities\Controllers as EntityControllers;
|
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('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::get('shelves', [EntityControllers\BookshelfApiController::class, 'list']);
|
||||||
Route::post('shelves', [EntityControllers\BookshelfApiController::class, 'create']);
|
Route::post('shelves', [EntityControllers\BookshelfApiController::class, 'create']);
|
||||||
Route::get('shelves/{id}', [EntityControllers\BookshelfApiController::class, 'read']);
|
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::get('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'read']);
|
||||||
Route::put('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'update']);
|
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']);
|
Route::get('system', [SystemApiController::class, 'read']);
|
||||||
|
|||||||
250
tests/Activity/CommentsApiTest.php
Normal file
250
tests/Activity/CommentsApiTest.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -340,8 +340,8 @@ class WatchTest extends TestCase
|
|||||||
ActivityType::PAGE_CREATE => $entities['page'],
|
ActivityType::PAGE_CREATE => $entities['page'],
|
||||||
ActivityType::PAGE_UPDATE => $entities['page'],
|
ActivityType::PAGE_UPDATE => $entities['page'],
|
||||||
ActivityType::COMMENT_CREATE => Comment::factory()->make([
|
ActivityType::COMMENT_CREATE => Comment::factory()->make([
|
||||||
'entity_id' => $entities['page']->id,
|
'commentable_id' => $entities['page']->id,
|
||||||
'entity_type' => $entities['page']->getMorphClass(),
|
'commentable_type' => $entities['page']->getMorphClass(),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Api;
|
namespace Tests\Api;
|
||||||
|
|
||||||
|
use BookStack\Activity\Models\Comment;
|
||||||
use BookStack\Entities\Models\Chapter;
|
use BookStack\Entities\Models\Chapter;
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Entities\Models\Page;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
@@ -199,6 +200,31 @@ class PagesApiTest extends TestCase
|
|||||||
$this->assertSame(404, $resp->json('error')['code']);
|
$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()
|
public function test_update_endpoint()
|
||||||
{
|
{
|
||||||
$this->actingAsApiEditor();
|
$this->actingAsApiEditor();
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ class CommentDisplayTest extends TestCase
|
|||||||
|
|
||||||
Comment::factory()->create([
|
Comment::factory()->create([
|
||||||
'created_by' => $editor->id,
|
'created_by' => $editor->id,
|
||||||
'entity_type' => 'page',
|
'commentable_type' => 'page',
|
||||||
'entity_id' => $page->id,
|
'commentable_id' => $page->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$resp = $this->actingAs($editor)->get($page->getUrl());
|
$resp = $this->actingAs($editor)->get($page->getUrl());
|
||||||
@@ -84,7 +84,7 @@ class CommentDisplayTest extends TestCase
|
|||||||
public function test_comment_displays_relative_times()
|
public function test_comment_displays_relative_times()
|
||||||
{
|
{
|
||||||
$page = $this->entities->page();
|
$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->created_at = now()->subWeek();
|
||||||
$comment->updated_at = now()->subDay();
|
$comment->updated_at = now()->subDay();
|
||||||
$comment->save();
|
$comment->save();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class CommentStoreTest extends TestCase
|
|||||||
$this->asAdmin();
|
$this->asAdmin();
|
||||||
$page = $this->entities->page();
|
$page = $this->entities->page();
|
||||||
|
|
||||||
|
Comment::factory()->create(['commentable_id' => $page->id, 'commentable_type' => 'page', 'local_id' => 2]);
|
||||||
$comment = Comment::factory()->make(['parent_id' => 2]);
|
$comment = Comment::factory()->make(['parent_id' => 2]);
|
||||||
$resp = $this->postJson("/comment/$page->id", $comment->getAttributes());
|
$resp = $this->postJson("/comment/$page->id", $comment->getAttributes());
|
||||||
|
|
||||||
@@ -24,9 +25,9 @@ class CommentStoreTest extends TestCase
|
|||||||
$pageResp->assertSee($comment->html, false);
|
$pageResp->assertSee($comment->html, false);
|
||||||
|
|
||||||
$this->assertDatabaseHas('comments', [
|
$this->assertDatabaseHas('comments', [
|
||||||
'local_id' => 1,
|
'local_id' => 3,
|
||||||
'entity_id' => $page->id,
|
'commentable_id' => $page->id,
|
||||||
'entity_type' => Page::newModelInstance()->getMorphClass(),
|
'commentable_type' => 'page',
|
||||||
'parent_id' => 2,
|
'parent_id' => 2,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -52,9 +53,9 @@ class CommentStoreTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if ($valid) {
|
if ($valid) {
|
||||||
$this->assertDatabaseHas('comments', ['entity_id' => $page->id, 'content_ref' => $ref]);
|
$this->assertDatabaseHas('comments', ['commentable_id' => $page->id, 'content_ref' => $ref]);
|
||||||
} else {
|
} 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', [
|
$this->assertDatabaseHas('comments', [
|
||||||
'html' => $newHtml,
|
'html' => $newHtml,
|
||||||
'entity_id' => $page->id,
|
'commentable_id' => $page->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertActivityExists(ActivityType::COMMENT_UPDATE);
|
$this->assertActivityExists(ActivityType::COMMENT_UPDATE);
|
||||||
@@ -218,7 +219,7 @@ class CommentStoreTest extends TestCase
|
|||||||
$page = $this->entities->page();
|
$page = $this->entities->page();
|
||||||
Comment::factory()->create([
|
Comment::factory()->create([
|
||||||
'html' => '<script>superbadscript</script><script>superbadscript</script><p onclick="superbadonclick">scriptincommentest</p>',
|
'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());
|
$resp = $this->asAdmin()->get($page->getUrl());
|
||||||
@@ -236,8 +237,8 @@ class CommentStoreTest extends TestCase
|
|||||||
$resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]);
|
$resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]);
|
||||||
$resp->assertOk();
|
$resp->assertOk();
|
||||||
$this->assertDatabaseHas('comments', [
|
$this->assertDatabaseHas('comments', [
|
||||||
'entity_type' => 'page',
|
'commentable_type' => 'page',
|
||||||
'entity_id' => $page->id,
|
'commentable_id' => $page->id,
|
||||||
'html' => $expected,
|
'html' => $expected,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -259,8 +260,8 @@ class CommentStoreTest extends TestCase
|
|||||||
$resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]);
|
$resp = $this->asAdmin()->post("/comment/{$page->id}", ['html' => $input]);
|
||||||
$resp->assertOk();
|
$resp->assertOk();
|
||||||
$this->assertDatabaseHas('comments', [
|
$this->assertDatabaseHas('comments', [
|
||||||
'entity_type' => 'page',
|
'commentable_type' => 'page',
|
||||||
'entity_id' => $page->id,
|
'commentable_id' => $page->id,
|
||||||
'html' => $expected,
|
'html' => $expected,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ abstract class TestCase extends BaseTestCase
|
|||||||
{
|
{
|
||||||
if ($response->status() === 403 && $response instanceof JsonResponse) {
|
if ($response->status() === 403 && $response instanceof JsonResponse) {
|
||||||
$errMessage = $response->getData(true)['error']['message'] ?? '';
|
$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
|
return $response->status() === 302
|
||||||
|
|||||||
Reference in New Issue
Block a user