Compare commits

..

1 Commits

Author SHA1 Message Date
Dan Brown
9d1c0e5dda Dev: Played with an all-in-one docker environment 2025-10-06 13:06:12 +01:00
232 changed files with 1473 additions and 4507 deletions

View File

@@ -26,13 +26,6 @@ DB_DATABASE=database_database
DB_USERNAME=database_username DB_USERNAME=database_username
DB_PASSWORD=database_user_password DB_PASSWORD=database_user_password
# Storage system to use
# By default files are stored on the local filesystem, with images being placed in
# public web space so they can be efficiently served directly by the web-server.
# For other options with different security levels & considerations, refer to:
# https://www.bookstackapp.com/docs/admin/upload-config/
STORAGE_TYPE=local
# Mail system to use # Mail system to use
# Can be 'smtp' or 'sendmail' # Can be 'smtp' or 'sendmail'
MAIL_DRIVER=smtp MAIL_DRIVER=smtp

View File

@@ -177,7 +177,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish Michał Stelmach (stelmach-web) :: Polish
arniom :: French arniom :: French
REMOVED_USER :: French; German; Dutch; Portuguese, Brazilian; Portuguese; Turkish; REMOVED_USER :: French; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
林祖年 (contagion) :: Chinese Traditional 林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@@ -222,7 +222,7 @@ SmokingCrop :: Dutch
Maciej Lebiest (Szwendacz) :: Polish Maciej Lebiest (Szwendacz) :: Polish
DiscordDigital :: German; German Informal DiscordDigital :: German; German Informal
Gábor Marton (dodver) :: Hungarian Gábor Marton (dodver) :: Hungarian
Jakob Åsell (Jasell) :: Swedish Jasell :: Swedish
Ghost_chu (ghostchu) :: Chinese Simplified Ghost_chu (ghostchu) :: Chinese Simplified
Ravid Shachar (ravidshachar) :: Hebrew Ravid Shachar (ravidshachar) :: Hebrew
Helga Guchshenskaya (guchshenskaya) :: Russian Helga Guchshenskaya (guchshenskaya) :: Russian
@@ -509,6 +509,3 @@ iamwhoiamwhoami :: Swedish
Grogui :: French Grogui :: French
MrCharlesIII :: Arabic MrCharlesIII :: Arabic
David Olsen (dawin) :: Danish David Olsen (dawin) :: Danish
ltnzr :: French
Frank Holler (holler.frank) :: German; German Informal
Korab Arifi (korabidev) :: Albanian

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
strategy: strategy:
matrix: matrix:
php: ['8.2', '8.3', '8.4', '8.5'] php: ['8.2', '8.3', '8.4']
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
strategy: strategy:
matrix: matrix:
php: ['8.2', '8.3', '8.4', '8.5'] php: ['8.2', '8.3', '8.4']
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

6
.gitignore vendored
View File

@@ -8,10 +8,10 @@ Homestead.yaml
.idea .idea
npm-debug.log npm-debug.log
yarn-error.log yarn-error.log
/public/dist/*.map /public/dist
/public/plugins /public/plugins
/public/css/*.map /public/css
/public/js/*.map /public/js
/public/bower /public/bower
/public/build/ /public/build/
/public/favicon.ico /public/favicon.ico

View File

@@ -11,6 +11,7 @@ class MfaSession
*/ */
public function isRequiredForUser(User $user): bool public function isRequiredForUser(User $user): bool
{ {
// TODO - Test both these cases
return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user); return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
} }

View File

@@ -4,7 +4,6 @@ namespace BookStack\Access\Mfa;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** /**
@@ -17,8 +16,6 @@ use Illuminate\Database\Eloquent\Model;
*/ */
class MfaValue extends Model class MfaValue extends Model
{ {
use HasFactory;
protected static $unguarded = true; protected static $unguarded = true;
const METHOD_TOTP = 'totp'; const METHOD_TOTP = 'totp';

View File

@@ -5,23 +5,18 @@ namespace BookStack\Access;
use BookStack\Activity\Models\Loggable; use BookStack\Activity\Models\Loggable;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
* Class SocialAccount.
*
* @property string $driver * @property string $driver
* @property User $user * @property User $user
*/ */
class SocialAccount extends Model implements Loggable class SocialAccount extends Model implements Loggable
{ {
use HasFactory; protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps'];
protected $fillable = ['user_id', 'driver', 'driver_id']; public function user()
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }

View File

@@ -4,11 +4,10 @@ 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
{ {
@@ -20,46 +19,11 @@ 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();
@@ -74,7 +38,6 @@ 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;
} }
@@ -96,7 +59,7 @@ class CommentRepo
/** /**
* Archive an existing comment. * Archive an existing comment.
*/ */
public function archive(Comment $comment, bool $log = true): Comment public function archive(Comment $comment): 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);
@@ -105,9 +68,7 @@ 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;
} }
@@ -115,7 +76,7 @@ class CommentRepo
/** /**
* Un-archive an existing comment. * Un-archive an existing comment.
*/ */
public function unarchive(Comment $comment, bool $log = true): Comment public function unarchive(Comment $comment): 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);
@@ -124,9 +85,7 @@ class CommentRepo
$comment->archived = false; $comment->archived = false;
$comment->save(); $comment->save();
if ($log) { ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
}
return $comment; return $comment;
} }

View File

@@ -1,148 +0,0 @@
<?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. * Save a new comment for a Page.
* *
* @throws ValidationException|\Exception * @throws ValidationException
*/ */
public function savePageComment(Request $request, int $pageId) public function savePageComment(Request $request, int $pageId)
{ {
@@ -37,6 +37,11 @@ class CommentController extends Controller
return response('Not found', 404); return response('Not found', 404);
} }
// Prevent adding comments to draft pages
if ($page->draft) {
return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400);
}
// Create a new comment. // Create a new comment.
$this->checkPermission(Permission::CommentCreateAll); $this->checkPermission(Permission::CommentCreateAll);
$contentRef = $input['content_ref'] ?? ''; $contentRef = $input['content_ref'] ?? '';

View File

@@ -6,7 +6,6 @@ use BookStack\App\Model;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\Models\JointPermission;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
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\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -25,8 +24,6 @@ use Illuminate\Support\Str;
*/ */
class Activity extends Model class Activity extends Model
{ {
use HasFactory;
/** /**
* Get the loggable model related to this activity. * Get the loggable model related to this activity.
* Currently only used for entities (previously entity_[id/type] columns). * Currently only used for entities (previously entity_[id/type] columns).

View File

@@ -3,24 +3,22 @@
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 $commentable_type * @property string $entity_type
* @property int $commentable_id * @property int $entity_id
* @property string $content_ref * @property string $content_ref
* @property bool $archived * @property bool $archived
*/ */
@@ -30,30 +28,13 @@ 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
{ {
// We specifically define null here to avoid the different name (commentable) return $this->morphTo('entity');
// being used by Laravel eager loading instead of the method name, which it was doing
// in some scenarios like when deserialized when going through the queue system.
// So we instead specify the type and id column names to use.
// Related to:
// https://github.com/laravel/framework/pull/24815
// https://github.com/laravel/framework/issues/27342
// https://github.com/laravel/framework/issues/47953
// (and probably more)
// Ultimately, we could just align the method name to 'commentable' but that would be a potential
// breaking change and not really worthwhile in a patch due to the risk of creating extra problems.
return $this->morphTo(null, 'commentable_type', 'commentable_id');
} }
/** /**
@@ -63,8 +44,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('commentable_type', '=', $this->commentable_type) ->where('entity_type', '=', $this->entity_type)
->where('commentable_id', '=', $this->commentable_id); ->where('entity_id', '=', $this->entity_id);
} }
/** /**
@@ -77,27 +58,11 @@ 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->commentable_type} (ID: {$this->commentable_id})"; return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_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');
}
} }

View File

@@ -4,14 +4,11 @@ namespace BookStack\Activity\Models;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\Models\JointPermission;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
class Favourite extends Model class Favourite extends Model
{ {
use HasFactory;
protected $fillable = ['user_id']; protected $fillable = ['user_id'];
/** /**

View File

@@ -5,7 +5,6 @@ namespace BookStack\Activity\Models;
use BookStack\Activity\WatchLevels; use BookStack\Activity\WatchLevels;
use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\Models\JointPermission;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -21,8 +20,6 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
*/ */
class Watch extends Model class Watch extends Model
{ {
use HasFactory;
protected $guarded = []; protected $guarded = [];
public function watchable(): MorphTo public function watchable(): MorphTo

View File

@@ -20,7 +20,6 @@ abstract class BaseNotificationHandler implements NotificationHandler
{ {
$users = User::query()->whereIn('id', array_unique($userIds))->get(); $users = User::query()->whereIn('id', array_unique($userIds))->get();
/** @var User $user */
foreach ($users as $user) { foreach ($users as $user) {
// Prevent sending to the user that initiated the activity // Prevent sending to the user that initiated the activity
if ($user->id === $initiator->id) { if ($user->id === $initiator->id) {

View File

@@ -27,7 +27,7 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
$watcherIds = $watchers->getWatcherUserIds(); $watcherIds = $watchers->getWatcherUserIds();
// Page owner if user preferences allow // Page owner if user preferences allow
if ($page->owned_by && !$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) { if (!$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
$userNotificationPrefs = new UserNotificationPreferences($page->ownedBy); $userNotificationPrefs = new UserNotificationPreferences($page->ownedBy);
if ($userNotificationPrefs->notifyOnOwnPageComments()) { if ($userNotificationPrefs->notifyOnOwnPageComments()) {
$watcherIds[] = $page->owned_by; $watcherIds[] = $page->owned_by;
@@ -36,7 +36,7 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
// Parent comment creator if preferences allow // Parent comment creator if preferences allow
$parentComment = $detail->parent()->first(); $parentComment = $detail->parent()->first();
if ($parentComment && $parentComment->created_by && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) { if ($parentComment && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
$parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy); $parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy);
if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) { if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) {
$watcherIds[] = $parentComment->created_by; $watcherIds[] = $parentComment->created_by;

View File

@@ -39,8 +39,8 @@ class PageUpdateNotificationHandler extends BaseNotificationHandler
$watchers = new EntityWatchers($detail, WatchLevels::UPDATES); $watchers = new EntityWatchers($detail, WatchLevels::UPDATES);
$watcherIds = $watchers->getWatcherUserIds(); $watcherIds = $watchers->getWatcherUserIds();
// Add the page owner if preferences allow // Add page owner if preferences allow
if ($detail->owned_by && !$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) { if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy); $userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
if ($userNotificationPrefs->notifyOnOwnPageChanges()) { if ($userNotificationPrefs->notifyOnOwnPageChanges()) {
$watcherIds[] = $detail->owned_by; $watcherIds[] = $detail->owned_by;

View File

@@ -13,11 +13,6 @@ 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(
@@ -44,7 +39,7 @@ class CommentTree
public function getActive(): array public function getActive(): array
{ {
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived)); return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived);
} }
public function activeThreadCount(): int public function activeThreadCount(): int
@@ -54,7 +49,7 @@ class CommentTree
public function getArchived(): array public function getArchived(): array
{ {
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived)); return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived);
} }
public function archivedThreadCount(): int public function archivedThreadCount(): int
@@ -84,14 +79,6 @@ 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[]
@@ -136,9 +123,6 @@ 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()) {

View File

@@ -83,19 +83,11 @@ 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->parseDescriptionFromDocBlockComment($comment) : null; $route['description'] = $comment ? $this->parseDescriptionFromMethodComment($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;
}); });
} }
@@ -148,7 +140,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 parseDescriptionFromDocBlockComment(string $comment): string protected function parseDescriptionFromMethodComment(string $comment): string
{ {
$matches = []; $matches = [];
preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches); preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches);
@@ -163,16 +155,6 @@ 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) {
@@ -180,7 +162,7 @@ class ApiDocsGenerator
$this->reflectionClasses[$className] = $class; $this->reflectionClasses[$className] = $class;
} }
return $class; return $class->getMethod($methodName);
} }
/** /**

View File

@@ -11,7 +11,7 @@
return [ return [
// Default Filesystem Disk // Default Filesystem Disk
// Options: local, local_secure, local_secure_restricted, s3 // Options: local, local_secure, s3
'default' => env('STORAGE_TYPE', 'local'), 'default' => env('STORAGE_TYPE', 'local'),
// Filesystem to use specifically for image uploads. // Filesystem to use specifically for image uploads.

View File

@@ -45,8 +45,10 @@ class UpdateUrlCommand extends Command
$columnsToUpdateByTable = [ $columnsToUpdateByTable = [
'attachments' => ['path'], 'attachments' => ['path'],
'entity_page_data' => ['html', 'text', 'markdown'], 'pages' => ['html', 'text', 'markdown'],
'entity_container_data' => ['description_html'], 'chapters' => ['description_html'],
'books' => ['description_html'],
'bookshelves' => ['description_html'],
'page_revisions' => ['html', 'text', 'markdown'], 'page_revisions' => ['html', 'text', 'markdown'],
'images' => ['url'], 'images' => ['url'],
'settings' => ['value'], 'settings' => ['value'],

View File

@@ -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 a 'content' property listing the chapter and pages directly within, in * The response data will contain '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.
*/ */
@@ -122,10 +122,9 @@ class BookApiController extends ApiController
$book = clone $book; $book = clone $book;
$book->unsetRelations()->refresh(); $book->unsetRelations()->refresh();
$book->load(['tags']); $book->load(['tags', 'cover']);
$book->makeVisible(['cover', 'description_html']) $book->makeVisible('description_html')
->setAttribute('description_html', $book->descriptionInfo()->getHtml()) ->setAttribute('description_html', $book->descriptionHtml());
->setAttribute('cover', $book->coverInfo()->getImage());
return $book; return $book;
} }

View File

@@ -116,10 +116,9 @@ class BookshelfApiController extends ApiController
$shelf = clone $shelf; $shelf = clone $shelf;
$shelf->unsetRelations()->refresh(); $shelf->unsetRelations()->refresh();
$shelf->load(['tags']); $shelf->load(['tags', 'cover']);
$shelf->makeVisible(['cover', 'description_html']) $shelf->makeVisible('description_html')
->setAttribute('description_html', $shelf->descriptionInfo()->getHtml()) ->setAttribute('description_html', $shelf->descriptionHtml());
->setAttribute('cover', $shelf->coverInfo()->getImage());
return $shelf; return $shelf;
} }

View File

@@ -116,7 +116,6 @@ class BookshelfController extends Controller
]); ]);
$sort = $listOptions->getSort(); $sort = $listOptions->getSort();
$sortedVisibleShelfBooks = $shelf->visibleBooks() $sortedVisibleShelfBooks = $shelf->visibleBooks()
->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder()) ->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder())
->get() ->get()

View File

@@ -104,7 +104,7 @@ class ChapterApiController extends ApiController
$chapter = $this->queries->findVisibleByIdOrFail(intval($id)); $chapter = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
if ($request->has('book_id') && $chapter->book_id !== (intval($requestData['book_id']) ?: null)) { if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) {
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter); $this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
try { try {
@@ -144,7 +144,7 @@ class ChapterApiController extends ApiController
$chapter->load(['tags']); $chapter->load(['tags']);
$chapter->makeVisible('description_html'); $chapter->makeVisible('description_html');
$chapter->setAttribute('description_html', $chapter->descriptionInfo()->getHtml()); $chapter->setAttribute('description_html', $chapter->descriptionHtml());
/** @var Book $book */ /** @var Book $book */
$book = $chapter->book()->first(); $book = $chapter->book()->first();

View File

@@ -130,7 +130,7 @@ class ChapterController extends Controller
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
$chapter = $this->chapterRepo->update($chapter, $validated); $this->chapterRepo->update($chapter, $validated);
return redirect($chapter->getUrl()); return redirect($chapter->getUrl());
} }

View File

