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

Merge branch 'development' into release

This commit is contained in:
Dan Brown
2025-11-09 12:51:26 +00:00
350 changed files with 7478 additions and 3277 deletions

View File

@@ -36,10 +36,14 @@ APP_LANG=en
# APP_LANG will be used if such a header is not provided.
APP_AUTO_LANG_PUBLIC=true
# Application timezone
# Used where dates are displayed such as on exported content.
# Application timezones
# The first option is used to determine what timezone is used for date storage.
# Leaving that as "UTC" is advised.
# The second option is used to set the timezone which will be used for date
# formatting and display. This defaults to the "APP_TIMEZONE" value.
# Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
APP_TIMEZONE=UTC
APP_DISPLAY_TIMEZONE=UTC
# Application theme
# Used to specific a themes/<APP_THEME> folder where BookStack UI

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

@@ -2,33 +2,18 @@
namespace BookStack\Access;
use BookStack\Users\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Database\Eloquent\Model;
class ExternalBaseUserProvider implements UserProvider
{
public function __construct(
protected string $model
) {
}
/**
* Create a new instance of the model.
*/
public function createModel(): Model
{
$class = '\\' . ltrim($this->model, '\\');
return new $class();
}
/**
* Retrieve a user by their unique identifier.
*/
public function retrieveById(mixed $identifier): ?Authenticatable
{
return $this->createModel()->newQuery()->find($identifier);
return User::query()->find($identifier);
}
/**
@@ -59,10 +44,7 @@ class ExternalBaseUserProvider implements UserProvider
*/
public function retrieveByCredentials(array $credentials): ?Authenticatable
{
// Search current user base by looking up a uid
$model = $this->createModel();
return $model->newQuery()
return User::query()
->where('external_auth_id', $credentials['external_auth_id'])
->first();
}

View File

@@ -3,23 +3,18 @@
namespace BookStack\Access\Guards;
/**
* Saml2 Session Guard.
* External Auth Session Guard.
*
* The saml2 login process is async in nature meaning it does not fit very well
* into the default laravel 'Guard' auth flow. Instead most of the logic is done
* via the Saml2 controller & Saml2Service. This class provides a safer, thin
* version of SessionGuard.
* The login process for external auth (SAML2/OIDC) is async in nature, meaning it does not fit very well
* into the default laravel 'Guard' auth flow. Instead, most of the logic is done via the relevant
* controller and services. This class provides a safer, thin version of SessionGuard.
*/
class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
{
/**
* Validate a user's credentials.
*
* @param array $credentials
*
* @return bool
*/
public function validate(array $credentials = [])
public function validate(array $credentials = []): bool
{
return false;
}
@@ -27,12 +22,9 @@ class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
/**
* Attempt to authenticate a user using the given credentials.
*
* @param array $credentials
* @param bool $remember
*
* @return bool
*/
public function attempt(array $credentials = [], $remember = false)
public function attempt(array $credentials = [], $remember = false): bool
{
return false;
}

View File

@@ -4,7 +4,7 @@ namespace BookStack\Access\Guards;
use BookStack\Access\RegistrationService;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Session\Session;
@@ -24,43 +24,31 @@ class ExternalBaseSessionGuard implements StatefulGuard
* The name of the Guard. Typically "session".
*
* Corresponds to guard name in authentication configuration.
*
* @var string
*/
protected $name;
protected readonly string $name;
/**
* The user we last attempted to retrieve.
*
* @var \Illuminate\Contracts\Auth\Authenticatable
*/
protected $lastAttempted;
protected Authenticatable|null $lastAttempted;
/**
* The session used by the guard.
*
* @var \Illuminate\Contracts\Session\Session
*/
protected $session;
protected Session $session;
/**
* Indicates if the logout method has been called.
*
* @var bool
*/
protected $loggedOut = false;
protected bool $loggedOut = false;
/**
* Service to handle common registration actions.
*
* @var RegistrationService
*/
protected $registrationService;
protected RegistrationService $registrationService;
/**
* Create a new authentication guard.
*
* @return void
*/
public function __construct(string $name, UserProvider $provider, Session $session, RegistrationService $registrationService)
{
@@ -72,13 +60,11 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Get the currently authenticated user.
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function user()
public function user(): Authenticatable|null
{
if ($this->loggedOut) {
return;
return null;
}
// If we've already retrieved the user for the current request we can just
@@ -101,13 +87,11 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Get the ID for the currently authenticated user.
*
* @return int|null
*/
public function id()
public function id(): int|null
{
if ($this->loggedOut) {
return;
return null;
}
return $this->user()
@@ -117,12 +101,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Log a user into the application without sessions or cookies.
*
* @param array $credentials
*
* @return bool
*/
public function once(array $credentials = [])
public function once(array $credentials = []): bool
{
if ($this->validate($credentials)) {
$this->setUser($this->lastAttempted);
@@ -135,12 +115,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Log the given user ID into the application without sessions or cookies.
*
* @param mixed $id
*
* @return \Illuminate\Contracts\Auth\Authenticatable|false
*/
public function onceUsingId($id)
public function onceUsingId($id): Authenticatable|false
{
if (!is_null($user = $this->provider->retrieveById($id))) {
$this->setUser($user);
@@ -153,38 +129,26 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Validate a user's credentials.
*
* @param array $credentials
*
* @return bool
*/
public function validate(array $credentials = [])
public function validate(array $credentials = []): bool
{
return false;
}
/**
* Attempt to authenticate a user using the given credentials.
*
* @param array $credentials
* @param bool $remember
*
* @return bool
* @param bool $remember
*/
public function attempt(array $credentials = [], $remember = false)
public function attempt(array $credentials = [], $remember = false): bool
{
return false;
}
/**
* Log the given user ID into the application.
*
* @param mixed $id
* @param bool $remember
*
* @return \Illuminate\Contracts\Auth\Authenticatable|false
*/
public function loginUsingId($id, $remember = false)
public function loginUsingId(mixed $id, $remember = false): Authenticatable|false
{
// Always return false as to disable this method,
// Logins should route through LoginService.
@@ -194,12 +158,9 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Log a user into the application.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @param bool $remember
*
* @return void
* @param bool $remember
*/
public function login(AuthenticatableContract $user, $remember = false)
public function login(Authenticatable $user, $remember = false): void
{
$this->updateSession($user->getAuthIdentifier());
@@ -208,12 +169,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Update the session with the given ID.
*
* @param string $id
*
* @return void
*/
protected function updateSession($id)
protected function updateSession(string|int $id): void
{
$this->session->put($this->getName(), $id);
@@ -222,10 +179,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Log the user out of the application.
*
* @return void
*/
public function logout()
public function logout(): void
{
$this->clearUserDataFromStorage();
@@ -239,62 +194,48 @@ class ExternalBaseSessionGuard implements StatefulGuard
/**
* Remove the user data from the session and cookies.
*
* @return void
*/
protected function clearUserDataFromStorage()
protected function clearUserDataFromStorage(): void
{
$this->session->remove($this->getName());
}
/**
* Get the last user we attempted to authenticate.
*
* @return \Illuminate\Contracts\Auth\Authenticatable
*/
public function getLastAttempted()
public function getLastAttempted(): Authenticatable
{
return $this->lastAttempted;
}
/**
* Get a unique identifier for the auth session value.
*
* @return string
*/
public function getName()
public function getName(): string
{
return 'login_' . $this->name . '_' . sha1(static::class);
}
/**
* Determine if the user was authenticated via "remember me" cookie.
*
* @return bool
*/
public function viaRemember()
public function viaRemember(): bool
{
return false;
}
/**
* Return the currently cached user.
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function getUser()
public function getUser(): Authenticatable|null
{
return $this->user;
}
/**
* Set the current user.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
*
* @return $this
*/
public function setUser(AuthenticatableContract $user)
public function setUser(Authenticatable $user): self
{
$this->user = $user;

View File

@@ -35,13 +35,9 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
/**
* Validate a user's credentials.
*
* @param array $credentials
*
* @throws LdapException
*
* @return bool
*/
public function validate(array $credentials = [])
public function validate(array $credentials = []): bool
{
$userDetails = $this->ldapService->getUserDetails($credentials['username']);

View File

@@ -9,6 +9,7 @@ use BookStack\Exceptions\LoginAttemptInvalidUserException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Facades\Activity;
use BookStack\Facades\Theme;
use BookStack\Permissions\Permission;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use Exception;
@@ -50,7 +51,7 @@ class LoginService
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user);
// Authenticate on all session guards if a likely admin
if ($user->can('users-manage') && $user->can('user-roles-manage')) {
if ($user->can(Permission::UsersManage) && $user->can(Permission::UserRolesManage)) {
$guards = ['standard', 'ldap', 'saml2', 'oidc'];
foreach ($guards as $guard) {
auth($guard)->login($user);
@@ -95,7 +96,7 @@ class LoginService
{
$value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
if (!$value) {
return ['user_id' => null, 'method' => null];
return ['user_id' => null, 'method' => null, 'remember' => false];
}
[$id, $method, $remember, $time] = explode(':', $value);
@@ -103,18 +104,18 @@ class LoginService
if ($time < $hourAgo) {
$this->clearLastLoginAttempted();
return ['user_id' => null, 'method' => null];
return ['user_id' => null, 'method' => null, 'remember' => false];
}
return ['user_id' => $id, 'method' => $method, 'remember' => boolval($remember)];
}
/**
* Set the last login attempted user.
* Set the last login-attempted user.
* Must be only used when credentials are correct and a login could be
* achieved but a secondary factor has stopped the login.
* achieved, but a secondary factor has stopped the login.
*/
protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember)
protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember): void
{
session()->put(
self::LAST_LOGIN_ATTEMPTED_SESSION_KEY,

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

@@ -51,7 +51,7 @@ class Saml2Service
* Returns the SAML2 request ID, and the URL to redirect the user to.
*
* @throws Error
* @returns array{url: string, id: ?string}
* @return array{url: string, id: ?string}
*/
public function logout(User $user): array
{

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

@@ -55,7 +55,7 @@ class SocialDriverManager
/**
* Gets the names of the active social drivers, keyed by driver id.
* @returns array<string, string>
* @return array<string, string>
*/
public function getActive(): array
{

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

@@ -4,6 +4,7 @@ namespace BookStack\Activity\Controllers;
use BookStack\Activity\Models\Activity;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
class AuditLogApiController extends ApiController
{
@@ -16,8 +17,8 @@ class AuditLogApiController extends ApiController
*/
public function list()
{
$this->checkPermission('settings-manage');
$this->checkPermission('users-manage');
$this->checkPermission(Permission::SettingsManage);
$this->checkPermission(Permission::UsersManage);
$query = Activity::query()->with(['user']);

View File

@@ -5,6 +5,7 @@ namespace BookStack\Activity\Controllers;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Sorting\SortUrl;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
@@ -13,8 +14,8 @@ class AuditLogController extends Controller
{
public function index(Request $request)
{
$this->checkPermission('settings-manage');
$this->checkPermission('users-manage');
$this->checkPermission(Permission::SettingsManage);
$this->checkPermission(Permission::UsersManage);
$sort = $request->get('sort', 'activity_date');
$order = $request->get('order', 'desc');

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

@@ -7,6 +7,7 @@ use BookStack\Activity\Tools\CommentTree;
use BookStack\Activity\Tools\CommentTreeNode;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -21,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)
{
@@ -36,13 +37,8 @@ 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('comment-create-all');
$this->checkPermission(Permission::CommentCreateAll);
$contentRef = $input['content_ref'] ?? '';
$comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef);
@@ -64,8 +60,8 @@ class CommentController extends Controller
]);
$comment = $this->commentRepo->getById($commentId);
$this->checkOwnablePermission('page-view', $comment->entity);
$this->checkOwnablePermission('comment-update', $comment);
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
$this->checkOwnablePermission(Permission::CommentUpdate, $comment);
$comment = $this->commentRepo->update($comment, $input['html']);
@@ -81,8 +77,8 @@ class CommentController extends Controller
public function archive(int $id)
{
$comment = $this->commentRepo->getById($id);
$this->checkOwnablePermission('page-view', $comment->entity);
if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) {
$this->showPermissionError();
}
@@ -101,8 +97,8 @@ class CommentController extends Controller
public function unarchive(int $id)
{
$comment = $this->commentRepo->getById($id);
$this->checkOwnablePermission('page-view', $comment->entity);
if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) {
$this->showPermissionError();
}
@@ -121,7 +117,7 @@ class CommentController extends Controller
public function destroy(int $id)
{
$comment = $this->commentRepo->getById($id);
$this->checkOwnablePermission('comment-delete', $comment);
$this->checkOwnablePermission(Permission::CommentDelete, $comment);
$this->commentRepo->delete($comment);

View File

@@ -5,13 +5,14 @@ namespace BookStack\Activity\Controllers;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use Illuminate\Http\Request;
class WatchController extends Controller
{
public function update(Request $request, MixedEntityRequestHelper $entityHelper)
{
$this->checkPermission('receive-notifications');
$this->checkPermission(Permission::ReceiveNotifications);
$this->preventGuestAccess();
$requestData = $this->validate($request, array_merge([

View File

@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Webhook;
use BookStack\Activity\Queries\WebhooksAllPaginatedAndSorted;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
@@ -14,7 +15,7 @@ class WebhookController extends Controller
public function __construct()
{
$this->middleware([
'can:settings-manage',
Permission::SettingsManage->middleware()
]);
}

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,48 +3,56 @@
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\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 int $created_by
* @property int $updated_by
* @property string $commentable_type
* @property int $commentable_id
* @property string $content_ref
* @property bool $archived
*/
class Comment extends Model implements Loggable
class Comment extends Model implements Loggable, OwnableInterface
{
use HasFactory;
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');
}
/**
* Get the parent comment this is in reply to (if existing).
* @return BelongsTo<Comment, $this>
*/
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);
}
/**
@@ -57,11 +65,27 @@ class Comment extends Model implements Loggable
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

@@ -5,6 +5,7 @@ namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
use BookStack\Entities\Models\Entity;
use BookStack\Permissions\Permission;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Log;
@@ -26,7 +27,7 @@ abstract class BaseNotificationHandler implements NotificationHandler
}
// Prevent sending of the user does not have notification permissions
if (!$user->can('receive-notifications')) {
if (!$user->can(Permission::ReceiveNotifications)) {
continue;
}

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

@@ -4,6 +4,7 @@ namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Page;
use BookStack\Permissions\Permission;
class CommentTree
{
@@ -12,6 +13,11 @@ class CommentTree
* @var CommentTreeNode[]
*/
protected array $tree;
/**
* A linear array of loaded comments.
* @var Comment[]
*/
protected array $comments;
public function __construct(
@@ -38,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
@@ -48,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
@@ -70,7 +76,7 @@ class CommentTree
public function canUpdateAny(): bool
{
foreach ($this->comments as $comment) {
if (userCan('comment-update', $comment)) {
if (userCan(Permission::CommentUpdate, $comment)) {
return true;
}
}
@@ -78,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[]
@@ -122,6 +136,9 @@ class CommentTree
return new CommentTreeNode($byId[$id], $depth, $children);
}
/**
* @return Comment[]
*/
protected function loadComments(): array
{
if (!$this->enabled()) {

View File

@@ -6,6 +6,7 @@ use BookStack\Activity\Models\Tag;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Permissions\Permission;
class TagClassGenerator
{
@@ -26,14 +27,14 @@ class TagClassGenerator
array_push($classes, ...$this->generateClassesForTag($tag));
}
if ($this->entity instanceof BookChild && userCan('view', $this->entity->book)) {
if ($this->entity instanceof BookChild && userCan(Permission::BookView, $this->entity->book)) {
$bookTags = $this->entity->book->tags;
foreach ($bookTags as $bookTag) {
array_push($classes, ...$this->generateClassesForTag($bookTag, 'book-'));
}
}
if ($this->entity instanceof Page && $this->entity->chapter && userCan('view', $this->entity->chapter)) {
if ($this->entity instanceof Page && $this->entity->chapter && userCan(Permission::ChapterView, $this->entity->chapter)) {
$chapterTags = $this->entity->chapter->tags;
foreach ($chapterTags as $chapterTag) {
array_push($classes, ...$this->generateClassesForTag($chapterTag, 'chapter-'));

View File

@@ -7,6 +7,7 @@ use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Permissions\Permission;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder;
@@ -22,7 +23,7 @@ class UserEntityWatchOptions
public function canWatch(): bool
{
return $this->user->can('receive-notifications') && !$this->user->isGuest();
return $this->user->can(Permission::ReceiveNotifications) && !$this->user->isGuest();
}
public function getWatchLevel(): string

View File

@@ -36,7 +36,7 @@ class WatchLevels
/**
* Get all the possible values as an option_name => value array.
* @returns array<string, int>
* @return array<string, int>
*/
public static function all(): array
{
@@ -50,7 +50,7 @@ class WatchLevels
/**
* Get the watch options suited for the given entity.
* @returns array<string, int>
* @return array<string, int>
*/
public static function allSuitedFor(Entity $entity): array
{

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

@@ -4,6 +4,7 @@ namespace BookStack\Api;
use BookStack\Access\LoginService;
use BookStack\Exceptions\ApiAuthException;
use BookStack\Permissions\Permission;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard;
@@ -146,7 +147,7 @@ class ApiTokenGuard implements Guard
throw new ApiAuthException(trans('errors.api_user_token_expired'), 403);
}
if (!$token->user->can('access-api')) {
if (!$token->user->can(Permission::AccessApi)) {
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
}
}

View File

@@ -4,6 +4,7 @@ namespace BookStack\Api;
use BookStack\Activity\ActivityType;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Users\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
@@ -16,8 +17,8 @@ class UserApiTokenController extends Controller
*/
public function create(Request $request, int $userId)
{
$this->checkPermission('access-api');
$this->checkPermissionOrCurrentUser('users-manage', $userId);
$this->checkPermission(Permission::AccessApi);
$this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId);
$this->updateContext($request);
$user = User::query()->findOrFail($userId);
@@ -35,8 +36,8 @@ class UserApiTokenController extends Controller
*/
public function store(Request $request, int $userId)
{
$this->checkPermission('access-api');
$this->checkPermissionOrCurrentUser('users-manage', $userId);
$this->checkPermission(Permission::AccessApi);
$this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId);
$this->validate($request, [
'name' => ['required', 'max:250'],
@@ -143,8 +144,8 @@ class UserApiTokenController extends Controller
*/
protected function checkPermissionAndFetchUserToken(int $userId, int $tokenId): array
{
$this->checkPermissionOr('users-manage', function () use ($userId) {
return $userId === user()->id && userCan('access-api');
$this->checkPermissionOr(Permission::UsersManage, function () use ($userId) {
return $userId === user()->id && userCan(Permission::AccessApi);
});
$user = User::query()->findOrFail($userId);

View File

@@ -8,7 +8,7 @@ class Model extends EloquentModel
{
/**
* Provides public access to get the raw attribute value from the model.
* Used in areas where no mutations are required but performance is critical.
* Used in areas where no mutations are required, but performance is critical.
*
* @return mixed
*/

View File

@@ -59,8 +59,8 @@ class AuthServiceProvider extends ServiceProvider
*/
public function register(): void
{
Auth::provider('external-users', function ($app, array $config) {
return new ExternalBaseUserProvider($config['model']);
Auth::provider('external-users', function () {
return new ExternalBaseUserProvider();
});
// Bind and provide the default system user as a singleton to the app instance when needed.

View File

@@ -15,7 +15,7 @@ class EventServiceProvider extends ServiceProvider
/**
* The event listener mappings for the application.
*
* @var array<class-string, array<int, class-string>>
* @var array<class-string, array<int, string>>
*/
protected $listen = [
SocialiteWasCalled::class => [

View File

@@ -3,6 +3,7 @@
namespace BookStack\App\Providers;
use BookStack\Entities\BreadcrumbsViewComposer;
use BookStack\Util\DateFormatter;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
@@ -10,6 +11,15 @@ use Illuminate\Support\ServiceProvider;
class ViewTweaksServiceProvider extends ServiceProvider
{
public function register()
{
$this->app->singleton(DateFormatter::class, function ($app) {
return new DateFormatter(
$app['config']->get('app.display_timezone'),
);
});
}
/**
* Bootstrap services.
*/
@@ -21,6 +31,9 @@ class ViewTweaksServiceProvider extends ServiceProvider
// View Composers
View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class);
// View Globals
View::share('dates', $this->app->make(DateFormatter::class));
// Custom blade view directives
Blade::directive('icon', function ($expression) {
return "<?php echo (new \BookStack\Util\SvgIcon($expression))->toHtml(); ?>";

View File

@@ -5,11 +5,8 @@ namespace BookStack\App;
/**
* Assigned to models that can have slugs.
* Must have the below properties.
*
* @property int $id
* @property string $name
*/
interface Sluggable
interface SluggableInterface
{
/**
* Regenerate the slug for this model.

View File

@@ -3,6 +3,7 @@
use BookStack\App\AppVersion;
use BookStack\App\Model;
use BookStack\Facades\Theme;
use BookStack\Permissions\Permission;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\SettingService;
use BookStack\Users\Models\User;
@@ -39,7 +40,7 @@ function user(): User
* Check if the current user has a permission. If an ownable element
* is passed in the jointPermissions are checked against that particular item.
*/
function userCan(string $permission, ?Model $ownable = null): bool
function userCan(string|Permission $permission, ?Model $ownable = null): bool
{
if (is_null($ownable)) {
return user()->can($permission);
@@ -55,7 +56,7 @@ function userCan(string $permission, ?Model $ownable = null): bool
* Check if the current user can perform the given action on any items in the system.
* Can be provided the class name of an entity to filter ability to that specific entity type.
*/
function userCanOnAny(string $action, string $entityClass = ''): bool
function userCanOnAny(string|Permission $action, string $entityClass = ''): bool
{
$permissions = app()->make(PermissionApplicator::class);

View File

@@ -70,8 +70,8 @@ return [
// A list of the sources/hostnames that can be reached by application SSR calls.
// This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
// Host-specific functionality (usually controlled via other options) like auth
// or user avatars for example, won't use this list.
// Space seperated if multiple. Can use '*' as a wildcard.
// or user avatars, for example, won't use this list.
// Space separated if multiple. Can use '*' as a wildcard.
// Values will be compared prefix-matched, case-insensitive, against called SSR urls.
// Defaults to allow all hosts.
'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'),
@@ -80,8 +80,10 @@ return [
// Integer value between 0 (IP hidden) to 4 (Full IP usage)
'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),
// Application timezone for back-end date functions.
// Application timezone for stored date/time values.
'timezone' => env('APP_TIMEZONE', 'UTC'),
// Application timezone for displayed date/time values in the UI.
'display_timezone' => env('APP_DISPLAY_TIMEZONE', env('APP_TIMEZONE', 'UTC')),
// Default locale to use
// A default variant is also stored since Laravel can overwrite

View File

@@ -85,6 +85,6 @@ return [
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache_'),
'prefix' => env('CACHE_PREFIX', 'bookstack_cache_'),
];

View File

@@ -75,7 +75,7 @@ return [
'collation' => 'utf8mb4_unicode_ci',
// Prefixes are only semi-supported and may be unstable
// since they are not tested as part of our automated test suite.
// If used, the prefix should not be changed otherwise you will likely receive errors.
// If used, the prefix should not be changed; otherwise you will likely receive errors.
'prefix' => env('DB_TABLE_PREFIX', ''),
'prefix_indexes' => true,
'strict' => false,
@@ -103,9 +103,7 @@ return [
],
// Migration Repository Table
// This table keeps track of all the migrations that have already run for
// your application. Using this information, we can determine which of
// the migrations on disk haven't actually been run in the database.
// This table keeps track of all the migrations that have already run for the application.
'migrations' => 'migrations',
// Redis configuration to use if set

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

@@ -8,7 +8,6 @@ use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\Rules\Unique;
class CreateAdminCommand extends Command
{
@@ -21,7 +20,9 @@ class CreateAdminCommand extends Command
{--email= : The email address for the new admin user}
{--name= : The name of the new admin user}
{--password= : The password to assign to the new admin user}
{--external-auth-id= : The external authentication system id for the new admin user (SAML2/LDAP/OIDC)}';
{--external-auth-id= : The external authentication system id for the new admin user (SAML2/LDAP/OIDC)}
{--generate-password : Generate a random password for the new admin user}
{--initial : Indicate if this should set/update the details of the initial admin user}';
/**
* The console command description.
@@ -35,26 +36,12 @@ class CreateAdminCommand extends Command
*/
public function handle(UserRepo $userRepo): int
{
$details = $this->snakeCaseOptions();
if (empty($details['email'])) {
$details['email'] = $this->ask('Please specify an email address for the new admin user');
}
if (empty($details['name'])) {
$details['name'] = $this->ask('Please specify a name for the new admin user');
}
if (empty($details['password'])) {
if (empty($details['external_auth_id'])) {
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
} else {
$details['password'] = Str::random(32);
}
}
$initialAdminOnly = $this->option('initial');
$shouldGeneratePassword = $this->option('generate-password');
$details = $this->gatherDetails($shouldGeneratePassword, $initialAdminOnly);
$validator = Validator::make($details, [
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
'email' => ['required', 'email', 'min:5'],
'name' => ['required', 'min:2'],
'password' => ['required_without:external_auth_id', Password::default()],
'external_auth_id' => ['required_without:password'],
@@ -68,16 +55,101 @@ class CreateAdminCommand extends Command
return 1;
}
$adminRole = Role::getSystemRole('admin');
if ($initialAdminOnly) {
$handled = $this->handleInitialAdminIfExists($userRepo, $details, $shouldGeneratePassword, $adminRole);
if ($handled !== null) {
return $handled;
}
}
$emailUsed = $userRepo->getByEmail($details['email']) !== null;
if ($emailUsed) {
$this->error("Could not create admin account.");
$this->error("An account with the email address \"{$details['email']}\" already exists.");
return 1;
}
$user = $userRepo->createWithoutActivity($validator->validated());
$user->attachRole(Role::getSystemRole('admin'));
$user->attachRole($adminRole);
$user->email_confirmed = true;
$user->save();
$this->info("Admin account with email \"{$user->email}\" successfully created!");
if ($shouldGeneratePassword) {
$this->line($details['password']);
} else {
$this->info("Admin account with email \"{$user->email}\" successfully created!");
}
return 0;
}
/**
* Handle updates to the original admin account if it exists.
* Returns an int return status if handled, otherwise returns null if not handled (new user to be created).
*/
protected function handleInitialAdminIfExists(UserRepo $userRepo, array $data, bool $generatePassword, Role $adminRole): int|null
{
$defaultAdmin = $userRepo->getByEmail('admin@admin.com');
if ($defaultAdmin && $defaultAdmin->hasSystemRole('admin')) {
if ($defaultAdmin->email !== $data['email'] && $userRepo->getByEmail($data['email']) !== null) {
$this->error("Could not create admin account.");
$this->error("An account with the email address \"{$data['email']}\" already exists.");
return 1;
}
$userRepo->updateWithoutActivity($defaultAdmin, $data, true);
if ($generatePassword) {
$this->line($data['password']);
} else {
$this->info("The default admin user has been updated with the provided details!");
}
return 0;
} else if ($adminRole->users()->count() > 0) {
$this->warn('Non-default admin user already exists. Skipping creation of new admin user.');
return 2;
}
return null;
}
protected function gatherDetails(bool $generatePassword, bool $initialAdmin): array
{
$details = $this->snakeCaseOptions();
if (empty($details['email'])) {
if ($initialAdmin) {
$details['email'] = 'admin@example.com';
} else {
$details['email'] = $this->ask('Please specify an email address for the new admin user');
}
}
if (empty($details['name'])) {
if ($initialAdmin) {
$details['name'] = 'Admin';
} else {
$details['name'] = $this->ask('Please specify a name for the new admin user');
}
}
if (empty($details['password'])) {
if (empty($details['external_auth_id'])) {
if ($generatePassword) {
$details['password'] = Str::random(32);
} else {
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
}
} else {
$details['password'] = Str::random(32);
}
}
return $details;
}
protected function snakeCaseOptions(): array
{
$returnOpts = [];

View File

@@ -45,14 +45,12 @@ 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'],
'comments' => ['html', 'text'],
'comments' => ['html'],
];
foreach ($columnsToUpdateByTable as $table => $columns) {

View File

@@ -11,6 +11,7 @@ use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -47,7 +48,7 @@ class BookApiController extends ApiController
*/
public function create(Request $request)
{
$this->checkPermission('book-create-all');
$this->checkPermission(Permission::BookCreateAll);
$requestData = $this->validate($request, $this->rules()['create']);
$book = $this->bookRepo->create($requestData);
@@ -57,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.
*/
@@ -92,7 +93,7 @@ class BookApiController extends ApiController
public function update(Request $request, string $id)
{
$book = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('book-update', $book);
$this->checkOwnablePermission(Permission::BookUpdate, $book);
$requestData = $this->validate($request, $this->rules()['update']);
$book = $this->bookRepo->update($book, $requestData);
@@ -109,7 +110,7 @@ class BookApiController extends ApiController
public function delete(string $id)
{
$book = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('book-delete', $book);
$this->checkOwnablePermission(Permission::BookDelete, $book);
$this->bookRepo->destroy($book);
@@ -121,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

@@ -17,6 +17,7 @@ use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceFetcher;
use BookStack\Util\DatabaseTransaction;
use BookStack\Util\SimpleListOptions;
@@ -73,12 +74,12 @@ class BookController extends Controller
*/
public function create(?string $shelfSlug = null)
{
$this->checkPermission('book-create-all');
$this->checkPermission(Permission::BookCreateAll);
$bookshelf = null;
if ($shelfSlug !== null) {
$bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
$this->checkOwnablePermission(Permission::BookshelfUpdate, $bookshelf);
}
$this->setPageTitle(trans('entities.books_create'));
@@ -96,7 +97,7 @@ class BookController extends Controller
*/
public function store(Request $request, ?string $shelfSlug = null)
{
$this->checkPermission('book-create-all');
$this->checkPermission(Permission::BookCreateAll);
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
@@ -108,7 +109,7 @@ class BookController extends Controller
$bookshelf = null;
if ($shelfSlug !== null) {
$bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
$this->checkOwnablePermission(Permission::BookshelfUpdate, $bookshelf);
}
$book = $this->bookRepo->create($validated);
@@ -154,7 +155,7 @@ class BookController extends Controller
public function edit(string $slug)
{
$book = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('book-update', $book);
$this->checkOwnablePermission(Permission::BookUpdate, $book);
$this->setPageTitle(trans('entities.books_edit_named', ['bookName' => $book->getShortName()]));
return view('books.edit', ['book' => $book, 'current' => $book]);
@@ -170,7 +171,7 @@ class BookController extends Controller
public function update(Request $request, string $slug)
{
$book = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('book-update', $book);
$this->checkOwnablePermission(Permission::BookUpdate, $book);
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
@@ -197,7 +198,7 @@ class BookController extends Controller
public function showDelete(string $bookSlug)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-delete', $book);
$this->checkOwnablePermission(Permission::BookDelete, $book);
$this->setPageTitle(trans('entities.books_delete_named', ['bookName' => $book->getShortName()]));
return view('books.delete', ['book' => $book, 'current' => $book]);
@@ -211,7 +212,7 @@ class BookController extends Controller
public function destroy(string $bookSlug)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-delete', $book);
$this->checkOwnablePermission(Permission::BookDelete, $book);
$this->bookRepo->destroy($book);
@@ -226,7 +227,7 @@ class BookController extends Controller
public function showCopy(string $bookSlug)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-view', $book);
$this->checkOwnablePermission(Permission::BookView, $book);
session()->flashInput(['name' => $book->name]);
@@ -243,8 +244,8 @@ class BookController extends Controller
public function copy(Request $request, Cloner $cloner, string $bookSlug)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-view', $book);
$this->checkPermission('book-create-all');
$this->checkOwnablePermission(Permission::BookView, $book);
$this->checkPermission(Permission::BookCreateAll);
$newName = $request->get('name') ?: $book->name;
$bookCopy = $cloner->cloneBook($book, $newName);
@@ -259,10 +260,10 @@ class BookController extends Controller
public function convertToShelf(HierarchyTransformer $transformer, string $bookSlug)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-update', $book);
$this->checkOwnablePermission('book-delete', $book);
$this->checkPermission('bookshelf-create-all');
$this->checkPermission('book-create-all');
$this->checkOwnablePermission(Permission::BookUpdate, $book);
$this->checkOwnablePermission(Permission::BookDelete, $book);
$this->checkPermission(Permission::BookshelfCreateAll);
$this->checkPermission(Permission::BookCreateAll);
$shelf = (new DatabaseTransaction(function () use ($book, $transformer) {
return $transformer->transformBookToShelf($book);

View File

@@ -6,6 +6,7 @@ use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use Exception;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\Request;
@@ -45,7 +46,7 @@ class BookshelfApiController extends ApiController
*/
public function create(Request $request)
{
$this->checkPermission('bookshelf-create-all');
$this->checkPermission(Permission::BookshelfCreateAll);
$requestData = $this->validate($request, $this->rules()['create']);
$bookIds = $request->get('books', []);
@@ -84,7 +85,7 @@ class BookshelfApiController extends ApiController
public function update(Request $request, string $id)
{
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('bookshelf-update', $shelf);
$this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf);
$requestData = $this->validate($request, $this->rules()['update']);
$bookIds = $request->get('books', null);
@@ -103,7 +104,7 @@ class BookshelfApiController extends ApiController
public function delete(string $id)
{
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->checkOwnablePermission(Permission::BookshelfDelete, $shelf);
$this->bookshelfRepo->destroy($shelf);
@@ -115,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

@@ -11,6 +11,7 @@ use BookStack\Entities\Tools\ShelfContext;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceFetcher;
use BookStack\Util\SimpleListOptions;
use Exception;
@@ -68,7 +69,7 @@ class BookshelfController extends Controller
*/
public function create()
{
$this->checkPermission('bookshelf-create-all');
$this->checkPermission(Permission::BookshelfCreateAll);
$books = $this->bookQueries->visibleForList()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
$this->setPageTitle(trans('entities.shelves_create'));
@@ -83,7 +84,7 @@ class BookshelfController extends Controller
*/
public function store(Request $request)
{
$this->checkPermission('bookshelf-create-all');
$this->checkPermission(Permission::BookshelfCreateAll);
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
@@ -105,7 +106,7 @@ class BookshelfController extends Controller
public function show(Request $request, ActivityQueries $activities, string $slug)
{
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-view', $shelf);
$this->checkOwnablePermission(Permission::BookshelfView, $shelf);
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
'default' => trans('common.sort_default'),
@@ -115,6 +116,7 @@ class BookshelfController extends Controller
]);
$sort = $listOptions->getSort();
$sortedVisibleShelfBooks = $shelf->visibleBooks()
->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder())
->get()
@@ -143,7 +145,7 @@ class BookshelfController extends Controller
public function edit(string $slug)
{
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-update', $shelf);
$this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf);
$shelfBookIds = $shelf->books()->get(['id'])->pluck('id');
$books = $this->bookQueries->visibleForList()
@@ -169,7 +171,7 @@ class BookshelfController extends Controller
public function update(Request $request, string $slug)
{
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-update', $shelf);
$this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf);
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
@@ -195,7 +197,7 @@ class BookshelfController extends Controller
public function showDelete(string $slug)
{
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->checkOwnablePermission(Permission::BookshelfDelete, $shelf);
$this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()]));
@@ -210,7 +212,7 @@ class BookshelfController extends Controller
public function destroy(string $slug)
{
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->checkOwnablePermission(Permission::BookshelfDelete, $shelf);
$this->shelfRepo->destroy($shelf);

View File

@@ -9,6 +9,7 @@ use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use Exception;
use Illuminate\Http\Request;
@@ -65,7 +66,7 @@ class ChapterApiController extends ApiController
$bookId = $request->get('book_id');
$book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId));
$this->checkOwnablePermission('chapter-create', $book);
$this->checkOwnablePermission(Permission::ChapterCreate, $book);
$chapter = $this->chapterRepo->create($requestData, $book);
@@ -101,10 +102,10 @@ class ChapterApiController extends ApiController
{
$requestData = $this->validate($request, $this->rules()['update']);
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) {
$this->checkOwnablePermission('chapter-delete', $chapter);
if ($request->has('book_id') && $chapter->book_id !== (intval($requestData['book_id']) ?: null)) {
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
try {
$this->chapterRepo->move($chapter, "book:{$requestData['book_id']}");
@@ -129,7 +130,7 @@ class ChapterApiController extends ApiController
public function delete(string $id)
{
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
$this->chapterRepo->destroy($chapter);
@@ -143,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

@@ -17,6 +17,7 @@ use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceFetcher;
use BookStack\Util\DatabaseTransaction;
use Illuminate\Http\Request;
@@ -39,7 +40,7 @@ class ChapterController extends Controller
public function create(string $bookSlug)
{
$book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('chapter-create', $book);
$this->checkOwnablePermission(Permission::ChapterCreate, $book);
$this->setPageTitle(trans('entities.chapters_create'));
@@ -64,7 +65,7 @@ class ChapterController extends Controller
]);
$book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('chapter-create', $book);
$this->checkOwnablePermission(Permission::ChapterCreate, $book);
$chapter = $this->chapterRepo->create($validated, $book);
@@ -77,7 +78,6 @@ class ChapterController extends Controller
public function show(string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
$sidebarTree = (new BookContents($chapter->book))->getTree();
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
@@ -106,7 +106,7 @@ class ChapterController extends Controller
public function edit(string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
$this->setPageTitle(trans('entities.chapters_edit_named', ['chapterName' => $chapter->getShortName()]));
@@ -128,9 +128,9 @@ class ChapterController extends Controller
]);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
$this->chapterRepo->update($chapter, $validated);
$chapter = $this->chapterRepo->update($chapter, $validated);
return redirect($chapter->getUrl());
}
@@ -143,7 +143,7 @@ class ChapterController extends Controller
public function showDelete(string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
$this->setPageTitle(trans('entities.chapters_delete_named', ['chapterName' => $chapter->getShortName()]));
@@ -159,7 +159,7 @@ class ChapterController extends Controller
public function destroy(string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
$this->chapterRepo->destroy($chapter);
@@ -175,8 +175,8 @@ class ChapterController extends Controller
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->setPageTitle(trans('entities.chapters_move_named', ['chapterName' => $chapter->getShortName()]));
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
return view('chapters.move', [
'chapter' => $chapter,
@@ -192,8 +192,8 @@ class ChapterController extends Controller
public function move(Request $request, string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
$entitySelection = $request->get('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') {
@@ -221,7 +221,6 @@ class ChapterController extends Controller
public function showCopy(string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
session()->flashInput(['name' => $chapter->name]);
@@ -240,7 +239,6 @@ class ChapterController extends Controller
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-view', $chapter);
$entitySelection = $request->get('entity_selection') ?: null;
$newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent();
@@ -251,7 +249,7 @@ class ChapterController extends Controller
return redirect($chapter->getUrl('/copy'));
}
$this->checkOwnablePermission('chapter-create', $newParentBook);
$this->checkOwnablePermission(Permission::ChapterCreate, $newParentBook);
$newName = $request->get('name') ?: $chapter->name;
$chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName);
@@ -266,9 +264,9 @@ class ChapterController extends Controller
public function convertToBook(HierarchyTransformer $transformer, string $bookSlug, string $chapterSlug)
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->checkPermission('book-create-all');
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
$this->checkPermission(Permission::BookCreateAll);
$book = (new DatabaseTransaction(function () use ($chapter, $transformer) {
return $transformer->transformChapterToBook($chapter);

View File

@@ -2,11 +2,13 @@
namespace BookStack\Entities\Controllers;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use Exception;
use Illuminate\Http\Request;
@@ -76,7 +78,7 @@ class PageApiController extends ApiController
} else {
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
}
$this->checkOwnablePermission('page-create', $parent);
$this->checkOwnablePermission(Permission::PageCreate, $parent);
$draft = $this->pageRepo->getNewDraftPage($parent);
$this->pageRepo->publishDraft($draft, $request->only(array_keys($this->rules['create'])));
@@ -87,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);
}
/**
@@ -116,7 +129,7 @@ class PageApiController extends ApiController
$requestData = $this->validate($request, $this->rules['update']);
$page = $this->queries->findVisibleByIdOrFail($id);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$parent = null;
if ($request->has('chapter_id')) {
@@ -126,7 +139,7 @@ class PageApiController extends ApiController
}
if ($parent && !$parent->matches($page->getParent())) {
$this->checkOwnablePermission('page-delete', $page);
$this->checkOwnablePermission(Permission::PageDelete, $page);
try {
$this->pageRepo->move($page, $parent->getType() . ':' . $parent->id);
@@ -151,7 +164,7 @@ class PageApiController extends ApiController
public function delete(string $id)
{
$page = $this->queries->findVisibleByIdOrFail($id);
$this->checkOwnablePermission('page-delete', $page);
$this->checkOwnablePermission(Permission::PageDelete, $page);
$this->pageRepo->destroy($page);

View File

@@ -20,6 +20,7 @@ use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceFetcher;
use Exception;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -50,7 +51,7 @@ class PageController extends Controller
$parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
}
$this->checkOwnablePermission('page-create', $parent);
$this->checkOwnablePermission(Permission::PageCreate, $parent);
// Redirect to draft edit screen if signed in
if ($this->isSignedIn()) {
@@ -82,7 +83,7 @@ class PageController extends Controller
$parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
}
$this->checkOwnablePermission('page-create', $parent);
$this->checkOwnablePermission(Permission::PageCreate, $parent);
$page = $this->pageRepo->getNewDraftPage($parent);
$this->pageRepo->publishDraft($page, [
@@ -100,7 +101,7 @@ class PageController extends Controller
public function editDraft(Request $request, string $bookSlug, int $pageId)
{
$draft = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission('page-create', $draft->getParent());
$this->checkOwnablePermission(Permission::PageCreate, $draft->getParent());
$editorData = new PageEditorData($draft, $this->entityQueries, $request->query('editor', ''));
$this->setPageTitle(trans('entities.pages_edit_draft'));
@@ -119,8 +120,9 @@ class PageController extends Controller
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
]);
$draftPage = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission('page-create', $draftPage->getParent());
$this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent());
$page = $this->pageRepo->publishDraft($draftPage, $request->all());
@@ -148,8 +150,6 @@ class PageController extends Controller
return redirect($page->getUrl());
}
$this->checkOwnablePermission('page-view', $page);
$pageContent = (new PageContent($page));
$page->html = $pageContent->render();
$pageNav = $pageContent->getNavigation($page->html);
@@ -197,7 +197,7 @@ class PageController extends Controller
public function edit(Request $request, string $bookSlug, string $pageSlug)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page, $page->getUrl());
$this->checkOwnablePermission(Permission::PageUpdate, $page, $page->getUrl());
$editorData = new PageEditorData($page, $this->entityQueries, $request->query('editor', ''));
if ($editorData->getWarnings()) {
@@ -221,7 +221,7 @@ class PageController extends Controller
'name' => ['required', 'string', 'max:255'],
]);
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$this->pageRepo->update($page, $request->all());
@@ -236,7 +236,7 @@ class PageController extends Controller
public function saveDraft(Request $request, int $pageId)
{
$page = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
if (!$this->isSignedIn()) {
return $this->jsonError(trans('errors.guests_cannot_save_drafts'), 500);
@@ -273,7 +273,7 @@ class PageController extends Controller
public function showDelete(string $bookSlug, string $pageSlug)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$this->checkOwnablePermission(Permission::PageDelete, $page);
$this->setPageTitle(trans('entities.pages_delete_named', ['pageName' => $page->getShortName()]));
$usedAsTemplate =
$this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 ||
@@ -295,7 +295,7 @@ class PageController extends Controller
public function showDeleteDraft(string $bookSlug, int $pageId)
{
$page = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName' => $page->getShortName()]));
$usedAsTemplate =
$this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 ||
@@ -318,7 +318,7 @@ class PageController extends Controller
public function destroy(string $bookSlug, string $pageSlug)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$this->checkOwnablePermission(Permission::PageDelete, $page);
$parent = $page->getParent();
$this->pageRepo->destroy($page);
@@ -337,13 +337,13 @@ class PageController extends Controller
$page = $this->queries->findVisibleByIdOrFail($pageId);
$book = $page->book;
$chapter = $page->chapter;
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$this->pageRepo->destroy($page);
$this->showSuccessNotification(trans('entities.pages_delete_draft_success'));
if ($chapter && userCan('view', $chapter)) {
if ($chapter && userCan(Permission::ChapterView, $chapter)) {
return redirect($chapter->getUrl());
}
@@ -384,8 +384,8 @@ class PageController extends Controller
public function showMove(string $bookSlug, string $pageSlug)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('page-delete', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$this->checkOwnablePermission(Permission::PageDelete, $page);
return view('pages.move', [
'book' => $page->book,
@@ -402,8 +402,8 @@ class PageController extends Controller
public function move(Request $request, string $bookSlug, string $pageSlug)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('page-delete', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$this->checkOwnablePermission(Permission::PageDelete, $page);
$entitySelection = $request->get('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') {
@@ -431,7 +431,6 @@ class PageController extends Controller
public function showCopy(string $bookSlug, string $pageSlug)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-view', $page);
session()->flashInput(['name' => $page->name]);
return view('pages.copy', [
@@ -449,7 +448,7 @@ class PageController extends Controller
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug)
{
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-view', $page);
$this->checkOwnablePermission(Permission::PageView, $page);
$entitySelection = $request->get('entity_selection') ?: null;
$newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent();
@@ -460,7 +459,7 @@ class PageController extends Controller
return redirect($page->getUrl('/copy'));
}
$this->checkOwnablePermission('page-create', $newParent);
$this->checkOwnablePermission(Permission::PageCreate, $newParent);
$newName = $request->get('name') ?: $page->name;
$pageCopy = $cloner->clonePage($page, $newParent, $newName);

View File

@@ -11,6 +11,7 @@ use BookStack\Entities\Tools\PageContent;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
use Ssddanbrown\HtmlDiff\Diff;
@@ -98,7 +99,7 @@ class PageRevisionController extends Controller
throw new NotFoundException();
}
$prev = $revision->getPrevious();
$prev = $revision->getPreviousRevision();
$prevContent = $prev->html ?? '';
$diff = Diff::excecute($prevContent, $revision->html);
@@ -124,7 +125,7 @@ class PageRevisionController extends Controller
public function restore(string $bookSlug, string $pageSlug, int $revisionId)
{
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$page = $this->pageRepo->restoreRevision($page, $revisionId);
@@ -139,7 +140,7 @@ class PageRevisionController extends Controller
public function destroy(string $bookSlug, string $pageSlug, int $revId)
{
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$this->checkOwnablePermission(Permission::PageDelete, $page);
$revision = $page->revisions()->where('id', '=', $revId)->first();
if ($revision === null) {

View File

@@ -6,18 +6,20 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Deletion;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\DeletionRepo;
use BookStack\Http\ApiController;
use Closure;
use BookStack\Permissions\Permission;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
class RecycleBinApiController extends ApiController
{
public function __construct()
{
$this->middleware(function ($request, $next) {
$this->checkPermission('settings-manage');
$this->checkPermission('restrictions-manage-all');
$this->checkPermission(Permission::SettingsManage);
$this->checkPermission(Permission::RestrictionsManageAll);
return $next($request);
});
@@ -40,7 +42,7 @@ class RecycleBinApiController extends ApiController
'updated_at',
'deletable_type',
'deletable_id',
], [Closure::fromCallable([$this, 'listFormatter'])]);
], [$this->listFormatter(...)]);
}
/**
@@ -69,10 +71,9 @@ class RecycleBinApiController extends ApiController
/**
* Load some related details for the deletion listing.
*/
protected function listFormatter(Deletion $deletion)
protected function listFormatter(Deletion $deletion): void
{
$deletable = $deletion->deletable;
$withTrashedQuery = fn (Builder $query) => $query->withTrashed();
if ($deletable instanceof BookChild) {
$parent = $deletable->getParent();
@@ -81,11 +82,19 @@ class RecycleBinApiController extends ApiController
}
if ($deletable instanceof Book || $deletable instanceof Chapter) {
$countsToLoad = ['pages' => $withTrashedQuery];
$countsToLoad = ['pages' => static::withTrashedQuery(...)];
if ($deletable instanceof Book) {
$countsToLoad['chapters'] = $withTrashedQuery;
$countsToLoad['chapters'] = static::withTrashedQuery(...);
}
$deletable->loadCount($countsToLoad);
}
}
/**
* @param Builder<Chapter|Page> $query
*/
protected static function withTrashedQuery(Builder $query): void
{
$query->withTrashed();
}
}

View File

@@ -8,6 +8,7 @@ use BookStack\Entities\Models\Entity;
use BookStack\Entities\Repos\DeletionRepo;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
class RecycleBinController extends Controller
{
@@ -20,8 +21,8 @@ class RecycleBinController extends Controller
public function __construct()
{
$this->middleware(function ($request, $next) {
$this->checkPermission('settings-manage');
$this->checkPermission('restrictions-manage-all');
$this->checkPermission(Permission::SettingsManage);
$this->checkPermission(Permission::RestrictionsManageAll);
return $next($request);
});

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 HasCoverImage
class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface
{
use HasFactory;
use HasHtmlDescription;
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,57 +44,9 @@ class Book extends Entity implements HasCoverImage
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>
*/
public function pages(): HasMany
{
@@ -106,15 +58,17 @@ class Book extends Entity implements HasCoverImage
*/
public function directPages(): HasMany
{
return $this->pages()->where('chapter_id', '=', '0');
return $this->pages()->whereNull('chapter_id');
}
/**
* Get all chapters within this book.
* @return HasMany<Chapter, $this>
*/
public function chapters(): HasMany
{
return $this->hasMany(Chapter::class);
return $this->hasMany(Chapter::class)
->where('type', '=', 'chapter');
}
/**
@@ -135,4 +89,27 @@ class Book extends Entity implements HasCoverImage
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 HasCoverImage
/**
* @property string $description
* @property string $description_html
*/
class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInterface
{
use HasFactory;
use HasHtmlDescription;
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,40 +50,6 @@ class Bookshelf extends Entity implements HasCoverImage
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.
*/
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.
*/
@@ -95,7 +61,7 @@ class Bookshelf extends Entity implements HasCoverImage
/**
* 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;
@@ -105,12 +71,13 @@ class Bookshelf extends Entity implements HasCoverImage
$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,32 +2,30 @@
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
class Chapter extends BookChild implements HasDescriptionInterface, HasDefaultTemplateInterface
{
use HasFactory;
use HasHtmlDescription;
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.
*
* @return HasMany<Page>
* @return HasMany<Page, $this>
*/
public function pages(string $dir = 'ASC'): HasMany
{
@@ -50,17 +48,9 @@ class Chapter extends BookChild
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.
* @returns Collection<Page>
* @return Collection<Page>
*/
public function getVisiblePages(): Collection
{
@@ -70,4 +60,9 @@ class Chapter extends BookChild
->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

@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
* A model that can be deleted in a manner that deletions
* are tracked to be part of the recycle bin system.
*/
interface Deletable
interface DeletableInterface
{
public function deletions(): MorphMany;
}

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;
@@ -13,10 +14,12 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
* @property int $deleted_by
* @property string $deletable_type
* @property int $deletable_id
* @property Deletable $deletable
* @property DeletableInterface $deletable
*/
class Deletion extends Model implements Loggable
{
use HasFactory;
protected $hidden = [];
/**

View File

@@ -12,7 +12,7 @@ use BookStack\Activity\Models\View;
use BookStack\Activity\Models\Viewable;
use BookStack\Activity\Models\Watch;
use BookStack\App\Model;
use BookStack\App\Sluggable;
use BookStack\App\SluggableInterface;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Permissions\JointPermissionBuilder;
use BookStack\Permissions\Models\EntityPermission;
@@ -22,38 +22,47 @@ use BookStack\References\Reference;
use BookStack\Search\SearchIndex;
use BookStack\Search\SearchTerm;
use BookStack\Users\Models\HasCreatorAndUpdater;
use BookStack\Users\Models\HasOwner;
use BookStack\Users\Models\OwnableInterface;
use BookStack\Users\Models\User;
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|null $created_by
* @property int|null $updated_by
* @property int|null $owned_by
* @property Collection $tags
*
* @method static Entity|Builder visible()
* @method static Builder withLastView()
* @method static Builder withViewCount()
*/
abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable, Loggable
abstract class Entity extends Model implements
SluggableInterface,
Favouritable,
Viewable,
DeletableInterface,
OwnableInterface,
Loggable
{
use SoftDeletes;
use HasCreatorAndUpdater;
use HasOwner;
/**
* @var string - Name of property where the main text content is found
@@ -70,6 +79,72 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
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.
*/
@@ -84,8 +159,8 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
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);
@@ -95,11 +170,12 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
/**
* 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]);
}
@@ -155,15 +231,17 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
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;
}
@@ -177,7 +255,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
}
/**
* Get this entities restrictions.
* Get this entities assigned permissions.
*/
public function permissions(): MorphMany
{
@@ -200,6 +278,20 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
return $this->morphMany(JointPermission::class, 'entity');
}
/**
* Get the user who owns this entity.
* @return BelongsTo<User, $this>
*/
public function ownedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'owned_by');
}
public function getOwnerFieldName(): string
{
return 'owned_by';
}
/**
* Get the related delete records for this entity.
*/
@@ -246,7 +338,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
}
/**
* 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
{
@@ -318,7 +410,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public function refreshSlug(): string
{
$this->slug = app()->make(SlugGenerator::class)->generate($this);
$this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
return $this->slug;
}
@@ -356,4 +448,40 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
{
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

@@ -1,18 +0,0 @@
<?php
namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
interface HasCoverImage
{
/**
* 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

@@ -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,21 +0,0 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\Util\HtmlContentFilter;
/**
* @property string $description
* @property string $description_html
*/
trait HasHtmlDescription
{
/**
* Get the HTML description for this book.
*/
public function descriptionHtml(): string
{
$html = $this->description_html ?: '<p>' . nl2br(e($this->description)) . '</p>';
return HtmlContentFilter::removeScriptsFromHtmlString($html);
}
}

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'];
@@ -60,7 +63,7 @@ class PageRevision extends Model implements Loggable
/**
* Get the previous revision for the same page if existing.
*/
public function getPrevious(): ?PageRevision
public function getPreviousRevision(): ?PageRevision
{
$id = static::newQuery()->where('page_id', '=', $this->page_id)
->where('id', '<', $this->id)

View File

@@ -6,6 +6,9 @@ use BookStack\Entities\Models\Book;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Database\Eloquent\Builder;
/**
* @implements ProvidesEntityQueries<Book>
*/
class BookQueries implements ProvidesEntityQueries
{
protected static array $listAttributes = [
@@ -13,6 +16,9 @@ class BookQueries implements ProvidesEntityQueries
'created_at', 'updated_at', 'image_id', 'owned_by',
];
/**
* @return Builder<Book>
*/
public function start(): Builder
{
return Book::query();
@@ -49,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

@@ -6,6 +6,9 @@ use BookStack\Entities\Models\Bookshelf;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Database\Eloquent\Builder;
/**
* @implements ProvidesEntityQueries<Bookshelf>
*/
class BookshelfQueries implements ProvidesEntityQueries
{
protected static array $listAttributes = [
@@ -13,6 +16,9 @@ class BookshelfQueries implements ProvidesEntityQueries
'created_at', 'updated_at', 'image_id', 'owned_by',
];
/**
* @return Builder<Bookshelf>
*/
public function start(): Builder
{
return Bookshelf::query();
@@ -54,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

@@ -6,6 +6,9 @@ use BookStack\Entities\Models\Chapter;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Database\Eloquent\Builder;
/**
* @implements ProvidesEntityQueries<Chapter>
*/
class ChapterQueries implements ProvidesEntityQueries
{
protected static array $listAttributes = [
@@ -62,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,19 +36,55 @@ 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
{
/** @var ?ProvidesEntityQueries $queries */
$queries = match ($type) {
'page' => $this->pages,
'chapter' => $this->chapters,

View File

@@ -6,11 +6,14 @@ use BookStack\Entities\Models\Page;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Database\Eloquent\Builder;
/**
* @implements ProvidesEntityQueries<Page>
*/
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 = [
@@ -18,6 +21,9 @@ class PageQueries implements ProvidesEntityQueries
'template', 'text', 'created_at', 'updated_at', 'priority', 'owned_by',
];
/**
* @return Builder<Page>
*/
public function start(): Builder
{
return Page::query();
@@ -66,6 +72,9 @@ class PageQueries implements ProvidesEntityQueries
});
}
/**
* @return Builder<Page>
*/
public function visibleForList(): Builder
{
return $this->start()
@@ -73,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()
@@ -95,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

@@ -7,28 +7,39 @@ use Illuminate\Database\Eloquent\Builder;
/**
* Interface for our classes which provide common queries for our
* entity objects. Ideally all queries for entities should run through
* entity objects. Ideally, all queries for entities should run through
* these classes.
* Any added methods should return a builder instances to allow extension
* via building on the query, unless the method starts with 'find'
* in which case an entity object should be returned.
* (nullable unless it's a *OrFail method).
*
* @template TModel of Entity
*/
interface ProvidesEntityQueries
{
/**
* Start a new query for this entity type.
* @return Builder<TModel>
*/
public function start(): Builder;
/**
* Find the entity of the given ID, or return null if not found.
* Find the entity of the given ID or return null if not found.
*/
public function findVisibleById(int $id): ?Entity;
/**
* Start a query for items that are visible, with selection
* configured for list display of this item.
* @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,12 +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\HasCoverImage;
use BookStack\Entities\Models\HasHtmlDescription;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore;
@@ -32,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'])) {
@@ -52,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'])) {
@@ -83,61 +98,35 @@ class BaseRepo
if ($oldUrl !== $entity->getUrl()) {
$this->referenceUpdater->updateEntityReferences($entity, $oldUrl);
}
return $entity;
}
/**
* Update the given items' cover image, or clear it.
*
* @param Entity&HasCoverImage $entity
* Update the given items' cover image or clear it.
*
* @throws ImageUploadException
* @throws \Exception
*/
public function updateCoverImage($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->image_id = 0;
$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
@@ -148,20 +137,22 @@ class BaseRepo
}
}
/**
* Update the description of the given entity from input data.
*/
protected function updateDescription(Entity $entity, array $input): void
{
if (!in_array(HasHtmlDescription::class, class_uses($entity))) {
if (!$entity instanceof HasDescriptionInterface) {
return;
}
/** @var HasHtmlDescription $entity */
if (isset($input['description_html'])) {
$entity->description_html = HtmlDescriptionFilter::filterFromString($input['description_html']);
$entity->description = html_entity_decode(strip_tags($input['description_html']));
$entity->descriptionInfo()->set(
HtmlDescriptionFilter::filterFromString($input['description_html']),
html_entity_decode(strip_tags($input['description_html']))
);
} else if (isset($input['description'])) {
$entity->description = $input['description'];
$entity->description_html = '';
$entity->description_html = $entity->descriptionHtml();
$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

@@ -11,6 +11,7 @@ use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity;
use BookStack\Permissions\Permission;
use BookStack\Util\DatabaseTransaction;
use Exception;
@@ -32,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);
@@ -47,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);
@@ -65,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);
@@ -87,12 +92,12 @@ class ChapterRepo
throw new MoveOperationException('Book to move chapter into not found');
}
if (!userCan('chapter-create', $parent)) {
if (!userCan(Permission::ChapterCreate, $parent)) {
throw new PermissionsException('User does not have permission to create a chapter within the chosen book');
}
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

@@ -16,6 +16,7 @@ use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater;
use BookStack\Util\DatabaseTransaction;
@@ -36,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'),
@@ -45,6 +46,9 @@ class PageRepo
'updated_by' => user()->id,
'draft' => true,
'editor' => PageEditorType::getSystemDefault()->value,
'html' => '',
'markdown' => '',
'text' => '',
]);
if ($parent instanceof Chapter) {
@@ -54,17 +58,18 @@ class PageRepo
$page->book_id = $parent->id;
}
$defaultTemplate = $page->chapter->defaultTemplate ?? $page->book->defaultTemplate;
if ($defaultTemplate && userCan('view', $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;
@@ -80,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');
@@ -111,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++;
@@ -142,7 +148,7 @@ class PageRepo
protected function updateTemplateStatusAndContentFromInput(Page $page, array $input): void
{
if (isset($input['template']) && userCan('templates-manage')) {
if (isset($input['template']) && userCan(Permission::TemplatesManage)) {
$page->template = ($input['template'] === 'true');
}
@@ -165,7 +171,7 @@ class PageRepo
$pageContent->setNewHTML($input['html'], user());
}
if (($newEditor !== $currentEditor || empty($page->editor)) && userCan('editor-change')) {
if (($newEditor !== $currentEditor || empty($page->editor)) && userCan(Permission::EditorChange)) {
$page->editor = $newEditor->value;
} elseif (empty($page->editor)) {
$page->editor = $defaultEditor->value;
@@ -175,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;
@@ -208,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);
@@ -271,14 +277,14 @@ class PageRepo
throw new MoveOperationException('Book or chapter to move page into not found');
}
if (!userCan('page-create', $parent)) {
if (!userCan(Permission::PageCreate, $parent)) {
throw new PermissionsException('User does not have permission to create a page within the new parent');
}
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,12 +6,13 @@ 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\HasCoverImage;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Permissions\Permission;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService;
use Illuminate\Http\UploadedFile;
@@ -49,7 +50,7 @@ class Cloner
$copyChapter = $this->chapterRepo->create($chapterDetails, $parent);
if (userCan('page-create', $copyChapter)) {
if (userCan(Permission::PageCreate, $copyChapter)) {
/** @var Page $page */
foreach ($original->getVisiblePages() as $page) {
$this->clonePage($page, $copyChapter, $page->name);
@@ -61,7 +62,7 @@ class Cloner
/**
* Clone the given book.
* Clones all child chapters & pages.
* Clones all child chapters and pages.
*/
public function cloneBook(Book $original, string $newName): Book
{
@@ -74,11 +75,11 @@ class Cloner
// Clone contents
$directChildren = $original->getDirectVisibleChildren();
foreach ($directChildren as $child) {
if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) {
if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) {
$this->cloneChapter($child, $copyBook, $child->name);
}
if ($child instanceof Page && !$child->draft && userCan('page-create', $copyBook)) {
if ($child instanceof Page && !$child->draft && userCan(Permission::PageCreate, $copyBook)) {
$this->clonePage($child, $copyBook, $child->name);
}
}
@@ -86,7 +87,7 @@ class Cloner
// Clone bookshelf relationships
/** @var Bookshelf $shelf */
foreach ($original->shelves as $shelf) {
if (userCan('bookshelf-update', $shelf)) {
if (userCan(Permission::BookshelfUpdate, $shelf)) {
$shelf->appendBook($copyBook);
}
}
@@ -105,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 HasCoverImage) {
$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

@@ -7,6 +7,7 @@ use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Facades\Theme;
use BookStack\Permissions\Permission;
use BookStack\Theming\ThemeEvents;
use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageService;
@@ -122,7 +123,7 @@ class PageContent
$imageInfo = $this->parseBase64ImageUri($uri);
// Validate user has permission to create images
if (!$updater->can('image-create-all')) {
if (!$updater->can(Permission::ImageCreateAll)) {
return '';
}
@@ -283,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

@@ -4,19 +4,15 @@ namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\PageRevision;
use BookStack\Util\DateFormatter;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
class PageEditActivity
{
protected Page $page;
/**
* PageEditActivity constructor.
*/
public function __construct(Page $page)
{
$this->page = $page;
public function __construct(
protected Page $page
) {
}
/**
@@ -50,11 +46,9 @@ class PageEditActivity
/**
* Get any editor clash warning messages to show for the given draft revision.
*
* @param PageRevision|Page $draft
*
* @return string[]
*/
public function getWarningMessagesForDraft($draft): array
public function getWarningMessagesForDraft(Page|PageRevision $draft): array
{
$warnings = [];
@@ -82,7 +76,8 @@ class PageEditActivity
*/
public function getEditingActiveDraftMessage(PageRevision $draft): string
{
$message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]);
$formatter = resolve(DateFormatter::class);
$message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $formatter->relative($draft->updated_at)]);
if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) {
return $message;
}

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