Compare commits

..

31 Commits

Author SHA1 Message Date
McTom234
743657dbac feat(auth/oidc): wider key algorithm support 2025-11-23 02:58:36 +01:00
Dan Brown
5bf2d801cf Notifications: Fixed attempted null usage issue where int expected 2025-11-09 11:39:38 +00:00
Dan Brown
1421ba871d Updated translator & dependency attribution before release v25.11 2025-11-09 10:52:09 +00:00
Dan Brown
563828ba52 Updated translations with latest Crowdin changes (#5843) 2025-11-02 14:41:16 +00:00
Dan Brown
d40a68b411 API: Re-ordered routes, Improved navigation
Updated route order to follow some kind of logic.
Updated scrolling sidebar to not be so cut-off in various scenarios.
Added new nav helper to quick jump to specific API models.

Closes #5865
2025-11-02 14:31:08 +00:00
Dan Brown
4a57933cd1 Deps: Updated PHP composer packages 2025-11-02 13:11:38 +00:00
Dan Brown
1df850ea3e Search: Fixed formatting timeout with many term occurrences
For #5863
2025-10-31 15:55:45 +00:00
Dan Brown
7881bddce0 Merge pull request #5860 from BookStackApp/api_image_data_endpoint
API: Added endpoints for reading image data
2025-10-31 13:48:54 +00:00
Dan Brown
02d024aa32 API: Added endpoints for reading image data 2025-10-29 18:17:51 +00:00
Dan Brown
652124abaf Merge pull request #5854 from BookStackApp/efficient_search
Pagable and efficient search
2025-10-29 13:12:52 +00:00
Dan Brown
751934c84a Search: Tested changes to single-table search
Updated filters to use single table where needed.
2025-10-29 12:59:34 +00:00
Dan Brown
3fd25bd03e Search: Added pagination, updated other search uses
Also updated hydrator to be created via injection.
2025-10-28 20:37:41 +00:00
Dan Brown
f0303de2e5 Search: Improved result hydration performance 2025-10-27 18:02:54 +00:00
Dan Brown
0b26573314 Search: Started work to make search result size consistent 2025-10-27 17:23:15 +00:00
Dan Brown
c21c36e2a6 Merge pull request #5850 from BookStackApp/comments_api
API: Started building comments API endpoints
2025-10-24 15:26:55 +01:00
Dan Brown
a949900570 API: Added examples for comments
Tweaked comment repo to avoid returning a lot of extra data on API
update responses.
2025-10-24 15:14:25 +01:00
Dan Brown
9c4a9225af Comments API: Addressed failing tests and static testing 2025-10-24 14:22:53 +01:00
Dan Brown
4627dfd4f7 API: Added comment tree to pages-read endpoint
Includes tests to cover
2025-10-24 10:18:52 +01:00
Dan Brown
fcacf7cacb API: Built out tests for comment API endpoints 2025-10-23 16:52:29 +01:00
Dan Brown
cbf27d70c8 API: Added comment CUD endpoints, drafted tests
Move some checks and made some tweaks to the repo to support consistency
between API and UI.
2025-10-23 10:21:33 +01:00
Dan Brown
3ad1e31fcc API: Added comment-read endpoint, added api docs section descriptions 2025-10-22 18:44:49 +01:00
Dan Brown
082dbc9944 API: Started building comments API endpoints 2025-10-22 14:58:29 +01:00
Dan Brown
abe9c1e5a3 API Docs: Updated link to archived GitHub repo
Closes #5813
2025-10-22 14:29:02 +01:00
Dan Brown
ebf82617b8 Code: Added groovy syntax highlighting
For #5822
2025-10-21 18:34:21 +01:00
Dan Brown
2c81447c9e Deps: Updated PHP deps via composer 2025-10-21 14:47:24 +01:00
Dan Brown
8898647f78 Merge pull request #5846 from BookStackApp/page_image_nullification
Images: Added nulling of image page relation on page delete
2025-10-21 14:46:49 +01:00
Dan Brown
ea6344898f Images: Added nulling of image page relation on page delete 2025-10-21 14:12:55 +01:00
Dan Brown
0bfd79925e Merge pull request #5844 from BookStackApp/user_ids
Updated handling of deleted user ID handling in DB
2025-10-21 13:45:00 +01:00
Dan Brown
efff8700d4 DB: Addressed test issues for user ID changes
Reverted change for activities table so that a record is retained of
past activity, and added a check where the ID may be displayed to ensure
it does not mislead and accidentially reference other, newer users.
2025-10-19 19:52:15 +01:00
Dan Brown
5754acf2fb DB: Updated handling of deleted user ID handling in DB
Updated uses of user ID to nullify on delete.
Added testing to cover deletion of user relations.
Added model factories to support changes and potential other tests.
Cleans existing ID references in the DB via migration.
2025-10-19 19:10:15 +01:00
Dan Brown
4c7d6420ee DB: Aligned entity structure to a common table
As per PR #5800

* DB: Planned out new entity table format via migrations

* DB: Created entity migration logic

Made some other tweaks/fixes while testing.

* DB: Added change of entity relation columns to suit new entities table

* DB: Got most view queries working for new structure

* Entities: Started logic change to new structure

Updated base entity class, and worked through BaseRepo.
Need to go through other repos next.

Removed a couple of redundant interfaces as part of this since we can
move the logic onto the shared ContainerData model as needed.

* Entities: Been through repos to update for new format

* Entities: Updated repos to act on refreshed clones

Changes to core entity models are now done on clones to ensure clean
state before save, and those clones are returned back if changes are
needed after that action.

* Entities: Updated model classes & relations for changes

* Entities: Changed from *Data to a common "contents" system

Added smart loading from builder instances which should hydrate with
"contents()" loaded via join, while keeping the core model original.

* Entities: Moved entity description/covers to own non-model classes

Added back some interfaces.

* Entities: Removed use of contents system for data access

* Entities: Got most queries back to working order

* Entities: Reverted back to data from contents, fixed various issues

* Entities: Started addressing issues from tests

* Entities: Addressed further tests/issues

* Entities: Been through tests to get all passing in dev

Fixed issues and needed test changes along the way.

* Entities: Addressed phpstan errors

* Entities: Reviewed TODO notes

* Entities: Ensured book/shelf relation data removed on destroy

* Entities: Been through API responses & adjusted field visibility

* Entities: Added type index to massively improve query speed
2025-10-18 13:14:30 +01:00
211 changed files with 3983 additions and 1367 deletions

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
namespace BookStack\Access\Oidc;
use Illuminate\Support\Facades\Log;
use phpseclib3\Crypt\Common\PublicKey;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Crypt\RSA;
@@ -63,8 +64,9 @@ class OidcJwtSigningKey
// 'alg' is optional for a JWK, but we will still attempt to validate if
// it exists otherwise presume it will be compatible.
$alg = $jwk['alg'] ?? null;
if ($jwk['kty'] !== 'RSA' || !(is_null($alg) || $alg === 'RS256')) {
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
$algorithm = OidcJwtSigningKeyAlgorithm::tryFrom($alg ?? OidcJwtSigningKeyAlgorithm::RS256->value);
if ($jwk['kty'] !== 'RSA' || $algorithm === null) {
throw new OidcInvalidKeyException("Only " . OidcJwtSigningKeyAlgorithm::getSupportedAlgorithms() . " keys are currently supported. Found key using {$alg}");
}
// 'use' is optional for a JWK but we assume 'sig' where no value exists since that's what
@@ -97,7 +99,16 @@ class OidcJwtSigningKey
throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
}
$this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
// apply key-algorithm depending hash
$key = match ($algorithm) {
OidcJwtSigningKeyAlgorithm::RS256 => $key->withHash('sha256'),
OidcJwtSigningKeyAlgorithm::RS512 => $key->withHash('sha512'),
};
// apply key-algorithm depending padding
$this->key = match ($algorithm) {
OidcJwtSigningKeyAlgorithm::RS256,
OidcJwtSigningKeyAlgorithm::RS512 => $key->withPadding(RSA::SIGNATURE_PKCS1),
};
}
/**

View File

@@ -0,0 +1,16 @@
<?php
namespace BookStack\Access\Oidc;
use UnitEnum;
enum OidcJwtSigningKeyAlgorithm: string
{
case RS256 = 'RS256';
case RS512 = 'RS512';
public static function getSupportedAlgorithms(): string
{
return join(',', array_map(static fn (UnitEnum $enum) => $enum->value, self::cases()));
}
}

View File

@@ -119,8 +119,8 @@ class OidcJwtWithClaims implements ProvidesClaims
*/
protected function validateTokenSignature(): void
{
if ($this->header['alg'] !== 'RS256') {
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
if (OidcJwtSigningKeyAlgorithm::tryFrom($this->header['alg']) === null) {
throw new OidcInvalidTokenException("Only " . OidcJwtSigningKeyAlgorithm::getSupportedAlgorithms() . " signature validation is supported. Token reports using {$this->header['alg']}");
}
$parsedKeys = array_map(function ($key) {

View File

@@ -158,10 +158,10 @@ class OidcProviderSettings
protected function filterKeys(array $keys): array
{
return array_filter($keys, function (array $key) {
$alg = $key['alg'] ?? 'RS256';
$alg = $key['alg'] ?? OidcJwtSigningKeyAlgorithm::RS256->value;
$use = $key['use'] ?? 'sig';
return $key['kty'] === 'RSA' && $use === 'sig' && $alg === 'RS256';
return $key['kty'] === 'RSA' && $use === 'sig' && OidcJwtSigningKeyAlgorithm::tryFrom($alg) !== null;
});
}

View File

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

View File

@@ -4,10 +4,11 @@ namespace BookStack\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PrettyException;
use BookStack\Facades\Activity as ActivityService;
use BookStack\Util\HtmlDescriptionFilter;
use Illuminate\Database\Eloquent\Builder;
class CommentRepo
{
@@ -19,11 +20,46 @@ class CommentRepo
return Comment::query()->findOrFail($id);
}
/**
* Get a comment by ID, ensuring it is visible to the user based upon access to the page
* which the comment is attached to.
*/
public function getVisibleById(int $id): Comment
{
return $this->getQueryForVisible()->findOrFail($id);
}
/**
* Start a query for comments visible to the user.
* @return Builder<Comment>
*/
public function getQueryForVisible(): Builder
{
return Comment::query()->scopes('visible');
}
/**
* Create a new comment on an entity.
*/
public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment
{
// Prevent comments being added to draft pages
if ($entity instanceof Page && $entity->draft) {
throw new \Exception(trans('errors.cannot_add_comment_to_draft'));
}
// Validate parent ID
if ($parentId !== null) {
$parentCommentExists = Comment::query()
->where('commentable_id', '=', $entity->id)
->where('commentable_type', '=', $entity->getMorphClass())
->where('local_id', '=', $parentId)
->exists();
if (!$parentCommentExists) {
$parentId = null;
}
}
$userId = user()->id;
$comment = new Comment();
@@ -38,6 +74,7 @@ class CommentRepo
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
$comment->refresh()->unsetRelations();
return $comment;
}
@@ -59,7 +96,7 @@ class CommentRepo
/**
* Archive an existing comment.
*/
public function archive(Comment $comment): Comment
public function archive(Comment $comment, bool $log = true): Comment
{
if ($comment->parent_id) {
throw new NotifyException('Only top-level comments can be archived.', '/', 400);
@@ -68,7 +105,9 @@ class CommentRepo
$comment->archived = true;
$comment->save();
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
if ($log) {
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
}
return $comment;
}
@@ -76,7 +115,7 @@ class CommentRepo
/**
* Un-archive an existing comment.
*/
public function unarchive(Comment $comment): Comment
public function unarchive(Comment $comment, bool $log = true): Comment
{
if ($comment->parent_id) {
throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
@@ -85,7 +124,9 @@ class CommentRepo
$comment->archived = false;
$comment->save();
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
if ($log) {
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
}
return $comment;
}

View File

@@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace BookStack\Activity\Controllers;
use BookStack\Activity\CommentRepo;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* The comment data model has a 'local_id' property, which is a unique integer ID
* scoped to the page which the comment is on. The 'parent_id' is used for replies
* and refers to the 'local_id' of the parent comment on the same page, not the main
* globally unique 'id'.
*
* If you want to get all comments for a page in a tree-like structure, as reflected in
* the UI, then that is provided on pages-read API responses.
*/
class CommentApiController extends ApiController
{
protected array $rules = [
'create' => [
'page_id' => ['required', 'integer'],
'reply_to' => ['nullable', 'integer'],
'html' => ['required', 'string'],
'content_ref' => ['string'],
],
'update' => [
'html' => ['string'],
'archived' => ['boolean'],
]
];
public function __construct(
protected CommentRepo $commentRepo,
protected PageQueries $pageQueries,
) {
}
/**
* Get a listing of comments visible to the user.
*/
public function list(): JsonResponse
{
$query = $this->commentRepo->getQueryForVisible();
return $this->apiListingResponse($query, [
'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at'
]);
}
/**
* Create a new comment on a page.
* If commenting as a reply to an existing comment, the 'reply_to' parameter
* should be provided, set to the 'local_id' of the comment being replied to.
*/
public function create(Request $request): JsonResponse
{
$this->checkPermission(Permission::CommentCreateAll);
$input = $this->validate($request, $this->rules()['create']);
$page = $this->pageQueries->findVisibleByIdOrFail($input['page_id']);
$comment = $this->commentRepo->create(
$page,
$input['html'],
$input['reply_to'] ?? null,
$input['content_ref'] ?? '',
);
return response()->json($comment);
}
/**
* Read the details of a single comment, along with its direct replies.
*/
public function read(string $id): JsonResponse
{
$comment = $this->commentRepo->getVisibleById(intval($id));
$comment->load('createdBy', 'updatedBy');
$replies = $this->commentRepo->getQueryForVisible()
->where('parent_id', '=', $comment->local_id)
->where('commentable_id', '=', $comment->commentable_id)
->where('commentable_type', '=', $comment->commentable_type)
->get();
/** @var Comment[] $toProcess */
$toProcess = [$comment, ...$replies];
foreach ($toProcess as $commentToProcess) {
$commentToProcess->setAttribute('html', $commentToProcess->safeHtml());
$commentToProcess->makeVisible('html');
}
$comment->setRelation('replies', $replies);
return response()->json($comment);
}
/**
* Update the content or archived status of an existing comment.
*
* Only provide a new archived status if needing to actively change the archive state.
* Only top-level comments (non-replies) can be archived or unarchived.
*/
public function update(Request $request, string $id): JsonResponse
{
$comment = $this->commentRepo->getVisibleById(intval($id));
$this->checkOwnablePermission(Permission::CommentUpdate, $comment);
$input = $this->validate($request, $this->rules()['update']);
$hasHtml = isset($input['html']);
if (isset($input['archived'])) {
if ($input['archived']) {
$this->commentRepo->archive($comment, !$hasHtml);
} else {
$this->commentRepo->unarchive($comment, !$hasHtml);
}
}
if ($hasHtml) {
$comment = $this->commentRepo->update($comment, $input['html']);
}
return response()->json($comment);
}
/**
* Delete a single comment from the system.
*/
public function delete(string $id): Response
{
$comment = $this->commentRepo->getVisibleById(intval($id));
$this->checkOwnablePermission(Permission::CommentDelete, $comment);
$this->commentRepo->delete($comment);
return response('', 204);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,7 +58,7 @@ class BookApiController extends ApiController
/**
* View the details of a single book.
* The response data will contain 'content' property listing the chapter and pages directly within, in
* The response data will contain a 'content' property listing the chapter and pages directly within, in
* the same structure as you'd see within the BookStack interface when viewing a book. Top-level
* contents will have a 'type' property to distinguish between pages & chapters.
*/
@@ -122,9 +122,10 @@ class BookApiController extends ApiController
$book = clone $book;
$book->unsetRelations()->refresh();
$book->load(['tags', 'cover']);
$book->makeVisible('description_html')
->setAttribute('description_html', $book->descriptionHtml());
$book->load(['tags']);
$book->makeVisible(['cover', 'description_html'])
->setAttribute('description_html', $book->descriptionInfo()->getHtml())
->setAttribute('cover', $book->coverInfo()->getImage());
return $book;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
<?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,9 +2,10 @@
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityCover;
use BookStack\Entities\Tools\EntityDefaultTemplate;
use BookStack\Sorting\SortRule;
use BookStack\Uploads\Image;
use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -15,26 +16,25 @@ use Illuminate\Support\Collection;
* Class Book.
*
* @property string $description
* @property string $description_html
* @property int $image_id
* @property ?int $default_template_id
* @property ?int $sort_rule_id
* @property Image|null $cover
* @property \Illuminate\Database\Eloquent\Collection $chapters
* @property \Illuminate\Database\Eloquent\Collection $pages
* @property \Illuminate\Database\Eloquent\Collection $directPages
* @property \Illuminate\Database\Eloquent\Collection $shelves
* @property ?Page $defaultTemplate
* @property ?SortRule $sortRule
* @property ?SortRule $sortRule
*/
class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterface
class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface
{
use HasFactory;
use HtmlDescriptionTrait;
use ContainerTrait;
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 $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html'];
/**
* Get the url for this book.
@@ -44,55 +44,6 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
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.
* @return HasMany<Page, $this>
@@ -107,7 +58,7 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
*/
public function directPages(): HasMany
{
return $this->pages()->where('chapter_id', '=', '0');
return $this->pages()->whereNull('chapter_id');
}
/**
@@ -116,7 +67,8 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
*/
public function chapters(): HasMany
{
return $this->hasMany(Chapter::class);
return $this->hasMany(Chapter::class)
->where('type', '=', 'chapter');
}
/**
@@ -137,4 +89,27 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa
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,7 +3,6 @@
namespace BookStack\Entities\Models;
use BookStack\References\ReferenceUpdater;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
@@ -27,13 +26,13 @@ abstract class BookChild extends Entity
/**
* Change the book that this entity belongs to.
*/
public function changeBook(int $newBookId): Entity
public function changeBook(int $newBookId): self
{
$oldUrl = $this->getUrl();
$this->book_id = $newBookId;
$this->unsetRelation('book');
$this->refreshSlug();
$this->save();
$this->refresh();
if ($oldUrl !== $this->getUrl()) {
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);

View File

@@ -2,34 +2,34 @@
namespace BookStack\Entities\Models;
use BookStack\Entities\Tools\EntityCover;
use BookStack\Uploads\Image;
use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
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 HtmlDescriptionTrait;
protected $table = 'bookshelves';
use ContainerTrait;
public float $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'image_id'];
protected $hidden = ['image_id', 'deleted_at', 'description_html'];
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'];
/**
* Get the books in this shelf.
* Should not be used directly since does not take into account permissions.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
* Should not be used directly since it does not take into account permissions.
*/
public function books()
public function books(): BelongsToMany
{
return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id')
->select(['entities.*', 'entity_container_data.*'])
->withPivot('order')
->orderBy('order', 'asc');
}
@@ -50,41 +50,6 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
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.
*/
@@ -96,7 +61,7 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
/**
* Add a book to the end of this shelf.
*/
public function appendBook(Book $book)
public function appendBook(Book $book): void
{
if ($this->contains($book)) {
return;
@@ -106,12 +71,13 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn
$this->books()->attach($book->id, ['order' => $maxOrder + 1]);
}
/**
* Get a visible shelf by its slug.
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public static function getBySlug(string $slug): self
public function coverInfo(): EntityCover
{
return static::visible()->where('slug', '=', $slug)->firstOrFail();
return new EntityCover($this);
}
public function cover(): BelongsTo
{
return $this->belongsTo(Image::class, 'image_id');
}
}

View File

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

View File

@@ -0,0 +1,26 @@
<?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

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

View File

@@ -28,23 +28,25 @@ use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* Class Entity
* The base class for book-like items such as pages, chapters & books.
* The base class for book-like items such as pages, chapters and books.
* This is not a database model in itself but extended.
*
* @property int $id
* @property string $type
* @property string $name
* @property string $slug
* @property Carbon $created_at
* @property Carbon $updated_at
* @property Carbon $deleted_at
* @property int $created_by
* @property int $updated_by
* @property int $owned_by
* @property int|null $created_by
* @property int|null $updated_by
* @property int|null $owned_by
* @property Collection $tags
*
* @method static Entity|Builder visible()
@@ -77,6 +79,72 @@ abstract class Entity extends Model implements
*/
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.
*/
@@ -91,8 +159,8 @@ abstract class Entity extends Model implements
public function scopeWithLastView(Builder $query)
{
$viewedAtQuery = View::query()->select('updated_at')
->whereColumn('viewable_id', '=', $this->getTable() . '.id')
->where('viewable_type', '=', $this->getMorphClass())
->whereColumn('viewable_id', '=', 'entities.id')
->whereColumn('viewable_type', '=', 'entities.type')
->where('user_id', '=', user()->id)
->take(1);
@@ -102,11 +170,12 @@ abstract class Entity extends Model implements
/**
* Query scope to get the total view count of the entities.
*/
public function scopeWithViewCount(Builder $query)
public function scopeWithViewCount(Builder $query): void
{
$viewCountQuery = View::query()->selectRaw('SUM(views) as view_count')
->whereColumn('viewable_id', '=', $this->getTable() . '.id')
->where('viewable_type', '=', $this->getMorphClass())->take(1);
->whereColumn('viewable_id', '=', 'entities.id')
->whereColumn('viewable_type', '=', 'entities.type')
->take(1);
$query->addSelect(['view_count' => $viewCountQuery]);
}
@@ -162,15 +231,17 @@ abstract class Entity extends Model implements
*/
public function tags(): MorphMany
{
return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
return $this->morphMany(Tag::class, 'entity')
->orderBy('order', 'asc');
}
/**
* Get the comments for an entity.
* @return MorphMany<Comment, $this>
*/
public function comments(bool $orderByCreated = true): MorphMany
{
$query = $this->morphMany(Comment::class, 'entity');
$query = $this->morphMany(Comment::class, 'commentable');
return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
}
@@ -184,7 +255,7 @@ abstract class Entity extends Model implements
}
/**
* Get this entities restrictions.
* Get this entities assigned permissions.
*/
public function permissions(): MorphMany
{
@@ -267,7 +338,7 @@ abstract class Entity extends Model implements
}
/**
* Gets a limited-length version of the entities name.
* Gets a limited-length version of the entity name.
*/
public function getShortName(int $length = 25): string
{
@@ -377,4 +448,40 @@ abstract class Entity extends Model implements
{
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

@@ -0,0 +1,52 @@
<?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

@@ -0,0 +1,25 @@
<?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

@@ -0,0 +1,38 @@
<?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

@@ -0,0 +1,27 @@
<?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());
if ($model instanceof Page) {
$builder->leftJoin('entity_page_data', 'entity_page_data.page_id', '=', 'entities.id');
} else {
$builder->leftJoin('entity_container_data', function (JoinClause $join) use ($model) {
$join->on('entity_container_data.entity_id', '=', 'entities.id')
->where('entity_container_data.entity_type', '=', $model->getMorphClass());
});
}
}
}

View File

@@ -0,0 +1,69 @@
<?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

@@ -0,0 +1,18 @@
<?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

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

View File

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

View File

@@ -1,17 +0,0 @@
<?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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,11 @@
namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\EntityTable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
class EntityQueries
@@ -32,17 +36,53 @@ class EntityQueries
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,
* suitable for listing display.
* @return Builder<Entity>
*/
public function visibleForList(string $entityType): Builder
public function visibleForListForType(string $entityType): Builder
{
$queries = $this->getQueriesForType($entityType);
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
{
$queries = match ($type) {

View File

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

View File

@@ -35,4 +35,11 @@ interface ProvidesEntityQueries
* @return Builder<TModel>
*/
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,13 +3,10 @@
namespace BookStack\Entities\Repos;
use BookStack\Activity\TagRepo;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\HasCoverInterface;
use BookStack\Entities\Models\HasDescriptionInterface;
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\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore;
@@ -33,17 +30,25 @@ class BaseRepo
/**
* Create a new entity in the system.
* @template T of Entity
* @param T $entity
* @return T
*/
public function create(Entity $entity, array $input)
public function create(Entity $entity, array $input): Entity
{
$entity = (clone $entity)->refresh();
$entity->fill($input);
$this->updateDescription($entity, $input);
$entity->forceFill([
'created_by' => user()->id,
'updated_by' => user()->id,
'owned_by' => user()->id,
]);
$entity->refreshSlug();
if ($entity instanceof HasDescriptionInterface) {
$this->updateDescription($entity, $input);
}
$entity->save();
if (isset($input['tags'])) {
@@ -53,24 +58,33 @@ class BaseRepo
$entity->refresh();
$entity->rebuildPermissions();
$entity->indexForSearch();
$this->referenceStore->updateForEntity($entity);
return $entity;
}
/**
* Update the given entity.
* @template T of Entity
* @param T $entity
* @return T
*/
public function update(Entity $entity, array $input)
public function update(Entity $entity, array $input): Entity
{
$oldUrl = $entity->getUrl();
$entity->fill($input);
$this->updateDescription($entity, $input);
$entity->updated_by = user()->id;
if ($entity->isDirty('name') || empty($entity->slug)) {
$entity->refreshSlug();
}
if ($entity instanceof HasDescriptionInterface) {
$this->updateDescription($entity, $input);
}
$entity->save();
if (isset($input['tags'])) {
@@ -84,59 +98,35 @@ class BaseRepo
if ($oldUrl !== $entity->getUrl()) {
$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 \Exception
*/
public function updateCoverImage(Entity&CoverImageInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false)
public function updateCoverImage(Entity&HasCoverInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false): void
{
if ($coverImage) {
$imageType = $entity->coverImageTypeKey();
$this->imageRepo->destroyImage($entity->cover()->first());
$imageType = 'cover_' . $entity->type;
$this->imageRepo->destroyImage($entity->coverInfo()->getImage());
$image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
$entity->cover()->associate($image);
$entity->coverInfo()->setImage($image);
$entity->save();
}
if ($removeImage) {
$this->imageRepo->destroyImage($entity->cover()->first());
$entity->cover()->dissociate();
$this->imageRepo->destroyImage($entity->coverInfo()->getImage());
$entity->coverInfo()->setImage(null);
$entity->save();
}
}
/**
* 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.
* Sort the parent of the given entity if any auto sort actions are set for it.
* Typically ran during create/update/insert events.
*/
public function sortParent(Entity $entity): void
@@ -147,19 +137,22 @@ class BaseRepo
}
}
/**
* Update the description of the given entity from input data.
*/
protected function updateDescription(Entity $entity, array $input): void
{
if (!($entity instanceof HtmlDescriptionInterface)) {
if (!$entity instanceof HasDescriptionInterface) {
return;
}
if (isset($input['description_html'])) {
$entity->setDescriptionHtml(
$entity->descriptionInfo()->set(
HtmlDescriptionFilter::filterFromString($input['description_html']),
html_entity_decode(strip_tags($input['description_html']))
);
} else if (isset($input['description'])) {
$entity->setDescriptionHtml('', $input['description']);
$entity->descriptionInfo()->set('', $input['description']);
}
}
}

View File

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

View File

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

View File

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

View File

@@ -9,11 +9,9 @@ use BookStack\Facades\Activity;
class DeletionRepo
{
private TrashCan $trashCan;
public function __construct(TrashCan $trashCan)
{
$this->trashCan = $trashCan;
public function __construct(
protected TrashCan $trashCan
) {
}
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.
*/
public function getNewDraftPage(Entity $parent)
public function getNewDraftPage(Entity $parent): Page
{
$page = (new Page())->forceFill([
'name' => trans('entities.pages_initial_name'),
@@ -46,6 +46,9 @@ class PageRepo
'updated_by' => user()->id,
'draft' => true,
'editor' => PageEditorType::getSystemDefault()->value,
'html' => '',
'markdown' => '',
'text' => '',
]);
if ($parent instanceof Chapter) {
@@ -55,17 +58,18 @@ class PageRepo
$page->book_id = $parent->id;
}
$defaultTemplate = $page->chapter->defaultTemplate ?? $page->book->defaultTemplate;
if ($defaultTemplate && userCan(Permission::PageView, $defaultTemplate)) {
$defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book?->defaultTemplate()->get();
if ($defaultTemplate) {
$page->forceFill([
'html' => $defaultTemplate->html,
'markdown' => $defaultTemplate->markdown,
]);
$page->text = (new PageContent($page))->toPlainText();
}
(new DatabaseTransaction(function () use ($page) {
$page->save();
$page->refresh()->rebuildPermissions();
$page->rebuildPermissions();
}))->run();
return $page;
@@ -81,7 +85,8 @@ class PageRepo
$draft->revision_count = 1;
$draft->priority = $this->getNewPriority($draft);
$this->updateTemplateStatusAndContentFromInput($draft, $input);
$this->baseRepo->update($draft, $input);
$draft = $this->baseRepo->update($draft, $input);
$draft->rebuildPermissions();
$summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
@@ -112,12 +117,12 @@ class PageRepo
public function update(Page $page, array $input): Page
{
// Hold the old details to compare later
$oldHtml = $page->html;
$oldName = $page->name;
$oldHtml = $page->html;
$oldMarkdown = $page->markdown;
$this->updateTemplateStatusAndContentFromInput($page, $input);
$this->baseRepo->update($page, $input);
$page = $this->baseRepo->update($page, $input);
// Update with new details
$page->revision_count++;
@@ -176,12 +181,12 @@ class PageRepo
/**
* Save a page update draft.
*/
public function updatePageDraft(Page $page, array $input)
public function updatePageDraft(Page $page, array $input): Page|PageRevision
{
// If the page itself is a draft simply update that
// If the page itself is a draft, simply update that
if ($page->draft) {
$this->updateTemplateStatusAndContentFromInput($page, $input);
$page->fill($input);
$page->forceFill(array_intersect_key($input, array_flip(['name'])))->save();
$page->save();
return $page;
@@ -209,7 +214,7 @@ class PageRepo
*
* @throws Exception
*/
public function destroy(Page $page)
public function destroy(Page $page): void
{
$this->trashCan->softDestroyPage($page);
Activity::add(ActivityType::PAGE_DELETE, $page);
@@ -279,7 +284,7 @@ class PageRepo
return (new DatabaseTransaction(function () use ($page, $parent) {
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
$page->changeBook($newBookId);
$page = $page->changeBook($newBookId);
$page->rebuildPermissions();
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.
* Checks for an existing revisions before providing a fresh one.
* Checks for an existing revision before providing a fresh one.
*/
public function getNewDraftForCurrentUser(Page $page): PageRevision
{
@@ -72,7 +72,7 @@ class RevisionRepo
/**
* Delete old revisions, for the given page, from the system.
*/
protected function deleteOldRevisions(Page $page)
protected function deleteOldRevisions(Page $page): void
{
$revisionLimit = config('app.revision_limit');
if ($revisionLimit === false) {

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
<?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

@@ -0,0 +1,60 @@
<?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

@@ -0,0 +1,60 @@
<?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

@@ -0,0 +1,140 @@
<?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,6 +34,7 @@ class HierarchyTransformer
/** @var Page $page */
foreach ($chapter->pages as $page) {
$page->chapter_id = 0;
$page->save();
$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'.
* @param Model[] $relations
*/
public function loadIntoRelations(array $relations, string $relationName, bool $loadParents): void
public function loadIntoRelations(array $relations, string $relationName, bool $loadParents, bool $withContents = false): void
{
$idsByType = [];
foreach ($relations as $relation) {
@@ -33,7 +33,7 @@ class MixedEntityListLoader
$idsByType[$type][] = $id;
}
$modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents);
$modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents, $withContents);
foreach ($relations as $relation) {
$type = $relation->getAttribute($relationName . '_type');
@@ -49,13 +49,13 @@ class MixedEntityListLoader
* @param array<string, int[]> $idsByType
* @return array<string, array<int, Model>>
*/
protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents): array
protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents, bool $withContents): array
{
$modelMap = [];
foreach ($idsByType as $type => $ids) {
$models = $this->queries->visibleForList($type)
->whereIn('id', $ids)
$base = $withContents ? $this->queries->visibleForContentForType($type) : $this->queries->visibleForListForType($type);
$models = $base->whereIn('id', $ids)
->with($eagerLoadParents ? $this->getRelationsToEagerLoad($type) : [])
->get();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,12 @@ use Illuminate\Http\JsonResponse;
abstract class ApiController extends Controller
{
/**
* The validation rules for this controller.
* Can alternative be defined in a rules() method is they need to be dynamic.
*
* @var array<string, array<string, string[]>>
*/
protected array $rules = [];
/**

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,13 @@
<?php
declare(strict_types=1);
namespace BookStack\Search;
use BookStack\Api\ApiEntityListFormatter;
use BookStack\Entities\Models\Entity;
use BookStack\Http\ApiController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SearchApiController extends ApiController
@@ -31,11 +34,9 @@ class SearchApiController extends ApiController
* between: bookshelf, book, chapter & page.
*
* The paging parameters and response format emulates a standard listing 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.
* but standard sorting and filtering cannot be done on this endpoint.
*/
public function all(Request $request)
public function all(Request $request): JsonResponse
{
$this->validate($request, $this->rules['all']);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,11 +3,13 @@
namespace BookStack\Uploads\Controllers;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Exceptions\NotFoundException;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageResizer;
use BookStack\Uploads\ImageService;
use Illuminate\Http\Request;
class ImageGalleryApiController extends ApiController
@@ -20,6 +22,7 @@ class ImageGalleryApiController extends ApiController
protected ImageRepo $imageRepo,
protected ImageResizer $imageResizer,
protected PageQueries $pageQueries,
protected ImageService $imageService,
) {
}
@@ -32,6 +35,9 @@ class ImageGalleryApiController extends ApiController
'image' => ['required', 'file', ...$this->getImageValidationRules()],
'name' => ['string', 'max:180'],
],
'readDataForUrl' => [
'url' => ['required', 'string', 'url'],
],
'update' => [
'name' => ['string', 'max:180'],
'image' => ['file', ...$this->getImageValidationRules()],
@@ -85,7 +91,8 @@ class ImageGalleryApiController extends ApiController
* 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
* 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.
* Actual image file data is not provided but can be fetched via the "url" response property or by
* using the "read-data" endpoint.
*/
public function read(string $id)
{
@@ -94,6 +101,37 @@ class ImageGalleryApiController extends ApiController
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.
* Since "image" is expected to be a file, this needs to be a 'multipart/form-data' type request if providing a

View File

@@ -42,7 +42,9 @@ class Image extends Model implements OwnableInterface
*/
public function scopeVisible(Builder $query): Builder
{
return app()->make(PermissionApplicator::class)->restrictPageRelationQuery($query, 'images', 'uploaded_to');
return app()->make(PermissionApplicator::class)
->restrictPageRelationQuery($query, 'images', 'uploaded_to')
->whereIn('type', ['gallery', 'drawio']);
}
/**

View File

@@ -184,7 +184,7 @@ class ImageService
/** @var Image $image */
foreach ($images as $image) {
$searchQuery = '%' . basename($image->path) . '%';
$inPage = DB::table('pages')
$inPage = DB::table('entity_page_data')
->where('html', 'like', $searchQuery)->count() > 0;
$inRevision = false;
@@ -264,6 +264,23 @@ class ImageService
&& str_starts_with($disk->mimeType($imagePath), 'image/');
}
/**
* Check if the given path exists and is accessible depending on the current settings.
*/
public function pathAccessible(string $imagePath): bool
{
$disk = $this->storage->getDisk('gallery');
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
return false;
}
// Check local_secure is active
return $disk->exists($imagePath)
// Check the file is likely an image file
&& str_starts_with($disk->mimeType($imagePath), 'image/');
}
/**
* Check that the current user has access to the relation
* of the image at the given path.

View File

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

View File

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

View File

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

439
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
<?php
namespace Database\Factories\Access\Mfa;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\BookStack\Access\Mfa\MfaValue>
*/
class MfaValueFactory extends Factory
{
protected $model = \BookStack\Access\Mfa\MfaValue::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'method' => 'totp',
'value' => '123456',
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace Database\Factories\Access;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\BookStack\Access\SocialAccount>
*/
class SocialAccountFactory extends Factory
{
protected $model = \BookStack\Access\SocialAccount::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'driver' => 'github',
'driver_id' => '123456',
'avatar' => '',
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Database\Factories\Activity\Models;
use BookStack\Activity\ActivityType;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\BookStack\Activity\Models\Activity>
*/
class ActivityFactory extends Factory
{
protected $model = \BookStack\Activity\Models\Activity::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$activities = ActivityType::all();
$activity = $activities[array_rand($activities)];
return [
'type' => $activity,
'detail' => 'Activity detail for ' . $activity,
'user_id' => User::factory(),
'ip' => $this->faker->ipv4(),
'loggable_id' => null,
'loggable_type' => null,
];
}
}

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