@@ -2,7 +2,6 @@
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;
@@ -89,32 +88,21 @@ 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 and escaped HTML content that BookStack * The 'html' property is the fully rendered & 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);
$page = $page->forJsonDisplay(); return response()->json($page->forJsonDisplay());
$commentTree = (new CommentTree($page));
$commentTree->loadVisibleHtml();
$page->setAttribute('comments', [
'active' => $commentTree->getActive(),
'archived' => $commentTree->getArchived(),
]);
return response()->json($page);
} }
/** /**

View File

@@ -120,7 +120,6 @@ class PageController extends Controller
$this->validate($request, [ $this->validate($request, [
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
]); ]);
$draftPage = $this->queries->findVisibleByIdOrFail($pageId); $draftPage = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent()); $this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent());

View File

@@ -1,20 +0,0 @@
<?php
namespace BookStack\Entities;
use Illuminate\Validation\Rules\Exists;
class EntityExistsRule implements \Stringable
{
public function __construct(
protected string $type,
) {
}
public function __toString()
{
$existsRule = (new Exists('entities', 'id'))
->where('type', $this->type);
return $existsRule->__toString();
}
}

View File

@@ -2,10 +2,9 @@
namespace BookStack\Entities\Models; namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityCover;
use BookStack\Entities\Tools\EntityDefaultTemplate;
use BookStack\Sorting\SortRule; use BookStack\Sorting\SortRule;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use Exception;
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\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -16,25 +15,26 @@ use Illuminate\Support\Collection;
* Class Book. * Class Book.
* *
* @property string $description * @property string $description
* @property string $description_html
* @property int $image_id * @property int $image_id
* @property ?int $default_template_id * @property ?int $default_template_id
* @property ?int $sort_rule_id * @property ?int $sort_rule_id
* @property Image|null $cover
* @property \Illuminate\Database\Eloquent\Collection $chapters * @property \Illuminate\Database\Eloquent\Collection $chapters
* @property \Illuminate\Database\Eloquent\Collection $pages * @property \Illuminate\Database\Eloquent\Collection $pages
* @property \Illuminate\Database\Eloquent\Collection $directPages * @property \Illuminate\Database\Eloquent\Collection $directPages
* @property \Illuminate\Database\Eloquent\Collection $shelves * @property \Illuminate\Database\Eloquent\Collection $shelves
* @property ?SortRule $sortRule * @property ?Page $defaultTemplate
* @property ?SortRule $sortRule
*/ */
class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterface
{ {
use HasFactory; use HasFactory;
use ContainerTrait; use HtmlDescriptionTrait;
public float $searchFactor = 1.2; public float $searchFactor = 1.2;
protected $hidden = ['pivot', 'deleted_at', 'description_html', 'entity_id', 'entity_type', 'chapter_id', 'book_id', 'priority'];
protected $fillable = ['name']; protected $fillable = ['name'];
protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html'];
/** /**
* Get the url for this book. * Get the url for this book.
@@ -44,6 +44,55 @@ class Book extends Entity implements HasDescriptionInterface, HasCoverInterface,
return url('/books/' . implode('/', [urlencode($this->slug), trim($path, '/')])); return url('/books/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
} }
/**
* Returns book cover image, if book cover not exists return default cover image.
*/
public function getBookCover(int $width = 440, int $height = 250): string
{
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
if (!$this->image_id || !$this->cover) {
return $default;
}
try {
return $this->cover->getThumb($width, $height, false) ?? $default;
} catch (Exception $err) {
return $default;
}
}
/**
* Get the cover image of the book.
*/
public function cover(): BelongsTo
{
return $this->belongsTo(Image::class, 'image_id');
}
/**
* Get the type of the image model that is used when storing a cover image.
*/
public function coverImageTypeKey(): string
{
return 'cover_book';
}
/**
* Get the Page that is used as default template for newly created pages within this Book.
*/
public function defaultTemplate(): BelongsTo
{
return $this->belongsTo(Page::class, 'default_template_id');
}
/**
* Get the sort set assigned to this book, if existing.
*/
public function sortRule(): BelongsTo
{
return $this->belongsTo(SortRule::class);
}
/** /**
* Get all pages within this book. * Get all pages within this book.
* @return HasMany<Page, $this> * @return HasMany<Page, $this>
@@ -58,7 +107,7 @@ class Book extends Entity implements HasDescriptionInterface, HasCoverInterface,
*/ */
public function directPages(): HasMany public function directPages(): HasMany
{ {
return $this->pages()->whereNull('chapter_id'); return $this->pages()->where('chapter_id', '=', '0');
} }
/** /**
@@ -88,27 +137,4 @@ class Book extends Entity implements HasDescriptionInterface, HasCoverInterface,
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft'); return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
} }
public function defaultTemplate(): EntityDefaultTemplate
{
return new EntityDefaultTemplate($this);
}
public function cover(): BelongsTo
{
return $this->belongsTo(Image::class, 'image_id');
}
public function coverInfo(): EntityCover
{
return new EntityCover($this);
}
/**
* Get the sort rule assigned to this container, if existing.
*/
public function sortRule(): BelongsTo
{
return $this->belongsTo(SortRule::class);
}
} }

View File

@@ -3,6 +3,7 @@
namespace BookStack\Entities\Models; namespace BookStack\Entities\Models;
use BookStack\References\ReferenceUpdater; use BookStack\References\ReferenceUpdater;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
@@ -26,13 +27,13 @@ abstract class BookChild extends Entity
/** /**
* Change the book that this entity belongs to. * Change the book that this entity belongs to.
*/ */
public function changeBook(int $newBookId): self public function changeBook(int $newBookId): Entity
{ {
$oldUrl = $this->getUrl(); $oldUrl = $this->getUrl();
$this->book_id = $newBookId; $this->book_id = $newBookId;
$this->unsetRelation('book');
$this->refreshSlug(); $this->refreshSlug();
$this->save(); $this->save();
$this->refresh();
if ($oldUrl !== $this->getUrl()) { if ($oldUrl !== $this->getUrl()) {
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl); app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);

View File

@@ -2,34 +2,34 @@
namespace BookStack\Entities\Models; namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityCover;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use Exception;
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\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
/** class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionInterface
* @property string $description
* @property string $description_html
*/
class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInterface
{ {
use HasFactory; use HasFactory;
use ContainerTrait; use HtmlDescriptionTrait;
protected $table = 'bookshelves';
public float $searchFactor = 1.2; public float $searchFactor = 1.2;
protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id']; protected $fillable = ['name', 'description', 'image_id'];
protected $fillable = ['name'];
protected $hidden = ['image_id', 'deleted_at', 'description_html'];
/** /**
* Get the books in this shelf. * Get the books in this shelf.
* Should not be used directly since it does not take into account permissions. * Should not be used directly since does not take into account permissions.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/ */
public function books(): BelongsToMany public function books()
{ {
return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id') return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id')
->select(['entities.*', 'entity_container_data.*'])
->withPivot('order') ->withPivot('order')
->orderBy('order', 'asc'); ->orderBy('order', 'asc');
} }
@@ -50,6 +50,41 @@ class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInter
return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')])); return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')]));
} }
/**
* Returns shelf cover image, if cover not exists return default cover image.
*/
public function getBookCover(int $width = 440, int $height = 250): string
{
// TODO - Make generic, focused on books right now, Perhaps set-up a better image
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
if (!$this->image_id || !$this->cover) {
return $default;
}
try {
return $this->cover->getThumb($width, $height, false) ?? $default;
} catch (Exception $err) {
return $default;
}
}
/**
* Get the cover image of the shelf.
* @return BelongsTo<Image, $this>
*/
public function cover(): BelongsTo
{
return $this->belongsTo(Image::class, 'image_id');
}
/**
* Get the type of the image model that is used when storing a cover image.
*/
public function coverImageTypeKey(): string
{
return 'cover_bookshelf';
}
/** /**
* Check if this shelf contains the given book. * Check if this shelf contains the given book.
*/ */
@@ -61,7 +96,7 @@ class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInter
/** /**
* Add a book to the end of this shelf. * Add a book to the end of this shelf.
*/ */
public function appendBook(Book $book): void public function appendBook(Book $book)
{ {
if ($this->contains($book)) { if ($this->contains($book)) {
return; return;
@@ -71,13 +106,12 @@ class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInter
$this->books()->attach($book->id, ['order' => $maxOrder + 1]); $this->books()->attach($book->id, ['order' => $maxOrder + 1]);
} }
public function coverInfo(): EntityCover /**
* Get a visible shelf by its slug.
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public static function getBySlug(string $slug): self
{ {
return new EntityCover($this); return static::visible()->where('slug', '=', $slug)->firstOrFail();
}
public function cover(): BelongsTo
{
return $this->belongsTo(Image::class, 'image_id');
} }
} }

View File

@@ -2,25 +2,27 @@
namespace BookStack\Entities\Models; namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityDefaultTemplate; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
/** /**
* Class Chapter.
*
* @property Collection<Page> $pages * @property Collection<Page> $pages
* @property ?int $default_template_id * @property ?int $default_template_id
* @property string $description * @property ?Page $defaultTemplate
* @property string $description_html
*/ */
class Chapter extends BookChild implements HasDescriptionInterface, HasDefaultTemplateInterface class Chapter extends BookChild implements HtmlDescriptionInterface
{ {
use HasFactory; use HasFactory;
use ContainerTrait; use HtmlDescriptionTrait;
public float $searchFactor = 1.2; public float $searchFactor = 1.2;
protected $hidden = ['pivot', 'deleted_at', 'description_html', 'sort_rule_id', 'image_id', 'entity_id', 'entity_type', 'chapter_id'];
protected $fillable = ['name', 'priority']; protected $fillable = ['name', 'description', 'priority'];
protected $hidden = ['pivot', 'deleted_at', 'description_html'];
/** /**
* Get the pages that this chapter contains. * Get the pages that this chapter contains.
@@ -48,6 +50,14 @@ class Chapter extends BookChild implements HasDescriptionInterface, HasDefaultTe
return url('/' . implode('/', $parts)); return url('/' . implode('/', $parts));
} }
/**
* Get the Page that is used as default template for newly created pages within this Chapter.
*/
public function defaultTemplate(): BelongsTo
{
return $this->belongsTo(Page::class, 'default_template_id');
}
/** /**
* Get the visible pages in this chapter. * Get the visible pages in this chapter.
* @return Collection<Page> * @return Collection<Page>
@@ -60,9 +70,4 @@ class Chapter extends BookChild implements HasDescriptionInterface, HasDefaultTe
->orderBy('priority', 'asc') ->orderBy('priority', 'asc')
->get(); ->get();
} }
public function defaultTemplate(): EntityDefaultTemplate
{
return new EntityDefaultTemplate($this);
}
} }

View File

@@ -1,26 +0,0 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityHtmlDescription;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* @mixin Entity
*/
trait ContainerTrait
{
public function descriptionInfo(): EntityHtmlDescription
{
return new EntityHtmlDescription($this);
}
/**
* @return HasOne<EntityContainerData, $this>
*/
public function relatedData(): HasOne
{
return $this->hasOne(EntityContainerData::class, 'entity_id', 'id')
->where('entity_type', '=', $this->getMorphClass());
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
interface CoverImageInterface
{
/**
* Get the cover image for this item.
*/
public function cover(): BelongsTo;
/**
* Get the type of the image model that is used when storing a cover image.
*/
public function coverImageTypeKey(): string;
}

View File

@@ -4,7 +4,6 @@ namespace BookStack\Entities\Models;
use BookStack\Activity\Models\Loggable; use BookStack\Activity\Models\Loggable;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -18,8 +17,6 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
*/ */
class Deletion extends Model implements Loggable class Deletion extends Model implements Loggable
{ {
use HasFactory;
protected $hidden = []; protected $hidden = [];
/** /**

View File

@@ -28,25 +28,23 @@ use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
/** /**
* Class Entity * Class Entity
* The base class for book-like items such as pages, chapters and books. * The base class for book-like items such as pages, chapters & books.
* This is not a database model in itself but extended. * This is not a database model in itself but extended.
* *
* @property int $id * @property int $id
* @property string $type
* @property string $name * @property string $name
* @property string $slug * @property string $slug
* @property Carbon $created_at * @property Carbon $created_at
* @property Carbon $updated_at * @property Carbon $updated_at
* @property Carbon $deleted_at * @property Carbon $deleted_at
* @property int|null $created_by * @property int $created_by
* @property int|null $updated_by * @property int $updated_by
* @property int|null $owned_by * @property int $owned_by
* @property Collection $tags * @property Collection $tags
* *
* @method static Entity|Builder visible() * @method static Entity|Builder visible()
@@ -79,72 +77,6 @@ abstract class Entity extends Model implements
*/ */
public float $searchFactor = 1.0; public float $searchFactor = 1.0;
/**
* Set the table to be that used by all entities.
*/
protected $table = 'entities';
/**
* Set a custom query builder for entities.
*/
protected static string $builder = EntityQueryBuilder::class;
public static array $commonFields = [
'id',
'type',
'name',
'slug',
'book_id',
'chapter_id',
'priority',
'created_at',
'updated_at',
'deleted_at',
'created_by',
'updated_by',
'owned_by',
];
/**
* Override the save method to also save the contents for convenience.
*/
public function save(array $options = []): bool
{
/** @var EntityPageData|EntityContainerData $contents */
$contents = $this->relatedData()->firstOrNew();
$contentFields = $this->getContentsAttributes();
foreach ($contentFields as $key => $value) {
$contents->setAttribute($key, $value);
unset($this->attributes[$key]);
}
$this->setAttribute('type', $this->getMorphClass());
$result = parent::save($options);
$contentsResult = true;
if ($result && $contents->isDirty()) {
$contentsFillData = $contents instanceof EntityPageData ? ['page_id' => $this->id] : ['entity_id' => $this->id, 'entity_type' => $this->getMorphClass()];
$contents->forceFill($contentsFillData);
$contentsResult = $contents->save();
$this->touch();
}
$this->forceFill($contentFields);
return $result && $contentsResult;
}
/**
* Check if this item is a container item.
*/
public function isContainer(): bool
{
return $this instanceof Bookshelf ||
$this instanceof Book ||
$this instanceof Chapter;
}
/** /**
* Get the entities that are visible to the current user. * Get the entities that are visible to the current user.
*/ */
@@ -159,8 +91,8 @@ abstract class Entity extends Model implements
public function scopeWithLastView(Builder $query) public function scopeWithLastView(Builder $query)
{ {
$viewedAtQuery = View::query()->select('updated_at') $viewedAtQuery = View::query()->select('updated_at')
->whereColumn('viewable_id', '=', 'entities.id') ->whereColumn('viewable_id', '=', $this->getTable() . '.id')
->whereColumn('viewable_type', '=', 'entities.type') ->where('viewable_type', '=', $this->getMorphClass())
->where('user_id', '=', user()->id) ->where('user_id', '=', user()->id)
->take(1); ->take(1);
@@ -170,12 +102,11 @@ abstract class Entity extends Model implements
/** /**
* Query scope to get the total view count of the entities. * Query scope to get the total view count of the entities.
*/ */
public function scopeWithViewCount(Builder $query): void public function scopeWithViewCount(Builder $query)
{ {
$viewCountQuery = View::query()->selectRaw('SUM(views) as view_count') $viewCountQuery = View::query()->selectRaw('SUM(views) as view_count')
->whereColumn('viewable_id', '=', 'entities.id') ->whereColumn('viewable_id', '=', $this->getTable() . '.id')
->whereColumn('viewable_type', '=', 'entities.type') ->where('viewable_type', '=', $this->getMorphClass())->take(1);
->take(1);
$query->addSelect(['view_count' => $viewCountQuery]); $query->addSelect(['view_count' => $viewCountQuery]);
} }
@@ -231,17 +162,15 @@ abstract class Entity extends Model implements
*/ */
public function tags(): MorphMany public function tags(): MorphMany
{ {
return $this->morphMany(Tag::class, 'entity') return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
->orderBy('order', 'asc');
} }
/** /**
* 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, 'commentable'); $query = $this->morphMany(Comment::class, 'entity');
return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query; return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
} }
@@ -255,7 +184,7 @@ abstract class Entity extends Model implements
} }
/** /**
* Get this entities assigned permissions. * Get this entities restrictions.
*/ */
public function permissions(): MorphMany public function permissions(): MorphMany
{ {
@@ -338,7 +267,7 @@ abstract class Entity extends Model implements
} }
/** /**
* Gets a limited-length version of the entity name. * Gets a limited-length version of the entities name.
*/ */
public function getShortName(int $length = 25): string public function getShortName(int $length = 25): string
{ {
@@ -448,40 +377,4 @@ abstract class Entity extends Model implements
{ {
return "({$this->id}) {$this->name}"; return "({$this->id}) {$this->name}";
} }
/**
* @return HasOne<covariant (EntityContainerData|EntityPageData), $this>
*/
abstract public function relatedData(): HasOne;
/**
* Get the attributes that are intended for the related contents model.
* @return array<string, mixed>
*/
protected function getContentsAttributes(): array
{
$contentFields = [];
$contentModel = $this instanceof Page ? EntityPageData::class : EntityContainerData::class;
foreach ($this->attributes as $key => $value) {
if (in_array($key, $contentModel::$fields)) {
$contentFields[$key] = $value;
}
}
return $contentFields;
}
/**
* Create a new instance for the given entity type.
*/
public static function instanceFromType(string $type): self
{
return match ($type) {
'page' => new Page(),
'chapter' => new Chapter(),
'book' => new Book(),
'bookshelf' => new Bookshelf(),
};
}
} }

View File

@@ -1,52 +0,0 @@
<?php
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $entity_id
* @property string $entity_type
* @property string $description
* @property string $description_html
* @property ?int $default_template_id
* @property ?int $image_id
* @property ?int $sort_rule_id
*/
class EntityContainerData extends Model
{
public $timestamps = false;
protected $primaryKey = 'entity_id';
public $incrementing = false;
public static array $fields = [
'description',
'description_html',
'default_template_id',
'image_id',
'sort_rule_id',
];
/**
* Override the default set keys for save query method to make it work with composite keys.
*/
public function setKeysForSaveQuery($query): Builder
{
$query->where($this->getKeyName(), '=', $this->getKeyForSaveQuery())
->where('entity_type', '=', $this->entity_type);
return $query;
}
/**
* Override the default set keys for a select query method to make it work with composite keys.
*/
protected function setKeysForSelectQuery($query): Builder
{
$query->where($this->getKeyName(), '=', $this->getKeyForSelectQuery())
->where('entity_type', '=', $this->entity_type);
return $query;
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $page_id
*/
class EntityPageData extends Model
{
public $timestamps = false;
protected $primaryKey = 'page_id';
public $incrementing = false;
public static array $fields = [
'draft',
'template',
'revision_count',
'editor',
'html',
'text',
'markdown',
];
}

View File

@@ -1,38 +0,0 @@
<?php
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
class EntityQueryBuilder extends Builder
{
/**
* Create a new Eloquent query builder instance.
*/
public function __construct(QueryBuilder $query)
{
parent::__construct($query);
$this->withGlobalScope('entity', new EntityScope());
}
public function withoutGlobalScope($scope): static
{
// Prevent removal of the entity scope
if ($scope === 'entity') {
return $this;
}
return parent::withoutGlobalScope($scope);
}
/**
* Override the default forceDelete method to add type filter onto the query
* since it specifically ignores scopes by default.
*/
public function forceDelete()
{
return $this->query->where('type', '=', $this->model->getMorphClass())->delete();
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Database\Query\JoinClause;
class EntityScope implements Scope
{
/**
* Apply the scope to a given Eloquent query builder.
*/
public function apply(Builder $builder, Model $model): void
{
$builder = $builder->where('type', '=', $model->getMorphClass());
$table = $model->getTable();
if ($model instanceof Page) {
$builder->leftJoin('entity_page_data', 'entity_page_data.page_id', '=', "{$table}.id");
} else {
$builder->leftJoin('entity_container_data', function (JoinClause $join) use ($model, $table) {
$join->on('entity_container_data.entity_id', '=', "{$table}.id")
->where('entity_container_data.entity_type', '=', $model->getMorphClass());
});
}
}
}

View File

@@ -1,69 +0,0 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\Activity\Models\Tag;
use BookStack\Activity\Models\View;
use BookStack\App\Model;
use BookStack\Permissions\Models\EntityPermission;
use BookStack\Permissions\Models\JointPermission;
use BookStack\Permissions\PermissionApplicator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* This is a simplistic model interpretation of a generic Entity used to query and represent
* that database abstractly. Generally, this should rarely be used outside queries.
*/
class EntityTable extends Model
{
use SoftDeletes;
protected $table = 'entities';
/**
* Get the entities that are visible to the current user.
*/
public function scopeVisible(Builder $query): Builder
{
return app()->make(PermissionApplicator::class)->restrictEntityQuery($query);
}
/**
* Get the entity jointPermissions this is connected to.
*/
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id')
->whereColumn('entity_type', '=', 'entities.type');
}
/**
* Get the Tags that have been assigned to entities.
*/
public function tags(): HasMany
{
return $this->hasMany(Tag::class, 'entity_id')
->whereColumn('entity_type', '=', 'entities.type');
}
/**
* Get the assigned permissions.
*/
public function permissions(): HasMany
{
return $this->hasMany(EntityPermission::class, 'entity_id')
->whereColumn('entity_type', '=', 'entities.type');
}
/**
* Get View objects for this entity.
*/
public function views(): HasMany
{
return $this->hasMany(View::class, 'viewable_id')
->whereColumn('viewable_type', '=', 'entities.type');
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityCover;
use BookStack\Uploads\Image;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
interface HasCoverInterface
{
public function coverInfo(): EntityCover;
/**
* The cover image of this entity.
* @return BelongsTo<Image, covariant Entity>
*/
public function cover(): BelongsTo;
}

View File

@@ -1,10 +0,0 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityDefaultTemplate;
interface HasDefaultTemplateInterface
{
public function defaultTemplate(): EntityDefaultTemplate;
}

View File

@@ -1,10 +0,0 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityHtmlDescription;
interface HasDescriptionInterface
{
public function descriptionInfo(): EntityHtmlDescription;
}

View File

@@ -0,0 +1,17 @@
<?php
namespace BookStack\Entities\Models;
interface HtmlDescriptionInterface
{
/**
* Get the HTML-based description for this item.
* By default, the content should be sanitised unless raw is set to true.
*/
public function descriptionHtml(bool $raw = false): string;
/**
* Set the HTML-based description for this item.
*/
public function setDescriptionHtml(string $html, string|null $plaintext = null): void;
}

View File

@@ -0,0 +1,35 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\Util\HtmlContentFilter;
/**
* @property string $description
* @property string $description_html
*/
trait HtmlDescriptionTrait
{
public function descriptionHtml(bool $raw = false): string
{
$html = $this->description_html ?: '<p>' . nl2br(e($this->description)) . '</p>';
if ($raw) {
return $html;
}
return HtmlContentFilter::removeScriptsFromHtmlString($html);
}
public function setDescriptionHtml(string $html, string|null $plaintext = null): void
{
$this->description_html = $html;
if ($plaintext !== null) {
$this->description = $plaintext;
}
if (empty($html) && !empty($plaintext)) {
$this->description_html = $this->descriptionHtml();
}
}
}

View File

@@ -3,6 +3,7 @@
namespace BookStack\Entities\Models; namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditorType;
use BookStack\Permissions\PermissionApplicator; use BookStack\Permissions\PermissionApplicator;
use BookStack\Uploads\Attachment; use BookStack\Uploads\Attachment;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -14,7 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
/** /**
* Class Page. * Class Page.
* @property EntityPageData $pageData *
* @property int $chapter_id * @property int $chapter_id
* @property string $html * @property string $html
* @property string $markdown * @property string $markdown
@@ -32,10 +33,12 @@ class Page extends BookChild
{ {
use HasFactory; use HasFactory;
protected $fillable = ['name', 'priority'];
public string $textField = 'text'; public string $textField = 'text';
public string $htmlField = 'html'; public string $htmlField = 'html';
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at', 'entity_id', 'entity_type'];
protected $fillable = ['name', 'priority']; protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
protected $casts = [ protected $casts = [
'draft' => 'boolean', 'draft' => 'boolean',
@@ -54,8 +57,10 @@ class Page extends BookChild
/** /**
* Get the chapter that this page is in, If applicable. * Get the chapter that this page is in, If applicable.
*
* @return BelongsTo
*/ */
public function chapter(): BelongsTo public function chapter()
{ {
return $this->belongsTo(Chapter::class); return $this->belongsTo(Chapter::class);
} }
@@ -102,8 +107,10 @@ class Page extends BookChild
/** /**
* Get the attachments assigned to this page. * Get the attachments assigned to this page.
*
* @return HasMany
*/ */
public function attachments(): HasMany public function attachments()
{ {
return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc'); return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc');
} }
@@ -132,16 +139,8 @@ class Page extends BookChild
$refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']); $refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']);
$refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown'])); $refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown']));
$refreshed->setAttribute('raw_html', $refreshed->html); $refreshed->setAttribute('raw_html', $refreshed->html);
$refreshed->setAttribute('html', (new PageContent($refreshed))->render()); $refreshed->html = (new PageContent($refreshed))->render();
return $refreshed; return $refreshed;
} }
/**
* @return HasOne<EntityPageData, $this>
*/
public function relatedData(): HasOne
{
return $this->hasOne(EntityPageData::class, 'page_id', 'id');
}
} }

View File

@@ -6,7 +6,6 @@ use BookStack\Activity\Models\Loggable;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
@@ -31,8 +30,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
*/ */
class PageRevision extends Model implements Loggable class PageRevision extends Model implements Loggable
{ {
use HasFactory;
protected $fillable = ['name', 'text', 'summary']; protected $fillable = ['name', 'text', 'summary'];
protected $hidden = ['html', 'markdown', 'text']; protected $hidden = ['html', 'markdown', 'text'];

View File

@@ -55,11 +55,6 @@ class BookQueries implements ProvidesEntityQueries
->select(static::$listAttributes); ->select(static::$listAttributes);
} }
public function visibleForContent(): Builder
{
return $this->start()->scopes('visible');
}
public function visibleForListWithCover(): Builder public function visibleForListWithCover(): Builder
{ {
return $this->visibleForList()->with('cover'); return $this->visibleForList()->with('cover');

View File

@@ -60,11 +60,6 @@ class BookshelfQueries implements ProvidesEntityQueries
return $this->start()->scopes('visible')->select(static::$listAttributes); return $this->start()->scopes('visible')->select(static::$listAttributes);
} }
public function visibleForContent(): Builder
{
return $this->start()->scopes('visible');
}
public function visibleForListWithCover(): Builder public function visibleForListWithCover(): Builder
{ {
return $this->visibleForList()->with('cover'); return $this->visibleForList()->with('cover');

View File

@@ -65,14 +65,8 @@ class ChapterQueries implements ProvidesEntityQueries
->scopes('visible') ->scopes('visible')
->select(array_merge(static::$listAttributes, ['book_slug' => function ($builder) { ->select(array_merge(static::$listAttributes, ['book_slug' => function ($builder) {
$builder->select('slug') $builder->select('slug')
->from('entities as books') ->from('books')
->where('type', '=', 'book') ->whereColumn('books.id', '=', 'chapters.book_id');
->whereColumn('books.id', '=', 'entities.book_id');
}])); }]));
} }
public function visibleForContent(): Builder
{
return $this->start()->scopes('visible');
}
} }

View File

@@ -3,11 +3,7 @@
namespace BookStack\Entities\Queries; namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\EntityTable;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException; use InvalidArgumentException;
class EntityQueries class EntityQueries
@@ -36,53 +32,17 @@ class EntityQueries
return $queries->findVisibleById($entityId); return $queries->findVisibleById($entityId);
} }
/**
* Start a query across all entity types.
* Combines the description/text fields into a single 'description' field.
* @return Builder<EntityTable>
*/
public function visibleForList(): Builder
{
$rawDescriptionField = DB::raw('COALESCE(description, text) as description');
$bookSlugSelect = function (QueryBuilder $query) {
return $query->select('slug')->from('entities as books')
->whereColumn('books.id', '=', 'entities.book_id')
->where('type', '=', 'book');
};
return EntityTable::query()->scopes('visible')
->select(['id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'created_at', 'updated_at', 'draft', 'book_slug' => $bookSlugSelect, $rawDescriptionField])
->leftJoin('entity_container_data', function (JoinClause $join) {
$join->on('entity_container_data.entity_id', '=', 'entities.id')
->on('entity_container_data.entity_type', '=', 'entities.type');
})->leftJoin('entity_page_data', function (JoinClause $join) {
$join->on('entity_page_data.page_id', '=', 'entities.id')
->where('entities.type', '=', 'page');
});
}
/** /**
* Start a query of visible entities of the given type, * Start a query of visible entities of the given type,
* suitable for listing display. * suitable for listing display.
* @return Builder<Entity> * @return Builder<Entity>
*/ */
public function visibleForListForType(string $entityType): Builder public function visibleForList(string $entityType): Builder
{ {
$queries = $this->getQueriesForType($entityType); $queries = $this->getQueriesForType($entityType);
return $queries->visibleForList(); return $queries->visibleForList();
} }
/**
* Start a query of visible entities of the given type,
* suitable for using the contents of the items.
* @return Builder<Entity>
*/
public function visibleForContentForType(string $entityType): Builder
{
$queries = $this->getQueriesForType($entityType);
return $queries->visibleForContent();
}
protected function getQueriesForType(string $type): ProvidesEntityQueries protected function getQueriesForType(string $type): ProvidesEntityQueries
{ {
$queries = match ($type) { $queries = match ($type) {

View File

@@ -13,7 +13,7 @@ class PageQueries implements ProvidesEntityQueries
{ {
protected static array $contentAttributes = [ protected static array $contentAttributes = [
'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft',
'template', 'html', 'markdown', 'text', 'created_at', 'updated_at', 'priority', 'template', 'html', 'text', 'created_at', 'updated_at', 'priority',
'created_by', 'updated_by', 'owned_by', 'created_by', 'updated_by', 'owned_by',
]; ];
protected static array $listAttributes = [ protected static array $listAttributes = [
@@ -82,14 +82,6 @@ class PageQueries implements ProvidesEntityQueries
->select($this->mergeBookSlugForSelect(static::$listAttributes)); ->select($this->mergeBookSlugForSelect(static::$listAttributes));
} }
/**
* @return Builder<Page>
*/
public function visibleForContent(): Builder
{
return $this->start()->scopes('visible');
}
public function visibleForChapterList(int $chapterId): Builder public function visibleForChapterList(int $chapterId): Builder
{ {
return $this->visibleForList() return $this->visibleForList()
@@ -112,19 +104,18 @@ class PageQueries implements ProvidesEntityQueries
->where('created_by', '=', user()->id); ->where('created_by', '=', user()->id);
} }
public function visibleTemplates(bool $includeContents = false): Builder public function visibleTemplates(): Builder
{ {
$base = $includeContents ? $this->visibleWithContents() : $this->visibleForList(); return $this->visibleForList()
return $base->where('template', '=', true); ->where('template', '=', true);
} }
protected function mergeBookSlugForSelect(array $columns): array protected function mergeBookSlugForSelect(array $columns): array
{ {
return array_merge($columns, ['book_slug' => function ($builder) { return array_merge($columns, ['book_slug' => function ($builder) {
$builder->select('slug') $builder->select('slug')
->from('entities as books') ->from('books')
->where('type', '=', 'book') ->whereColumn('books.id', '=', 'pages.book_id');
->whereColumn('books.id', '=', 'entities.book_id');
}]); }]);
} }
} }

View File

@@ -35,11 +35,4 @@ interface ProvidesEntityQueries
* @return Builder<TModel> * @return Builder<TModel>
*/ */
public function visibleForList(): Builder; public function visibleForList(): Builder;
/**
* Start a query for items that are visible, with selection
* configured for using the content of the items found.
* @return Builder<TModel>
*/
public function visibleForContent(): Builder;
} }

View File

@@ -3,10 +3,13 @@
namespace BookStack\Entities\Repos; namespace BookStack\Entities\Repos;
use BookStack\Activity\TagRepo; use BookStack\Activity\TagRepo;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\HasCoverInterface; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\HasDescriptionInterface;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\CoverImageInterface;
use BookStack\Entities\Models\HtmlDescriptionInterface;
use BookStack\Entities\Models\HtmlDescriptionTrait;
use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Queries\PageQueries;
use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore; use BookStack\References\ReferenceStore;
@@ -30,25 +33,17 @@ class BaseRepo
/** /**
* Create a new entity in the system. * Create a new entity in the system.
* @template T of Entity
* @param T $entity
* @return T
*/ */
public function create(Entity $entity, array $input): Entity public function create(Entity $entity, array $input)
{ {
$entity = (clone $entity)->refresh();
$entity->fill($input); $entity->fill($input);
$this->updateDescription($entity, $input);
$entity->forceFill([ $entity->forceFill([
'created_by' => user()->id, 'created_by' => user()->id,
'updated_by' => user()->id, 'updated_by' => user()->id,
'owned_by' => user()->id, 'owned_by' => user()->id,
]); ]);
$entity->refreshSlug(); $entity->refreshSlug();
if ($entity instanceof HasDescriptionInterface) {
$this->updateDescription($entity, $input);
}
$entity->save(); $entity->save();
if (isset($input['tags'])) { if (isset($input['tags'])) {
@@ -58,33 +53,24 @@ class BaseRepo
$entity->refresh(); $entity->refresh();
$entity->rebuildPermissions(); $entity->rebuildPermissions();
$entity->indexForSearch(); $entity->indexForSearch();
$this->referenceStore->updateForEntity($entity); $this->referenceStore->updateForEntity($entity);
return $entity;
} }
/** /**
* Update the given entity. * Update the given entity.
* @template T of Entity
* @param T $entity
* @return T
*/ */
public function update(Entity $entity, array $input): Entity public function update(Entity $entity, array $input)
{ {
$oldUrl = $entity->getUrl(); $oldUrl = $entity->getUrl();
$entity->fill($input); $entity->fill($input);
$this->updateDescription($entity, $input);
$entity->updated_by = user()->id; $entity->updated_by = user()->id;
if ($entity->isDirty('name') || empty($entity->slug)) { if ($entity->isDirty('name') || empty($entity->slug)) {
$entity->refreshSlug(); $entity->refreshSlug();
} }
if ($entity instanceof HasDescriptionInterface) {
$this->updateDescription($entity, $input);
}
$entity->save(); $entity->save();
if (isset($input['tags'])) { if (isset($input['tags'])) {
@@ -98,35 +84,59 @@ class BaseRepo
if ($oldUrl !== $entity->getUrl()) { if ($oldUrl !== $entity->getUrl()) {
$this->referenceUpdater->updateEntityReferences($entity, $oldUrl); $this->referenceUpdater->updateEntityReferences($entity, $oldUrl);
} }
return $entity;
} }
/** /**
* Update the given items' cover image or clear it. * Update the given items' cover image, or clear it.
* *
* @throws ImageUploadException * @throws ImageUploadException
* @throws \Exception * @throws \Exception
*/ */
public function updateCoverImage(Entity&HasCoverInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false): void public function updateCoverImage(Entity&CoverImageInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false)
{ {
if ($coverImage) { if ($coverImage) {
$imageType = 'cover_' . $entity->type; $imageType = $entity->coverImageTypeKey();
$this->imageRepo->destroyImage($entity->coverInfo()->getImage()); $this->imageRepo->destroyImage($entity->cover()->first());
$image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true); $image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
$entity->coverInfo()->setImage($image); $entity->cover()->associate($image);
$entity->save(); $entity->save();
} }
if ($removeImage) { if ($removeImage) {
$this->imageRepo->destroyImage($entity->coverInfo()->getImage()); $this->imageRepo->destroyImage($entity->cover()->first());
$entity->coverInfo()->setImage(null); $entity->cover()->dissociate();
$entity->save(); $entity->save();
} }
} }
/** /**
* Sort the parent of the given entity if any auto sort actions are set for it. * Update the default page template used for this item.
* Checks that, if changing, the provided value is a valid template and the user
* has visibility of the provided page template id.
*/
public function updateDefaultTemplate(Book|Chapter $entity, int $templateId): void
{
$changing = $templateId !== intval($entity->default_template_id);
if (!$changing) {
return;
}
if ($templateId === 0) {
$entity->default_template_id = null;
$entity->save();
return;
}
$templateExists = $this->pageQueries->visibleTemplates()
->where('id', '=', $templateId)
->exists();
$entity->default_template_id = $templateExists ? $templateId : null;
$entity->save();
}
/**
* Sort the parent of the given entity, if any auto sort actions are set for it.
* Typically ran during create/update/insert events. * Typically ran during create/update/insert events.
*/ */
public function sortParent(Entity $entity): void public function sortParent(Entity $entity): void
@@ -137,22 +147,19 @@ class BaseRepo
} }
} }
/**
* Update the description of the given entity from input data.
*/
protected function updateDescription(Entity $entity, array $input): void protected function updateDescription(Entity $entity, array $input): void
{ {
if (!$entity instanceof HasDescriptionInterface) { if (!($entity instanceof HtmlDescriptionInterface)) {
return; return;
} }
if (isset($input['description_html'])) { if (isset($input['description_html'])) {
$entity->descriptionInfo()->set( $entity->setDescriptionHtml(
HtmlDescriptionFilter::filterFromString($input['description_html']), HtmlDescriptionFilter::filterFromString($input['description_html']),
html_entity_decode(strip_tags($input['description_html'])) html_entity_decode(strip_tags($input['description_html']))
); );
} else if (isset($input['description'])) { } else if (isset($input['description'])) {
$entity->descriptionInfo()->set('', $input['description']); $entity->setDescriptionHtml('', $input['description']);
} }
} }
} }

View File

@@ -30,18 +30,19 @@ class BookRepo
public function create(array $input): Book public function create(array $input): Book
{ {
return (new DatabaseTransaction(function () use ($input) { return (new DatabaseTransaction(function () use ($input) {
$book = $this->baseRepo->create(new Book(), $input); $book = new Book();
$this->baseRepo->create($book, $input);
$this->baseRepo->updateCoverImage($book, $input['image'] ?? null); $this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
$book->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null)); $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
Activity::add(ActivityType::BOOK_CREATE, $book); Activity::add(ActivityType::BOOK_CREATE, $book);
$defaultBookSortSetting = intval(setting('sorting-book-default', '0')); $defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) { if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
$book->sort_rule_id = $defaultBookSortSetting; $book->sort_rule_id = $defaultBookSortSetting;
$book->save();
} }
$book->save();
return $book; return $book;
}))->run(); }))->run();
} }
@@ -51,29 +52,28 @@ class BookRepo
*/ */
public function update(Book $book, array $input): Book public function update(Book $book, array $input): Book
{ {
$book = $this->baseRepo->update($book, $input); $this->baseRepo->update($book, $input);
if (array_key_exists('default_template_id', $input)) { if (array_key_exists('default_template_id', $input)) {
$book->defaultTemplate()->setFromId(intval($input['default_template_id'])); $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id']));
} }
if (array_key_exists('image', $input)) { if (array_key_exists('image', $input)) {
$this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null); $this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null);
} }
$book->save();
Activity::add(ActivityType::BOOK_UPDATE, $book); Activity::add(ActivityType::BOOK_UPDATE, $book);
return $book; return $book;
} }
/** /**
* Update the given book's cover image or clear it. * Update the given book's cover image, or clear it.
* *
* @throws ImageUploadException * @throws ImageUploadException
* @throws Exception * @throws Exception
*/ */
public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false): void public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false)
{ {
$this->baseRepo->updateCoverImage($book, $coverImage, $removeImage); $this->baseRepo->updateCoverImage($book, $coverImage, $removeImage);
} }
@@ -83,7 +83,7 @@ class BookRepo
* *
* @throws Exception * @throws Exception
*/ */
public function destroy(Book $book): void public function destroy(Book $book)
{ {
$this->trashCan->softDestroyBook($book); $this->trashCan->softDestroyBook($book);
Activity::add(ActivityType::BOOK_DELETE, $book); Activity::add(ActivityType::BOOK_DELETE, $book);

View File

@@ -25,7 +25,8 @@ class BookshelfRepo
public function create(array $input, array $bookIds): Bookshelf public function create(array $input, array $bookIds): Bookshelf
{ {
return (new DatabaseTransaction(function () use ($input, $bookIds) { return (new DatabaseTransaction(function () use ($input, $bookIds) {
$shelf = $this->baseRepo->create(new Bookshelf(), $input); $shelf = new Bookshelf();
$this->baseRepo->create($shelf, $input);
$this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null); $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
$this->updateBooks($shelf, $bookIds); $this->updateBooks($shelf, $bookIds);
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf); Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
@@ -38,7 +39,7 @@ class BookshelfRepo
*/ */
public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf
{ {
$shelf = $this->baseRepo->update($shelf, $input); $this->baseRepo->update($shelf, $input);
if (!is_null($bookIds)) { if (!is_null($bookIds)) {
$this->updateBooks($shelf, $bookIds); $this->updateBooks($shelf, $bookIds);
@@ -95,7 +96,7 @@ class BookshelfRepo
* *
* @throws Exception * @throws Exception
*/ */
public function destroy(Bookshelf $shelf): void public function destroy(Bookshelf $shelf)
{ {
$this->trashCan->softDestroyShelf($shelf); $this->trashCan->softDestroyShelf($shelf);
Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf); Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);

View File

@@ -33,11 +33,8 @@ class ChapterRepo
$chapter = new Chapter(); $chapter = new Chapter();
$chapter->book_id = $parentBook->id; $chapter->book_id = $parentBook->id;
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1; $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
$this->baseRepo->create($chapter, $input);
$chapter = $this->baseRepo->create($chapter, $input); $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
$chapter->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null));
$chapter->save();
Activity::add(ActivityType::CHAPTER_CREATE, $chapter); Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
$this->baseRepo->sortParent($chapter); $this->baseRepo->sortParent($chapter);
@@ -51,13 +48,12 @@ class ChapterRepo
*/ */
public function update(Chapter $chapter, array $input): Chapter public function update(Chapter $chapter, array $input): Chapter
{ {
$chapter = $this->baseRepo->update($chapter, $input); $this->baseRepo->update($chapter, $input);
if (array_key_exists('default_template_id', $input)) { if (array_key_exists('default_template_id', $input)) {
$chapter->defaultTemplate()->setFromId(intval($input['default_template_id'])); $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id']));
} }
$chapter->save();
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter); Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
$this->baseRepo->sortParent($chapter); $this->baseRepo->sortParent($chapter);
@@ -70,7 +66,7 @@ class ChapterRepo
* *
* @throws Exception * @throws Exception
*/ */
public function destroy(Chapter $chapter): void public function destroy(Chapter $chapter)
{ {
$this->trashCan->softDestroyChapter($chapter); $this->trashCan->softDestroyChapter($chapter);
Activity::add(ActivityType::CHAPTER_DELETE, $chapter); Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
@@ -97,7 +93,7 @@ class ChapterRepo
} }
return (new DatabaseTransaction(function () use ($chapter, $parent) { return (new DatabaseTransaction(function () use ($chapter, $parent) {
$chapter = $chapter->changeBook($parent->id); $chapter->changeBook($parent->id);
$chapter->rebuildPermissions(); $chapter->rebuildPermissions();
Activity::add(ActivityType::CHAPTER_MOVE, $chapter); Activity::add(ActivityType::CHAPTER_MOVE, $chapter);

View File

@@ -9,9 +9,11 @@ use BookStack\Facades\Activity;
class DeletionRepo class DeletionRepo
{ {
public function __construct( private TrashCan $trashCan;
protected TrashCan $trashCan
) { public function __construct(TrashCan $trashCan)
{
$this->trashCan = $trashCan;
} }
public function restore(int $id): int public function restore(int $id): int

View File

@@ -37,7 +37,7 @@ class PageRepo
/** /**
* Get a new draft page belonging to the given parent entity. * Get a new draft page belonging to the given parent entity.
*/ */
public function getNewDraftPage(Entity $parent): Page public function getNewDraftPage(Entity $parent)
{ {
$page = (new Page())->forceFill([ $page = (new Page())->forceFill([
'name' => trans('entities.pages_initial_name'), 'name' => trans('entities.pages_initial_name'),
@@ -46,9 +46,6 @@ class PageRepo
'updated_by' => user()->id, 'updated_by' => user()->id,
'draft' => true, 'draft' => true,
'editor' => PageEditorType::getSystemDefault()->value, 'editor' => PageEditorType::getSystemDefault()->value,
'html' => '',
'markdown' => '',
'text' => '',
]); ]);
if ($parent instanceof Chapter) { if ($parent instanceof Chapter) {
@@ -58,18 +55,17 @@ class PageRepo
$page->book_id = $parent->id; $page->book_id = $parent->id;
} }
$defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book?->defaultTemplate()->get(); $defaultTemplate = $page->chapter->defaultTemplate ?? $page->book->defaultTemplate;
if ($defaultTemplate) { if ($defaultTemplate && userCan(Permission::PageView, $defaultTemplate)) {
$page->forceFill([ $page->forceFill([
'html' => $defaultTemplate->html, 'html' => $defaultTemplate->html,
'markdown' => $defaultTemplate->markdown, 'markdown' => $defaultTemplate->markdown,
]); ]);
$page->text = (new PageContent($page))->toPlainText();
} }
(new DatabaseTransaction(function () use ($page) { (new DatabaseTransaction(function () use ($page) {
$page->save(); $page->save();
$page->rebuildPermissions(); $page->refresh()->rebuildPermissions();
}))->run(); }))->run();
return $page; return $page;
@@ -85,8 +81,7 @@ class PageRepo
$draft->revision_count = 1; $draft->revision_count = 1;
$draft->priority = $this->getNewPriority($draft); $draft->priority = $this->getNewPriority($draft);
$this->updateTemplateStatusAndContentFromInput($draft, $input); $this->updateTemplateStatusAndContentFromInput($draft, $input);
$this->baseRepo->update($draft, $input);
$draft = $this->baseRepo->update($draft, $input);
$draft->rebuildPermissions(); $draft->rebuildPermissions();
$summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision'); $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
@@ -117,12 +112,12 @@ class PageRepo
public function update(Page $page, array $input): Page public function update(Page $page, array $input): Page
{ {
// Hold the old details to compare later // Hold the old details to compare later
$oldName = $page->name;
$oldHtml = $page->html; $oldHtml = $page->html;
$oldName = $page->name;
$oldMarkdown = $page->markdown; $oldMarkdown = $page->markdown;
$this->updateTemplateStatusAndContentFromInput($page, $input); $this->updateTemplateStatusAndContentFromInput($page, $input);
$page = $this->baseRepo->update($page, $input); $this->baseRepo->update($page, $input);
// Update with new details // Update with new details
$page->revision_count++; $page->revision_count++;
@@ -181,12 +176,12 @@ class PageRepo
/** /**
* Save a page update draft. * Save a page update draft.
*/ */
public function updatePageDraft(Page $page, array $input): Page|PageRevision public function updatePageDraft(Page $page, array $input)
{ {
// If the page itself is a draft, simply update that // If the page itself is a draft simply update that
if ($page->draft) { if ($page->draft) {
$this->updateTemplateStatusAndContentFromInput($page, $input); $this->updateTemplateStatusAndContentFromInput($page, $input);
$page->forceFill(array_intersect_key($input, array_flip(['name'])))->save(); $page->fill($input);
$page->save(); $page->save();
return $page; return $page;
@@ -214,7 +209,7 @@ class PageRepo
* *
* @throws Exception * @throws Exception
*/ */
public function destroy(Page $page): void public function destroy(Page $page)
{ {
$this->trashCan->softDestroyPage($page); $this->trashCan->softDestroyPage($page);
Activity::add(ActivityType::PAGE_DELETE, $page); Activity::add(ActivityType::PAGE_DELETE, $page);
@@ -284,7 +279,7 @@ class PageRepo
return (new DatabaseTransaction(function () use ($page, $parent) { return (new DatabaseTransaction(function () use ($page, $parent) {
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null; $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id; $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
$page = $page->changeBook($newBookId); $page->changeBook($newBookId);
$page->rebuildPermissions(); $page->rebuildPermissions();
Activity::add(ActivityType::PAGE_MOVE, $page); Activity::add(ActivityType::PAGE_MOVE, $page);

View File

@@ -23,7 +23,7 @@ class RevisionRepo
/** /**
* Get a user update_draft page revision to update for the given page. * Get a user update_draft page revision to update for the given page.
* Checks for an existing revision before providing a fresh one. * Checks for an existing revisions before providing a fresh one.
*/ */
public function getNewDraftForCurrentUser(Page $page): PageRevision public function getNewDraftForCurrentUser(Page $page): PageRevision
{ {
@@ -72,7 +72,7 @@ class RevisionRepo
/** /**
* Delete old revisions, for the given page, from the system. * Delete old revisions, for the given page, from the system.
*/ */
protected function deleteOldRevisions(Page $page): void protected function deleteOldRevisions(Page $page)
{ {
$revisionLimit = config('app.revision_limit'); $revisionLimit = config('app.revision_limit');
if ($revisionLimit === false) { if ($revisionLimit === false) {

View File

@@ -3,10 +3,13 @@
namespace BookStack\Entities\Tools; namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\EntityQueries;
use BookStack\Sorting\BookSortMap;
use BookStack\Sorting\BookSortMapItem;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class BookContents class BookContents
@@ -26,7 +29,7 @@ class BookContents
{ {
$maxPage = $this->book->pages() $maxPage = $this->book->pages()
->where('draft', '=', false) ->where('draft', '=', false)
->whereDoesntHave('chapter') ->where('chapter_id', '=', 0)
->max('priority'); ->max('priority');
$maxChapter = $this->book->chapters() $maxChapter = $this->book->chapters()
@@ -77,11 +80,11 @@ class BookContents
protected function bookChildSortFunc(): callable protected function bookChildSortFunc(): callable
{ {
return function (Entity $entity) { return function (Entity $entity) {
if ($entity->getAttribute('draft') ?? false) { if (isset($entity['draft']) && $entity['draft']) {
return -100; return -100;
} }
return $entity->getAttribute('priority') ?? 0; return $entity['priority'] ?? 0;
}; };
} }

View File

@@ -6,8 +6,8 @@ use BookStack\Activity\Models\Tag;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\HasCoverInterface;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\CoverImageInterface;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Repos\ChapterRepo;
@@ -106,8 +106,8 @@ class Cloner
$inputData['tags'] = $this->entityTagsToInputArray($entity); $inputData['tags'] = $this->entityTagsToInputArray($entity);
// Add a cover to the data if existing on the original entity // Add a cover to the data if existing on the original entity
if ($entity instanceof HasCoverInterface) { if ($entity instanceof CoverImageInterface) {
$cover = $entity->coverInfo()->getImage(); $cover = $entity->cover()->first();
if ($cover) { if ($cover) {
$inputData['image'] = $this->imageToUploadedFile($cover); $inputData['image'] = $this->imageToUploadedFile($cover);
} }

View File

@@ -1,75 +0,0 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Uploads\Image;
use Exception;
use Illuminate\Database\Eloquent\Builder;
class EntityCover
{
public function __construct(
protected Book|Bookshelf $entity,
) {
}
protected function imageQuery(): Builder
{
return Image::query()->where('id', '=', $this->entity->image_id);
}
/**
* Check if a cover image exists for this entity.
*/
public function exists(): bool
{
return $this->entity->image_id !== null && $this->imageQuery()->exists();
}
/**
* Get the assigned cover image model.
*/
public function getImage(): Image|null
{
if ($this->entity->image_id === null) {
return null;
}
$cover = $this->imageQuery()->first();
if ($cover instanceof Image) {
return $cover;
}
return null;
}
/**
* Returns a cover image URL, or the given default if none assigned/existing.
*/
public function getUrl(int $width = 440, int $height = 250, string|null $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='): string|null
{
if (!$this->entity->image_id) {
return $default;
}
try {
return $this->getImage()?->getThumb($width, $height, false) ?? $default;
} catch (Exception $err) {
return $default;
}
}
/**
* Set the image to use as the cover for this entity.
*/
public function setImage(Image|null $image): void
{
if ($image === null) {
$this->entity->image_id = null;
} else {
$this->entity->image_id = $image->id;
}
}
}

View File

@@ -1,60 +0,0 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\PageQueries;
class EntityDefaultTemplate
{
public function __construct(
protected Book|Chapter $entity,
) {
}
/**
* Set the default template ID for this entity.
*/
public function setFromId(int $templateId): void
{
$changing = $templateId !== intval($this->entity->default_template_id);
if (!$changing) {
return;
}
if ($templateId === 0) {
$this->entity->default_template_id = null;
return;
}
$pageQueries = app()->make(PageQueries::class);
$templateExists = $pageQueries->visibleTemplates()
->where('id', '=', $templateId)
->exists();
$this->entity->default_template_id = $templateExists ? $templateId : null;
}
/**
* Get the default template for this entity (if visible).
*/
public function get(): Page|null
{
if (!$this->entity->default_template_id) {
return null;
}
$pageQueries = app()->make(PageQueries::class);
$page = $pageQueries->visibleTemplates(true)
->where('id', '=', $this->entity->default_template_id)
->first();
if ($page instanceof Page) {
return $page;
}
return null;
}
}

View File

@@ -1,60 +0,0 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Util\HtmlContentFilter;
class EntityHtmlDescription
{
protected string $html = '';
protected string $plain = '';
public function __construct(
protected Book|Chapter|Bookshelf $entity,
) {
$this->html = $this->entity->description_html ?? '';
$this->plain = $this->entity->description ?? '';
}
/**
* Update the description from HTML code.
* Optionally takes plaintext to use for the model also.
*/
public function set(string $html, string|null $plaintext = null): void
{
$this->html = $html;
$this->entity->description_html = $this->html;
if ($plaintext !== null) {
$this->plain = $plaintext;
$this->entity->description = $this->plain;
}
if (empty($html) && !empty($plaintext)) {
$this->html = $this->getHtml();
$this->entity->description_html = $this->html;
}
}
/**
* Get the description as HTML.
* Optionally returns the raw HTML if requested.
*/
public function getHtml(bool $raw = false): string
{
$html = $this->html ?: '<p>' . nl2br(e($this->plain)) . '</p>';
if ($raw) {
return $html;
}
return HtmlContentFilter::removeScriptsFromHtmlString($html);
}
public function getPlain(): string
{
return $this->plain;
}
}

View File

@@ -1,140 +0,0 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Activity\Models\Tag;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\EntityTable;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries;
use Illuminate\Database\Eloquent\Collection;
class EntityHydrator
{
public function __construct(
protected EntityQueries $entityQueries,
) {
}
/**
* Hydrate the entities of this hydrator to return a list of entities represented
* in their original intended models.
* @param EntityTable[] $entities
* @return Entity[]
*/
public function hydrate(array $entities, bool $loadTags = false, bool $loadParents = false): array
{
$hydrated = [];
foreach ($entities as $entity) {
$data = $entity->getRawOriginal();
$instance = Entity::instanceFromType($entity->type);
if ($instance instanceof Page) {
$data['text'] = $data['description'];
unset($data['description']);
}
$instance = $instance->setRawAttributes($data, true);
$hydrated[] = $instance;
}
if ($loadTags) {
$this->loadTagsIntoModels($hydrated);
}
if ($loadParents) {
$this->loadParentsIntoModels($hydrated);
}
return $hydrated;
}
/**
* @param Entity[] $entities
*/
protected function loadTagsIntoModels(array $entities): void
{
$idsByType = [];
$entityMap = [];
foreach ($entities as $entity) {
if (!isset($idsByType[$entity->type])) {
$idsByType[$entity->type] = [];
}
$idsByType[$entity->type][] = $entity->id;
$entityMap[$entity->type . ':' . $entity->id] = $entity;
}
$query = Tag::query();
foreach ($idsByType as $type => $ids) {
$query->orWhere(function ($query) use ($type, $ids) {
$query->where('entity_type', '=', $type)
->whereIn('entity_id', $ids);
});
}
$tags = empty($idsByType) ? [] : $query->get()->all();
$tagMap = [];
foreach ($tags as $tag) {
$key = $tag->entity_type . ':' . $tag->entity_id;
if (!isset($tagMap[$key])) {
$tagMap[$key] = [];
}
$tagMap[$key][] = $tag;
}
foreach ($entityMap as $key => $entity) {
$entityTags = new Collection($tagMap[$key] ?? []);
$entity->setRelation('tags', $entityTags);
}
}
/**
* @param Entity[] $entities
*/
protected function loadParentsIntoModels(array $entities): void
{
$parentsByType = ['book' => [], 'chapter' => []];
foreach ($entities as $entity) {
if ($entity->getAttribute('book_id') !== null) {
$parentsByType['book'][] = $entity->getAttribute('book_id');
}
if ($entity->getAttribute('chapter_id') !== null) {
$parentsByType['chapter'][] = $entity->getAttribute('chapter_id');
}
}
$parentQuery = $this->entityQueries->visibleForList();
$filtered = count($parentsByType['book']) > 0 || count($parentsByType['chapter']) > 0;
$parentQuery = $parentQuery->where(function ($query) use ($parentsByType) {
foreach ($parentsByType as $type => $ids) {
if (count($ids) > 0) {
$query = $query->orWhere(function ($query) use ($type, $ids) {
$query->where('type', '=', $type)
->whereIn('id', $ids);
});
}
}
});
$parentModels = $filtered ? $parentQuery->get()->all() : [];
$parents = $this->hydrate($parentModels);
$parentMap = [];
foreach ($parents as $parent) {
$parentMap[$parent->type . ':' . $parent->id] = $parent;
}
foreach ($entities as $entity) {
if ($entity instanceof Page || $entity instanceof Chapter) {
$key = 'book:' . $entity->getRawAttribute('book_id');
$entity->setRelation('book', $parentMap[$key] ?? null);
}
if ($entity instanceof Page) {
$key = 'chapter:' . $entity->getRawAttribute('chapter_id');
$entity->setRelation('chapter', $parentMap[$key] ?? null);
}
}
}
}

View File

@@ -34,7 +34,6 @@ class HierarchyTransformer
/** @var Page $page */ /** @var Page $page */
foreach ($chapter->pages as $page) { foreach ($chapter->pages as $page) {
$page->chapter_id = 0; $page->chapter_id = 0;
$page->save();
$page->changeBook($book->id); $page->changeBook($book->id);
} }

View File

@@ -19,7 +19,7 @@ class MixedEntityListLoader
* This will look for a model id and type via 'name_id' and 'name_type'. * This will look for a model id and type via 'name_id' and 'name_type'.
* @param Model[] $relations * @param Model[] $relations
*/ */
public function loadIntoRelations(array $relations, string $relationName, bool $loadParents, bool $withContents = false): void public function loadIntoRelations(array $relations, string $relationName, bool $loadParents): void
{ {
$idsByType = []; $idsByType = [];
foreach ($relations as $relation) { foreach ($relations as $relation) {
@@ -33,7 +33,7 @@ class MixedEntityListLoader
$idsByType[$type][] = $id; $idsByType[$type][] = $id;
} }
$modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents, $withContents); $modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents);
foreach ($relations as $relation) { foreach ($relations as $relation) {
$type = $relation->getAttribute($relationName . '_type'); $type = $relation->getAttribute($relationName . '_type');
@@ -49,13 +49,13 @@ class MixedEntityListLoader
* @param array<string, int[]> $idsByType * @param array<string, int[]> $idsByType
* @return array<string, array<int, Model>> * @return array<string, array<int, Model>>
*/ */
protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents, bool $withContents): array protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents): array
{ {
$modelMap = []; $modelMap = [];
foreach ($idsByType as $type => $ids) { foreach ($idsByType as $type => $ids) {
$base = $withContents ? $this->queries->visibleForContentForType($type) : $this->queries->visibleForListForType($type); $models = $this->queries->visibleForList($type)
$models = $base->whereIn('id', $ids) ->whereIn('id', $ids)
->with($eagerLoadParents ? $this->getRelationsToEagerLoad($type) : []) ->with($eagerLoadParents ? $this->getRelationsToEagerLoad($type) : [])
->get(); ->get();

View File

@@ -284,7 +284,7 @@ class PageContent
/** /**
* Get a plain-text visualisation of this page. * Get a plain-text visualisation of this page.
*/ */
public function toPlainText(): string protected function toPlainText(): string
{ {
$html = $this->render(true); $html = $this->render(true);

View File

@@ -6,16 +6,14 @@ use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\EntityContainerData;
use BookStack\Entities\Models\HasCoverInterface;
use BookStack\Entities\Models\Deletion; use BookStack\Entities\Models\Deletion;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\CoverImageInterface;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\EntityQueries;
use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\NotifyException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Uploads\AttachmentService; use BookStack\Uploads\AttachmentService;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService; use BookStack\Uploads\ImageService;
use BookStack\Util\DatabaseTransaction; use BookStack\Util\DatabaseTransaction;
use Exception; use Exception;
@@ -142,7 +140,6 @@ class TrashCan
protected function destroyShelf(Bookshelf $shelf): int protected function destroyShelf(Bookshelf $shelf): int
{ {
$this->destroyCommonRelations($shelf); $this->destroyCommonRelations($shelf);
$shelf->books()->detach();
$shelf->forceDelete(); $shelf->forceDelete();
return 1; return 1;
@@ -170,7 +167,6 @@ class TrashCan
} }
$this->destroyCommonRelations($book); $this->destroyCommonRelations($book);
$book->shelves()->detach();
$book->forceDelete(); $book->forceDelete();
return $count + 1; return $count + 1;
@@ -213,16 +209,15 @@ class TrashCan
$attachmentService->deleteFile($attachment); $attachmentService->deleteFile($attachment);
} }
// Remove use as a template // Remove book template usages
EntityContainerData::query() $this->queries->books->start()
->where('default_template_id', '=', $page->id) ->where('default_template_id', '=', $page->id)
->update(['default_template_id' => null]); ->update(['default_template_id' => null]);
// Nullify uploaded image relations // Remove chapter template usages
Image::query() $this->queries->chapters->start()
->whereIn('type', ['gallery', 'drawio']) ->where('default_template_id', '=', $page->id)
->where('uploaded_to', '=', $page->id) ->update(['default_template_id' => null]);
->update(['uploaded_to' => null]);
$page->forceDelete(); $page->forceDelete();
@@ -273,8 +268,8 @@ class TrashCan
// exists in the event it has already been destroyed during this request. // exists in the event it has already been destroyed during this request.
$entity = $deletion->deletable()->first(); $entity = $deletion->deletable()->first();
$count = 0; $count = 0;
if ($entity instanceof Entity) { if ($entity) {
$count = $this->destroyEntity($entity); $count = $this->destroyEntity($deletion->deletable);
} }
$deletion->delete(); $deletion->delete();
@@ -403,11 +398,9 @@ class TrashCan
$entity->referencesTo()->delete(); $entity->referencesTo()->delete();
$entity->referencesFrom()->delete(); $entity->referencesFrom()->delete();
if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) { if ($entity instanceof CoverImageInterface && $entity->cover()->exists()) {
$imageService = app()->make(ImageService::class); $imageService = app()->make(ImageService::class);
$imageService->destroy($entity->coverInfo()->getImage()); $imageService->destroy($entity->cover()->first());
} }
$entity->relatedData()->delete();
} }
} }

View File

@@ -284,7 +284,7 @@ class ExportFormatter
public function bookToPlainText(Book $book): string public function bookToPlainText(Book $book): string
{ {
$bookTree = (new BookContents($book))->getTree(false, true); $bookTree = (new BookContents($book))->getTree(false, true);
$text = $book->name . "\n" . $book->descriptionInfo()->getPlain(); $text = $book->name . "\n" . $book->description;
$text = rtrim($text) . "\n\n"; $text = rtrim($text) . "\n\n";
$parts = []; $parts = [];
@@ -318,7 +318,7 @@ class ExportFormatter
{ {
$text = '# ' . $chapter->name . "\n\n"; $text = '# ' . $chapter->name . "\n\n";
$description = (new HtmlToMarkdown($chapter->descriptionInfo()->getHtml()))->convert(); $description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert();
if ($description) { if ($description) {
$text .= $description . "\n\n"; $text .= $description . "\n\n";
} }
@@ -338,7 +338,7 @@ class ExportFormatter
$bookTree = (new BookContents($book))->getTree(false, true); $bookTree = (new BookContents($book))->getTree(false, true);
$text = '# ' . $book->name . "\n\n"; $text = '# ' . $book->name . "\n\n";
$description = (new HtmlToMarkdown($book->descriptionInfo()->getHtml()))->convert(); $description = (new HtmlToMarkdown($book->descriptionHtml()))->convert();
if ($description) { if ($description) {
$text .= $description . "\n\n"; $text .= $description . "\n\n";
} }

View File

@@ -55,10 +55,10 @@ final class ZipExportBook extends ZipExportModel
$instance = new self(); $instance = new self();
$instance->id = $model->id; $instance->id = $model->id;
$instance->name = $model->name; $instance->name = $model->name;
$instance->description_html = $model->descriptionInfo()->getHtml(); $instance->description_html = $model->descriptionHtml();
if ($model->coverInfo()->exists()) { if ($model->cover) {
$instance->cover = $files->referenceForImage($model->coverInfo()->getImage()); $instance->cover = $files->referenceForImage($model->cover);
} }
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());

View File

@@ -40,7 +40,7 @@ final class ZipExportChapter extends ZipExportModel
$instance = new self(); $instance = new self();
$instance->id = $model->id; $instance->id = $model->id;
$instance->name = $model->name; $instance->name = $model->name;
$instance->description_html = $model->descriptionInfo()->getHtml(); $instance->description_html = $model->descriptionHtml();
$instance->priority = $model->priority; $instance->priority = $model->priority;
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());

View File

@@ -15,7 +15,6 @@ use BookStack\Exports\ZipExports\Models\ZipExportPage;
use BookStack\Permissions\Permission; use BookStack\Permissions\Permission;
use BookStack\Uploads\Attachment; use BookStack\Uploads\Attachment;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService;
class ZipExportReferences class ZipExportReferences
{ {
@@ -34,7 +33,6 @@ class ZipExportReferences
public function __construct( public function __construct(
protected ZipReferenceParser $parser, protected ZipReferenceParser $parser,
protected ImageService $imageService,
) { ) {
} }
@@ -135,17 +133,10 @@ class ZipExportReferences
return "[[bsexport:image:{$model->id}]]"; return "[[bsexport:image:{$model->id}]]";
} }
// Get the page which we'll reference this image upon // Find and include images if in visibility
$page = $model->getPage(); $page = $model->getPage();
$pageExportModel = null; $pageExportModel = $this->pages[$page->id] ?? ($exportModel instanceof ZipExportPage ? $exportModel : null);
if ($page && isset($this->pages[$page->id])) { if (isset($this->images[$model->id]) || ($page && $pageExportModel && userCan(Permission::PageView, $page))) {
$pageExportModel = $this->pages[$page->id];
} elseif ($exportModel instanceof ZipExportPage) {
$pageExportModel = $exportModel;
}
// Add the image to the export if it's accessible or just return the existing reference if already added
if (isset($this->images[$model->id]) || ($pageExportModel && $this->imageService->imageAccessible($model))) {
if (!isset($this->images[$model->id])) { if (!isset($this->images[$model->id])) {
$exportImage = ZipExportImage::fromModel($model, $files); $exportImage = ZipExportImage::fromModel($model, $files);
$this->images[$model->id] = $exportImage; $this->images[$model->id] = $exportImage;
@@ -153,7 +144,6 @@ class ZipExportReferences
} }
return "[[bsexport:image:{$model->id}]]"; return "[[bsexport:image:{$model->id}]]";
} }
return null; return null;
} }

View File

@@ -135,8 +135,8 @@ class ZipImportRunner
'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []), 'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []),
]); ]);
if ($book->coverInfo()->getImage()) { if ($book->cover) {
$this->references->addImage($book->coverInfo()->getImage(), null); $this->references->addImage($book->cover, null);
} }
$children = [ $children = [
@@ -197,8 +197,8 @@ class ZipImportRunner
$this->pageRepo->publishDraft($page, [ $this->pageRepo->publishDraft($page, [
'name' => $exportPage->name, 'name' => $exportPage->name,
'markdown' => $exportPage->markdown ?? '', 'markdown' => $exportPage->markdown,
'html' => $exportPage->html ?? '', 'html' => $exportPage->html,
'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []), 'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []),
]); ]);

View File

@@ -8,12 +8,6 @@ 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 = [];
/** /**

View File

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

View File

@@ -40,6 +40,10 @@ class PermissionApplicator
$ownerField = $ownable->getOwnerFieldName(); $ownerField = $ownable->getOwnerFieldName();
$ownableFieldVal = $ownable->getAttribute($ownerField); $ownableFieldVal = $ownable->getAttribute($ownerField);
if (is_null($ownableFieldVal)) {
throw new InvalidArgumentException("{$ownerField} field used but has not been loaded");
}
$isOwner = $user->id === $ownableFieldVal; $isOwner = $user->id === $ownableFieldVal;
$hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission); $hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission);
@@ -140,10 +144,10 @@ class PermissionApplicator
/** @var Builder $query */ /** @var Builder $query */
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass) $query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) { ->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
$query->select('page_id')->from('entity_page_data') $query->select('id')->from('pages')
->whereColumn('entity_page_data.page_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn']) ->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass) ->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
->where('entity_page_data.draft', '=', false); ->where('pages.draft', '=', false);
}); });
}); });
} }
@@ -193,18 +197,18 @@ class PermissionApplicator
{ {
$fullPageIdColumn = $tableName . '.' . $pageIdColumn; $fullPageIdColumn = $tableName . '.' . $pageIdColumn;
return $this->restrictEntityQuery($query) return $this->restrictEntityQuery($query)
->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) { ->where(function ($query) use ($fullPageIdColumn) {
$query->select('id')->from('entities') /** @var Builder $query */
->leftJoin('entity_page_data', 'entities.id', '=', 'entity_page_data.page_id') $query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
->whereColumn('entities.id', '=', $fullPageIdColumn) $query->select('id')->from('pages')
->where('entities.type', '=', 'page') ->whereColumn('pages.id', '=', $fullPageIdColumn)
->where(function (QueryBuilder $query) { ->where('pages.draft', '=', false);
$query->where('entity_page_data.draft', '=', false) })->orWhereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
->orWhere(function (QueryBuilder $query) { $query->select('id')->from('pages')
$query->where('entity_page_data.draft', '=', true) ->whereColumn('pages.id', '=', $fullPageIdColumn)
->where('entities.created_by', '=', $this->currentUser()->id); ->where('pages.draft', '=', true)
}); ->where('pages.created_by', '=', $this->currentUser()->id);
}); });
}); });
} }

View File

@@ -20,10 +20,10 @@ class ReferenceFetcher
* Query and return the references pointing to the given entity. * Query and return the references pointing to the given entity.
* Loads the commonly required relations while taking permissions into account. * Loads the commonly required relations while taking permissions into account.
*/ */
public function getReferencesToEntity(Entity $entity, bool $withContents = false): Collection public function getReferencesToEntity(Entity $entity): Collection
{ {
$references = $this->queryReferencesToEntity($entity)->get(); $references = $this->queryReferencesToEntity($entity)->get();
$this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', false, $withContents); $this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', true);
return $references; return $references;
} }

View File

@@ -3,9 +3,9 @@
namespace BookStack\References; namespace BookStack\References;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\HasDescriptionInterface;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\EntityContainerData; use BookStack\Entities\Models\HtmlDescriptionInterface;
use BookStack\Entities\Models\HtmlDescriptionTrait;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\RevisionRepo; use BookStack\Entities\Repos\RevisionRepo;
use BookStack\Util\HtmlDocument; use BookStack\Util\HtmlDocument;
@@ -36,7 +36,7 @@ class ReferenceUpdater
protected function getReferencesToUpdate(Entity $entity): array protected function getReferencesToUpdate(Entity $entity): array
{ {
/** @var Reference[] $references */ /** @var Reference[] $references */
$references = $this->referenceFetcher->getReferencesToEntity($entity, true)->values()->all(); $references = $this->referenceFetcher->getReferencesToEntity($entity)->values()->all();
if ($entity instanceof Book) { if ($entity instanceof Book) {
$pages = $entity->pages()->get(['id']); $pages = $entity->pages()->get(['id']);
@@ -44,7 +44,7 @@ class ReferenceUpdater
$children = $pages->concat($chapters); $children = $pages->concat($chapters);
foreach ($children as $bookChild) { foreach ($children as $bookChild) {
/** @var Reference[] $childRefs */ /** @var Reference[] $childRefs */
$childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild, true)->values()->all(); $childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild)->values()->all();
array_push($references, ...$childRefs); array_push($references, ...$childRefs);
} }
} }
@@ -64,16 +64,16 @@ class ReferenceUpdater
$this->updateReferencesWithinPage($entity, $oldLink, $newLink); $this->updateReferencesWithinPage($entity, $oldLink, $newLink);
} }
if ($entity instanceof HasDescriptionInterface) { if ($entity instanceof HtmlDescriptionInterface) {
$this->updateReferencesWithinDescription($entity, $oldLink, $newLink); $this->updateReferencesWithinDescription($entity, $oldLink, $newLink);
} }
} }
protected function updateReferencesWithinDescription(Entity&HasDescriptionInterface $entity, string $oldLink, string $newLink): void protected function updateReferencesWithinDescription(Entity&HtmlDescriptionInterface $entity, string $oldLink, string $newLink): void
{ {
$description = $entity->descriptionInfo(); $entity = (clone $entity)->refresh();
$html = $this->updateLinksInHtml($description->getHtml(true) ?: '', $oldLink, $newLink); $html = $this->updateLinksInHtml($entity->descriptionHtml(true) ?: '', $oldLink, $newLink);
$description->set($html); $entity->setDescriptionHtml($html);
$entity->save(); $entity->save();
} }

View File

@@ -1,13 +1,10 @@
<?php <?php
declare(strict_types=1);
namespace BookStack\Search; namespace BookStack\Search;
use BookStack\Api\ApiEntityListFormatter; use BookStack\Api\ApiEntityListFormatter;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Http\ApiController; use BookStack\Http\ApiController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class SearchApiController extends ApiController class SearchApiController extends ApiController
@@ -34,9 +31,11 @@ class SearchApiController extends ApiController
* between: bookshelf, book, chapter & page. * between: bookshelf, book, chapter & page.
* *
* The paging parameters and response format emulates a standard listing endpoint * The paging parameters and response format emulates a standard listing endpoint
* but standard sorting and filtering cannot be done on this endpoint. * but standard sorting and filtering cannot be done on this endpoint. If a count value
* is provided this will only be taken as a suggestion. The results in the response
* may currently be up to 4x this value.
*/ */
public function all(Request $request): JsonResponse public function all(Request $request)
{ {
$this->validate($request, $this->rules['all']); $this->validate($request, $this->rules['all']);

View File

@@ -7,7 +7,6 @@ use BookStack\Entities\Queries\QueryPopular;
use BookStack\Entities\Tools\SiblingFetcher; use BookStack\Entities\Tools\SiblingFetcher;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
class SearchController extends Controller class SearchController extends Controller
{ {
@@ -24,21 +23,20 @@ class SearchController extends Controller
{ {
$searchOpts = SearchOptions::fromRequest($request); $searchOpts = SearchOptions::fromRequest($request);
$fullSearchString = $searchOpts->toString(); $fullSearchString = $searchOpts->toString();
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
$page = intval($request->get('page', '0')) ?: 1; $page = intval($request->get('page', '0')) ?: 1;
$nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page + 1));
$results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20); $results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
$formatter->format($results['results']->all(), $searchOpts); $formatter->format($results['results']->all(), $searchOpts);
$paginator = new LengthAwarePaginator($results['results'], $results['total'], 20, $page);
$paginator->setPath('/search');
$paginator->appends($request->except('page'));
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
return view('search.all', [ return view('search.all', [
'entities' => $results['results'], 'entities' => $results['results'],
'totalResults' => $results['total'], 'totalResults' => $results['total'],
'paginator' => $paginator,
'searchTerm' => $fullSearchString, 'searchTerm' => $fullSearchString,
'hasNextPage' => $results['has_more'],
'nextPageLink' => $nextPageLink,
'options' => $searchOpts, 'options' => $searchOpts,
]); ]);
} }
@@ -130,7 +128,7 @@ class SearchController extends Controller
} }
/** /**
* Search sibling items in the system. * Search siblings items in the system.
*/ */
public function searchSiblings(Request $request, SiblingFetcher $siblingFetcher) public function searchSiblings(Request $request, SiblingFetcher $siblingFetcher)
{ {

View File

@@ -126,7 +126,7 @@ class SearchIndex
$termMap = $this->textToTermCountMap($text); $termMap = $this->textToTermCountMap($text);
foreach ($termMap as $term => $count) { foreach ($termMap as $term => $count) {
$termMap[$term] = intval($count * $scoreAdjustment); $termMap[$term] = floor($count * $scoreAdjustment);
} }
return $termMap; return $termMap;

View File

@@ -91,7 +91,7 @@ class SearchResultsFormatter
$offset = 0; $offset = 0;
$term = mb_strtolower($term); $term = mb_strtolower($term);
$pos = mb_strpos($text, $term, $offset); $pos = mb_strpos($text, $term, $offset);
while ($pos !== false && count($matchRefs) < 25) { while ($pos !== false) {
$end = $pos + mb_strlen($term); $end = $pos + mb_strlen($term);
$matchRefs[$pos] = $end; $matchRefs[$pos] = $end;
$offset = $end; $offset = $end;

View File

@@ -4,15 +4,16 @@ namespace BookStack\Search;
use BookStack\Entities\EntityProvider; use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\EntityHydrator;
use BookStack\Permissions\PermissionApplicator; use BookStack\Permissions\PermissionApplicator;
use BookStack\Search\Options\TagSearchOption; use BookStack\Search\Options\TagSearchOption;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Database\Connection; use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -21,7 +22,7 @@ use WeakMap;
class SearchRunner class SearchRunner
{ {
/** /**
* Retain a cache of score-adjusted terms for specific search options. * Retain a cache of score adjusted terms for specific search options.
*/ */
protected WeakMap $termAdjustmentCache; protected WeakMap $termAdjustmentCache;
@@ -29,15 +30,16 @@ class SearchRunner
protected EntityProvider $entityProvider, protected EntityProvider $entityProvider,
protected PermissionApplicator $permissions, protected PermissionApplicator $permissions,
protected EntityQueries $entityQueries, protected EntityQueries $entityQueries,
protected EntityHydrator $entityHydrator,
) { ) {
$this->termAdjustmentCache = new WeakMap(); $this->termAdjustmentCache = new WeakMap();
} }
/** /**
* Search all entities in the system. * Search all entities in the system.
* The provided count is for each entity to search,
* Total returned could be larger and not guaranteed.
* *
* @return array{total: int, results: Collection<Entity>} * @return array{total: int, count: int, has_more: bool, results: Collection<Entity>}
*/ */
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20): array public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20): array
{ {
@@ -51,13 +53,32 @@ class SearchRunner
$entityTypesToSearch = explode('|', $filterMap['type']); $entityTypesToSearch = explode('|', $filterMap['type']);
} }
$searchQuery = $this->buildQuery($searchOpts, $entityTypesToSearch); $results = collect();
$total = $searchQuery->count(); $total = 0;
$results = $this->getPageOfDataFromQuery($searchQuery, $page, $count); $hasMore = false;
foreach ($entityTypesToSearch as $entityType) {
if (!in_array($entityType, $entityTypes)) {
continue;
}
$searchQuery = $this->buildQuery($searchOpts, $entityType);
$entityTotal = $searchQuery->count();
$searchResults = $this->getPageOfDataFromQuery($searchQuery, $entityType, $page, $count);
if ($entityTotal > ($page * $count)) {
$hasMore = true;
}
$total += $entityTotal;
$results = $results->merge($searchResults);
}
return [ return [
'total' => $total, 'total' => $total,
'results' => $results->values(), 'count' => count($results),
'has_more' => $hasMore,
'results' => $results->sortByDesc('score')->values(),
]; ];
} }
@@ -71,10 +92,17 @@ class SearchRunner
$filterMap = $opts->filters->toValueMap(); $filterMap = $opts->filters->toValueMap();
$entityTypesToSearch = isset($filterMap['type']) ? explode('|', $filterMap['type']) : $entityTypes; $entityTypesToSearch = isset($filterMap['type']) ? explode('|', $filterMap['type']) : $entityTypes;
$filteredTypes = array_intersect($entityTypesToSearch, $entityTypes); $results = collect();
$query = $this->buildQuery($opts, $filteredTypes)->where('book_id', '=', $bookId); foreach ($entityTypesToSearch as $entityType) {
if (!in_array($entityType, $entityTypes)) {
continue;
}
return $this->getPageOfDataFromQuery($query, 1, 20)->sortByDesc('score'); $search = $this->buildQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
$results = $results->merge($search);
}
return $results->sortByDesc('score')->take(20);
} }
/** /**
@@ -83,45 +111,54 @@ class SearchRunner
public function searchChapter(int $chapterId, string $searchString): Collection public function searchChapter(int $chapterId, string $searchString): Collection
{ {
$opts = SearchOptions::fromString($searchString); $opts = SearchOptions::fromString($searchString);
$query = $this->buildQuery($opts, ['page'])->where('chapter_id', '=', $chapterId); $pages = $this->buildQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
return $this->getPageOfDataFromQuery($query, 1, 20)->sortByDesc('score'); return $pages->sortByDesc('score');
} }
/** /**
* Get a page of result data from the given query based on the provided page parameters. * Get a page of result data from the given query based on the provided page parameters.
*/ */
protected function getPageOfDataFromQuery(EloquentBuilder $query, int $page, int $count): Collection protected function getPageOfDataFromQuery(EloquentBuilder $query, string $entityType, int $page = 1, int $count = 20): EloquentCollection
{ {
$entities = $query->clone() $relations = ['tags'];
if ($entityType === 'page' || $entityType === 'chapter') {
$relations['book'] = function (BelongsTo $query) {
$query->scopes('visible');
};
}
if ($entityType === 'page') {
$relations['chapter'] = function (BelongsTo $query) {
$query->scopes('visible');
};
}
return $query->clone()
->with(array_filter($relations))
->skip(($page - 1) * $count) ->skip(($page - 1) * $count)
->take($count) ->take($count)
->get(); ->get();
$hydrated = $this->entityHydrator->hydrate($entities->all(), true, true);
return collect($hydrated);
} }
/** /**
* Create a search query for an entity. * Create a search query for an entity.
* @param string[] $entityTypes
*/ */
protected function buildQuery(SearchOptions $searchOpts, array $entityTypes): EloquentBuilder protected function buildQuery(SearchOptions $searchOpts, string $entityType): EloquentBuilder
{ {
$entityQuery = $this->entityQueries->visibleForList() $entityModelInstance = $this->entityProvider->get($entityType);
->whereIn('type', $entityTypes); $entityQuery = $this->entityQueries->visibleForList($entityType);
// Handle normal search terms // Handle normal search terms
$this->applyTermSearch($entityQuery, $searchOpts, $entityTypes); $this->applyTermSearch($entityQuery, $searchOpts, $entityType);
// Handle exact term matching // Handle exact term matching
foreach ($searchOpts->exacts->all() as $exact) { foreach ($searchOpts->exacts->all() as $exact) {
$filter = function (EloquentBuilder $query) use ($exact) { $filter = function (EloquentBuilder $query) use ($exact, $entityModelInstance) {
$inputTerm = str_replace('\\', '\\\\', $exact->value); $inputTerm = str_replace('\\', '\\\\', $exact->value);
$query->where('name', 'like', '%' . $inputTerm . '%') $query->where('name', 'like', '%' . $inputTerm . '%')
->orWhere('description', 'like', '%' . $inputTerm . '%') ->orWhere($entityModelInstance->textField, 'like', '%' . $inputTerm . '%');
->orWhere('text', 'like', '%' . $inputTerm . '%');
}; };
$exact->negated ? $entityQuery->whereNot($filter) : $entityQuery->where($filter); $exact->negated ? $entityQuery->whereNot($filter) : $entityQuery->where($filter);
@@ -136,7 +173,7 @@ class SearchRunner
foreach ($searchOpts->filters->all() as $filterOption) { foreach ($searchOpts->filters->all() as $filterOption) {
$functionName = Str::camel('filter_' . $filterOption->getKey()); $functionName = Str::camel('filter_' . $filterOption->getKey());
if (method_exists($this, $functionName)) { if (method_exists($this, $functionName)) {
$this->$functionName($entityQuery, $filterOption->value, $filterOption->negated); $this->$functionName($entityQuery, $entityModelInstance, $filterOption->value, $filterOption->negated);
} }
} }
@@ -146,7 +183,7 @@ class SearchRunner
/** /**
* For the given search query, apply the queries for handling the regular search terms. * For the given search query, apply the queries for handling the regular search terms.
*/ */
protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, array $entityTypes): void protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, string $entityType): void
{ {
$terms = $options->searches->toValueArray(); $terms = $options->searches->toValueArray();
if (count($terms) === 0) { if (count($terms) === 0) {
@@ -163,6 +200,8 @@ class SearchRunner
]); ]);
$subQuery->addBinding($scoreSelect['bindings'], 'select'); $subQuery->addBinding($scoreSelect['bindings'], 'select');
$subQuery->where('entity_type', '=', $entityType);
$subQuery->where(function (Builder $query) use ($terms) { $subQuery->where(function (Builder $query) use ($terms) {
foreach ($terms as $inputTerm) { foreach ($terms as $inputTerm) {
$escapedTerm = str_replace('\\', '\\\\', $inputTerm); $escapedTerm = str_replace('\\', '\\\\', $inputTerm);
@@ -171,10 +210,7 @@ class SearchRunner
}); });
$subQuery->groupBy('entity_type', 'entity_id'); $subQuery->groupBy('entity_type', 'entity_id');
$entityQuery->joinSub($subQuery, 's', function (JoinClause $join) { $entityQuery->joinSub($subQuery, 's', 'id', '=', 'entity_id');
$join->on('s.entity_id', '=', 'entities.id')
->on('s.entity_type', '=', 'entities.type');
});
$entityQuery->addSelect('s.score'); $entityQuery->addSelect('s.score');
$entityQuery->orderBy('score', 'desc'); $entityQuery->orderBy('score', 'desc');
} }
@@ -302,7 +338,7 @@ class SearchRunner
$option->negated ? $query->whereDoesntHave('tags', $filter) : $query->whereHas('tags', $filter); $option->negated ? $query->whereDoesntHave('tags', $filter) : $query->whereHas('tags', $filter);
} }
protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string|callable $column, string|null $operator, mixed $value): void protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string $column, string $operator, mixed $value): void
{ {
if ($negated) { if ($negated) {
$query->whereNot($column, $operator, $value); $query->whereNot($column, $operator, $value);
@@ -314,31 +350,31 @@ class SearchRunner
/** /**
* Custom entity search filters. * Custom entity search filters.
*/ */
protected function filterUpdatedAfter(EloquentBuilder $query, string $input, bool $negated): void protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
{ {
$date = date_create($input); $date = date_create($input);
$this->applyNegatableWhere($query, $negated, 'updated_at', '>=', $date); $this->applyNegatableWhere($query, $negated, 'updated_at', '>=', $date);
} }
protected function filterUpdatedBefore(EloquentBuilder $query, string $input, bool $negated): void protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
{ {
$date = date_create($input); $date = date_create($input);
$this->applyNegatableWhere($query, $negated, 'updated_at', '<', $date); $this->applyNegatableWhere($query, $negated, 'updated_at', '<', $date);
} }
protected function filterCreatedAfter(EloquentBuilder $query, string $input, bool $negated): void protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
{ {
$date = date_create($input); $date = date_create($input);
$this->applyNegatableWhere($query, $negated, 'created_at', '>=', $date); $this->applyNegatableWhere($query, $negated, 'created_at', '>=', $date);
} }
protected function filterCreatedBefore(EloquentBuilder $query, string $input, bool $negated) protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{ {
$date = date_create($input); $date = date_create($input);
$this->applyNegatableWhere($query, $negated, 'created_at', '<', $date); $this->applyNegatableWhere($query, $negated, 'created_at', '<', $date);
} }
protected function filterCreatedBy(EloquentBuilder $query, string $input, bool $negated) protected function filterCreatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{ {
$userSlug = $input === 'me' ? user()->slug : trim($input); $userSlug = $input === 'me' ? user()->slug : trim($input);
$user = User::query()->where('slug', '=', $userSlug)->first(['id']); $user = User::query()->where('slug', '=', $userSlug)->first(['id']);
@@ -347,7 +383,7 @@ class SearchRunner
} }
} }
protected function filterUpdatedBy(EloquentBuilder $query, string $input, bool $negated) protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{ {
$userSlug = $input === 'me' ? user()->slug : trim($input); $userSlug = $input === 'me' ? user()->slug : trim($input);
$user = User::query()->where('slug', '=', $userSlug)->first(['id']); $user = User::query()->where('slug', '=', $userSlug)->first(['id']);
@@ -356,7 +392,7 @@ class SearchRunner
} }
} }
protected function filterOwnedBy(EloquentBuilder $query, string $input, bool $negated) protected function filterOwnedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{ {
$userSlug = $input === 'me' ? user()->slug : trim($input); $userSlug = $input === 'me' ? user()->slug : trim($input);
$user = User::query()->where('slug', '=', $userSlug)->first(['id']); $user = User::query()->where('slug', '=', $userSlug)->first(['id']);
@@ -365,30 +401,27 @@ class SearchRunner
} }
} }
protected function filterInName(EloquentBuilder $query, string $input, bool $negated) protected function filterInName(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{ {
$this->applyNegatableWhere($query, $negated, 'name', 'like', '%' . $input . '%'); $this->applyNegatableWhere($query, $negated, 'name', 'like', '%' . $input . '%');
} }
protected function filterInTitle(EloquentBuilder $query, string $input, bool $negated) protected function filterInTitle(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{ {
$this->filterInName($query, $input, $negated); $this->filterInName($query, $model, $input, $negated);
} }
protected function filterInBody(EloquentBuilder $query, string $input, bool $negated) protected function filterInBody(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{ {
$this->applyNegatableWhere($query, $negated, function (EloquentBuilder $query) use ($input) { $this->applyNegatableWhere($query, $negated, $model->textField, 'like', '%' . $input . '%');
$query->where('description', 'like', '%' . $input . '%')
->orWhere('text', 'like', '%' . $input . '%');
}, null, null);
} }
protected function filterIsRestricted(EloquentBuilder $query, string $input, bool $negated) protected function filterIsRestricted(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{ {
$negated ? $query->whereDoesntHave('permissions') : $query->whereHas('permissions'); $negated ? $query->whereDoesntHave('permissions') : $query->whereHas('permissions');
} }
protected function filterViewedByMe(EloquentBuilder $query, string $input, bool $negated) protected function filterViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{ {
$filter = function ($query) { $filter = function ($query) {
$query->where('user_id', '=', user()->id); $query->where('user_id', '=', user()->id);
@@ -397,7 +430,7 @@ class SearchRunner
$negated ? $query->whereDoesntHave('views', $filter) : $query->whereHas('views', $filter); $negated ? $query->whereDoesntHave('views', $filter) : $query->whereHas('views', $filter);
} }
protected function filterNotViewedByMe(EloquentBuilder $query, string $input, bool $negated) protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{ {
$filter = function ($query) { $filter = function ($query) {
$query->where('user_id', '=', user()->id); $query->where('user_id', '=', user()->id);
@@ -406,30 +439,31 @@ class SearchRunner
$negated ? $query->whereHas('views', $filter) : $query->whereDoesntHave('views', $filter); $negated ? $query->whereHas('views', $filter) : $query->whereDoesntHave('views', $filter);
} }
protected function filterIsTemplate(EloquentBuilder $query, string $input, bool $negated) protected function filterIsTemplate(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{ {
$this->applyNegatableWhere($query, $negated, 'template', '=', true); if ($model instanceof Page) {
$this->applyNegatableWhere($query, $negated, 'template', '=', true);
}
} }
protected function filterSortBy(EloquentBuilder $query, string $input, bool $negated) protected function filterSortBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
{ {
$functionName = Str::camel('sort_by_' . $input); $functionName = Str::camel('sort_by_' . $input);
if (method_exists($this, $functionName)) { if (method_exists($this, $functionName)) {
$this->$functionName($query, $negated); $this->$functionName($query, $model, $negated);
} }
} }
/** /**
* Sorting filter options. * Sorting filter options.
*/ */
protected function sortByLastCommented(EloquentBuilder $query, bool $negated) protected function sortByLastCommented(EloquentBuilder $query, Entity $model, bool $negated)
{ {
$commentsTable = DB::getTablePrefix() . 'comments'; $commentsTable = DB::getTablePrefix() . '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 c2.created_at IS NULL) as 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');
$query->join($commentQuery, function (JoinClause $join) { $query->join($commentQuery, $model->getTable() . '.id', '=', DB::raw('comments.entity_id'))
$join->on('entities.id', '=', 'comments.commentable_id') ->orderBy('last_commented', $negated ? 'asc' : 'desc');
->on('entities.type', '=', 'comments.commentable_type');
})->orderBy('last_commented', $negated ? 'asc' : 'desc');
} }
} }

View File

@@ -33,22 +33,22 @@ class BookSorter
*/ */
public function runBookAutoSort(Book $book): void public function runBookAutoSort(Book $book): void
{ {
$rule = $book->sortRule()->first(); $set = $book->sortRule;
if (!($rule instanceof SortRule)) { if (!$set) {
return; return;
} }
$sortFunctions = array_map(function (SortRuleOperation $op) { $sortFunctions = array_map(function (SortRuleOperation $op) {
return $op->getSortFunction(); return $op->getSortFunction();
}, $rule->getOperations()); }, $set->getOperations());
$chapters = $book->chapters() $chapters = $book->chapters()
->with('pages:id,name,book_id,chapter_id,priority,created_at,updated_at') ->with('pages:id,name,priority,created_at,updated_at,chapter_id')
->get(['id', 'name', 'priority', 'created_at', 'updated_at']); ->get(['id', 'name', 'priority', 'created_at', 'updated_at']);
/** @var (Chapter|Book)[] $topItems */ /** @var (Chapter|Book)[] $topItems */
$topItems = [ $topItems = [
...$book->directPages()->get(['id', 'book_id', 'name', 'priority', 'created_at', 'updated_at']), ...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']),
...$chapters, ...$chapters,
]; ];
@@ -155,12 +155,11 @@ class BookSorter
// Action the required changes // Action the required changes
if ($bookChanged) { if ($bookChanged) {
$model = $model->changeBook($newBook->id); $model->changeBook($newBook->id);
} }
if ($model instanceof Page && $chapterChanged) { if ($model instanceof Page && $chapterChanged) {
$model->chapter_id = $newChapter->id ?? 0; $model->chapter_id = $newChapter->id ?? 0;
$model->unsetRelation('chapter');
} }
if ($priorityChanged) { if ($priorityChanged) {

View File

@@ -50,7 +50,7 @@ class SortRule extends Model implements Loggable
public function books(): HasMany public function books(): HasMany
{ {
return $this->hasMany(Book::class, 'entity_container_data.sort_rule_id', 'id'); return $this->hasMany(Book::class);
} }
public static function allByName(): Collection public static function allByName(): Collection

View File

@@ -3,7 +3,6 @@
namespace BookStack\Sorting; namespace BookStack\Sorting;
use BookStack\Activity\ActivityType; use BookStack\Activity\ActivityType;
use BookStack\Entities\Models\EntityContainerData;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Permissions\Permission; use BookStack\Permissions\Permission;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -89,9 +88,7 @@ class SortRuleController extends Controller
if ($booksAssigned > 0) { if ($booksAssigned > 0) {
if ($confirmed) { if ($confirmed) {
EntityContainerData::query() $rule->books()->update(['sort_rule_id' => null]);
->where('sort_rule_id', $rule->id)
->update(['sort_rule_id' => null]);
} else { } else {
$warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]); $warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]);
} }

View File

@@ -2,7 +2,6 @@
namespace BookStack\Uploads\Controllers; namespace BookStack\Uploads\Controllers;
use BookStack\Entities\EntityExistsRule;
use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Queries\PageQueries;
use BookStack\Exceptions\FileUploadException; use BookStack\Exceptions\FileUploadException;
use BookStack\Http\ApiController; use BookStack\Http\ApiController;
@@ -174,13 +173,13 @@ class AttachmentApiController extends ApiController
return [ return [
'create' => [ 'create' => [
'name' => ['required', 'string', 'min:1', 'max:255'], 'name' => ['required', 'string', 'min:1', 'max:255'],
'uploaded_to' => ['required', 'integer', new EntityExistsRule('page')], 'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'file' => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()), 'file' => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()),
'link' => ['required_without:file', 'string', 'min:1', 'max:2000', 'safe_url'], 'link' => ['required_without:file', 'string', 'min:1', 'max:2000', 'safe_url'],
], ],
'update' => [ 'update' => [
'name' => ['string', 'min:1', 'max:255'], 'name' => ['string', 'min:1', 'max:255'],
'uploaded_to' => ['integer', new EntityExistsRule('page')], 'uploaded_to' => ['integer', 'exists:pages,id'],
'file' => $this->attachmentService->getFileValidationRules(), 'file' => $this->attachmentService->getFileValidationRules(),
'link' => ['string', 'min:1', 'max:2000', 'safe_url'], 'link' => ['string', 'min:1', 'max:2000', 'safe_url'],
], ],

View File

@@ -2,7 +2,6 @@
namespace BookStack\Uploads\Controllers; namespace BookStack\Uploads\Controllers;
use BookStack\Entities\EntityExistsRule;
use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\PageRepo; use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\FileUploadException; use BookStack\Exceptions\FileUploadException;
@@ -35,7 +34,7 @@ class AttachmentController extends Controller
public function upload(Request $request) public function upload(Request $request)
{ {
$this->validate($request, [ $this->validate($request, [
'uploaded_to' => ['required', 'integer', new EntityExistsRule('page')], 'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()), 'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()),
]); ]);
@@ -145,7 +144,7 @@ class AttachmentController extends Controller
try { try {
$this->validate($request, [ $this->validate($request, [
'attachment_link_uploaded_to' => ['required', 'integer', new EntityExistsRule('page')], 'attachment_link_uploaded_to' => ['required', 'integer', 'exists:pages,id'],
'attachment_link_name' => ['required', 'string', 'min:1', 'max:255'], 'attachment_link_name' => ['required', 'string', 'min:1', 'max:255'],
'attachment_link_url' => ['required', 'string', 'min:1', 'max:2000', 'safe_url'], 'attachment_link_url' => ['required', 'string', 'min:1', 'max:2000', 'safe_url'],
]); ]);

View File

@@ -3,13 +3,11 @@
namespace BookStack\Uploads\Controllers; namespace BookStack\Uploads\Controllers;
use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Queries\PageQueries;
use BookStack\Exceptions\NotFoundException;
use BookStack\Http\ApiController; use BookStack\Http\ApiController;
use BookStack\Permissions\Permission; use BookStack\Permissions\Permission;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageResizer; use BookStack\Uploads\ImageResizer;
use BookStack\Uploads\ImageService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class ImageGalleryApiController extends ApiController class ImageGalleryApiController extends ApiController
@@ -22,7 +20,6 @@ class ImageGalleryApiController extends ApiController
protected ImageRepo $imageRepo, protected ImageRepo $imageRepo,
protected ImageResizer $imageResizer, protected ImageResizer $imageResizer,
protected PageQueries $pageQueries, protected PageQueries $pageQueries,
protected ImageService $imageService,
) { ) {
} }
@@ -35,9 +32,6 @@ class ImageGalleryApiController extends ApiController
'image' => ['required', 'file', ...$this->getImageValidationRules()], 'image' => ['required', 'file', ...$this->getImageValidationRules()],
'name' => ['string', 'max:180'], 'name' => ['string', 'max:180'],
], ],
'readDataForUrl' => [
'url' => ['required', 'string', 'url'],
],
'update' => [ 'update' => [
'name' => ['string', 'max:180'], 'name' => ['string', 'max:180'],
'image' => ['file', ...$this->getImageValidationRules()], 'image' => ['file', ...$this->getImageValidationRules()],
@@ -91,8 +85,7 @@ class ImageGalleryApiController extends ApiController
* The "thumbs" response property contains links to scaled variants that BookStack may use in its UI. * The "thumbs" response property contains links to scaled variants that BookStack may use in its UI.
* The "content" response property provides HTML and Markdown content, in the format that BookStack * The "content" response property provides HTML and Markdown content, in the format that BookStack
* would typically use by default to add the image in page content, as a convenience. * would typically use by default to add the image in page content, as a convenience.
* Actual image file data is not provided but can be fetched via the "url" response property or by * Actual image file data is not provided but can be fetched via the "url" response property.
* using the "read-data" endpoint.
*/ */
public function read(string $id) public function read(string $id)
{ {
@@ -101,37 +94,6 @@ class ImageGalleryApiController extends ApiController
return response()->json($this->formatForSingleResponse($image)); return response()->json($this->formatForSingleResponse($image));
} }
/**
* Read the image file data for a single image in the system.
* The returned response will be a stream of image data instead of a JSON response.
*/
public function readData(string $id)
{
$image = Image::query()->scopes(['visible'])->findOrFail($id);
return $this->imageService->streamImageFromStorageResponse('gallery', $image->path);
}
/**
* Read the image file data for a single image in the system, using the provided URL
* to identify the image instead of its ID, which is provided as a "URL" query parameter.
* The returned response will be a stream of image data instead of a JSON response.
*/
public function readDataForUrl(Request $request)
{
$data = $this->validate($request, $this->rules()['readDataForUrl']);
$basePath = url('/uploads/images/');
$imagePath = str_replace($basePath, '', $data['url']);
if (!$this->imageService->pathAccessible($imagePath)) {
throw (new NotFoundException(trans('errors.image_not_found')))
->setSubtitle(trans('errors.image_not_found_subtitle'))
->setDetails(trans('errors.image_not_found_details'));
}
return $this->imageService->streamImageFromStorageResponse('gallery', $imagePath);
}
/** /**
* Update the details of an existing image in the system. * Update the details of an existing image in the system.
* Since "image" is expected to be a file, this needs to be a 'multipart/form-data' type request if providing a * Since "image" is expected to be a file, this needs to be a 'multipart/form-data' type request if providing a

View File

@@ -13,14 +13,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
/** /**
* @property int $id * @property int $id
* @property string $name * @property string $name
* @property string $url * @property string $url
* @property string $path * @property string $path
* @property string $type * @property string $type
* @property int|null $uploaded_to * @property int $uploaded_to
* @property int $created_by * @property int $created_by
* @property int $updated_by * @property int $updated_by
*/ */
class Image extends Model implements OwnableInterface class Image extends Model implements OwnableInterface
{ {
@@ -42,9 +42,7 @@ class Image extends Model implements OwnableInterface
*/ */
public function scopeVisible(Builder $query): Builder public function scopeVisible(Builder $query): Builder
{ {
return app()->make(PermissionApplicator::class) return app()->make(PermissionApplicator::class)->restrictPageRelationQuery($query, 'images', 'uploaded_to');
->restrictPageRelationQuery($query, 'images', 'uploaded_to')
->whereIn('type', ['gallery', 'drawio']);
} }
/** /**

View File

@@ -148,7 +148,7 @@ class ImageService
} }
/** /**
* Destroy an image along with its revisions, thumbnails, and remaining folders. * Destroy an image along with its revisions, thumbnails and remaining folders.
* *
* @throws Exception * @throws Exception
*/ */
@@ -184,7 +184,7 @@ class ImageService
/** @var Image $image */ /** @var Image $image */
foreach ($images as $image) { foreach ($images as $image) {
$searchQuery = '%' . basename($image->path) . '%'; $searchQuery = '%' . basename($image->path) . '%';
$inPage = DB::table('entity_page_data') $inPage = DB::table('pages')
->where('html', 'like', $searchQuery)->count() > 0; ->where('html', 'like', $searchQuery)->count() > 0;
$inRevision = false; $inRevision = false;
@@ -252,59 +252,16 @@ class ImageService
{ {
$disk = $this->storage->getDisk('gallery'); $disk = $this->storage->getDisk('gallery');
return $disk->usingSecureImages() && $this->pathAccessible($imagePath);
}
/**
* Check if the given path exists and is accessible depending on the current settings.
*/
public function pathAccessible(string $imagePath): bool
{
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) { if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
return false; return false;
} }
if ($this->blockedBySecureImages()) { // Check local_secure is active
return false; return $disk->usingSecureImages()
} // Check the image file exists
&& $disk->exists($imagePath)
return $this->imageFileExists($imagePath, 'gallery'); // Check the file is likely an image file
} && str_starts_with($disk->mimeType($imagePath), 'image/');
/**
* Check if the given image should be accessible to the current user.
*/
public function imageAccessible(Image $image): bool
{
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImage($image)) {
return false;
}
if ($this->blockedBySecureImages()) {
return false;
}
return $this->imageFileExists($image->path, $image->type);
}
/**
* Check if the current user should be blocked from accessing images based on if secure images are enabled
* and if public access is enabled for the application.
*/
protected function blockedBySecureImages(): bool
{
$enforced = $this->storage->usingSecureImages() && !setting('app-public');
return $enforced && user()->isGuest();
}
/**
* Check if the given image path exists for the given image type and that it is likely an image file.
*/
protected function imageFileExists(string $imagePath, string $imageType): bool
{
$disk = $this->storage->getDisk($imageType);
return $disk->exists($imagePath) && str_starts_with($disk->mimeType($imagePath), 'image/');
} }
/** /**
@@ -333,11 +290,6 @@ class ImageService
return false; return false;
} }
return $this->checkUserHasAccessToRelationOfImage($image);
}
protected function checkUserHasAccessToRelationOfImage(Image $image): bool
{
$imageType = $image->type; $imageType = $image->type;
// Allow user or system (logo) images // Allow user or system (logo) images

View File

@@ -34,15 +34,6 @@ class ImageStorage
return config('filesystems.images') === 'local_secure_restricted'; return config('filesystems.images') === 'local_secure_restricted';
} }
/**
* Check if "local secure" (Fetched behind auth, either with or without permissions enforced)
* is currently active in the instance.
*/
public function usingSecureImages(): bool
{
return config('filesystems.images') === 'local_secure' || $this->usingSecureRestrictedImages();
}
/** /**
* Clean up an image file name to be both URL and storage safe. * Clean up an image file name to be both URL and storage safe.
*/ */
@@ -74,7 +65,7 @@ class ImageStorage
return 'local'; return 'local';
} }
// Rename local_secure options to get our image-specific storage driver, which // Rename local_secure options to get our image specific storage driver which
// is scoped to the relevant image directories. // is scoped to the relevant image directories.
if ($localSecureInUse) { if ($localSecureInUse) {
return 'local_secure_images'; return 'local_secure_images';

View File

@@ -2,7 +2,6 @@
namespace BookStack\Users\Controllers; namespace BookStack\Users\Controllers;
use BookStack\Entities\EntityExistsRule;
use BookStack\Exceptions\UserUpdateException; use BookStack\Exceptions\UserUpdateException;
use BookStack\Http\ApiController; use BookStack\Http\ApiController;
use BookStack\Permissions\Permission; use BookStack\Permissions\Permission;

View File

@@ -31,6 +31,8 @@ use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
/** /**
* Class User.
*
* @property int $id * @property int $id
* @property string $name * @property string $name
* @property string $slug * @property string $slug

View File

@@ -5,13 +5,13 @@ namespace BookStack\Users;
use BookStack\Access\UserInviteException; use BookStack\Access\UserInviteException;
use BookStack\Access\UserInviteService; use BookStack\Access\UserInviteService;
use BookStack\Activity\ActivityType; use BookStack\Activity\ActivityType;
use BookStack\Entities\EntityProvider;
use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\UserUpdateException; use BookStack\Exceptions\UserUpdateException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Uploads\UserAvatars; use BookStack\Uploads\UserAvatars;
use BookStack\Users\Models\Role; use BookStack\Users\Models\Role;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use DB;
use Exception; use Exception;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -25,6 +25,7 @@ class UserRepo
) { ) {
} }
/** /**
* Get a user by their email address. * Get a user by their email address.
*/ */
@@ -158,12 +159,15 @@ class UserRepo
* *
* @throws Exception * @throws Exception
*/ */
public function destroy(User $user, ?int $newOwnerId = null): void public function destroy(User $user, ?int $newOwnerId = null)
{ {
$this->ensureDeletable($user); $this->ensureDeletable($user);
$this->removeUserDependantRelations($user); $user->socialAccounts()->delete();
$this->nullifyUserNonDependantRelations($user); $user->apiTokens()->delete();
$user->favourites()->delete();
$user->mfaValues()->delete();
$user->watches()->delete();
$user->delete(); $user->delete();
// Delete user profile images // Delete user profile images
@@ -172,50 +176,14 @@ class UserRepo
// Delete related activities // Delete related activities
setting()->deleteUserSettings($user->id); setting()->deleteUserSettings($user->id);
// Migrate or nullify ownership
$newOwner = null;
if (!empty($newOwnerId)) { if (!empty($newOwnerId)) {
$newOwner = User::query()->find($newOwnerId); $newOwner = User::query()->find($newOwnerId);
} if (!is_null($newOwner)) {
$this->migrateOwnership($user, $newOwner); $this->migrateOwnership($user, $newOwner);
Activity::add(ActivityType::USER_DELETE, $user);
}
protected function removeUserDependantRelations(User $user): void
{
$user->apiTokens()->delete();
$user->socialAccounts()->delete();
$user->favourites()->delete();
$user->mfaValues()->delete();
$user->watches()->delete();
$tables = ['email_confirmations', 'user_invites', 'views'];
foreach ($tables as $table) {
DB::table($table)->where('user_id', '=', $user->id)->delete();
}
}
protected function nullifyUserNonDependantRelations(User $user): void
{
$toNullify = [
'attachments' => ['created_by', 'updated_by'],
'comments' => ['created_by', 'updated_by'],
'deletions' => ['deleted_by'],
'entities' => ['created_by', 'updated_by'],
'images' => ['created_by', 'updated_by'],
'imports' => ['created_by'],
'joint_permissions' => ['owner_id'],
'page_revisions' => ['created_by'],
'sessions' => ['user_id'],
];
foreach ($toNullify as $table => $columns) {
foreach ($columns as $column) {
DB::table($table)
->where($column, '=', $user->id)
->update([$column => null]);
} }
} }
Activity::add(ActivityType::USER_DELETE, $user);
} }
/** /**
@@ -235,12 +203,13 @@ class UserRepo
/** /**
* Migrate ownership of items in the system from one user to another. * Migrate ownership of items in the system from one user to another.
*/ */
protected function migrateOwnership(User $fromUser, User|null $toUser): void protected function migrateOwnership(User $fromUser, User $toUser)
{ {
$newOwnerValue = $toUser ? $toUser->id : null; $entities = (new EntityProvider())->all();
DB::table('entities') foreach ($entities as $instance) {
->where('owned_by', '=', $fromUser->id) $instance->newQuery()->where('owned_by', '=', $fromUser->id)
->update(['owned_by' => $newOwnerValue]); ->update(['owned_by' => $toUser->id]);
}
} }
/** /**
@@ -278,7 +247,7 @@ class UserRepo
* *
* @throws UserUpdateException * @throws UserUpdateException
*/ */
protected function setUserRoles(User $user, array $roles): void protected function setUserRoles(User $user, array $roles)
{ {
$roles = array_filter(array_values($roles)); $roles = array_filter(array_values($roles));
@@ -291,7 +260,7 @@ class UserRepo
/** /**
* Check if the given user is the last admin and their new roles no longer * Check if the given user is the last admin and their new roles no longer
* contain the admin role. * contains the admin role.
*/ */
protected function demotingLastAdmin(User $user, array $newRoles): bool protected function demotingLastAdmin(User $user, array $newRoles): bool
{ {

Some files were not shown because too many files have changed in this diff Show More