Compare commits

..

61 Commits

Author SHA1 Message Date
Dan Brown
9d1c0e5dda Dev: Played with an all-in-one docker environment 2025-10-06 13:06:12 +01:00
Dan Brown
146a6c01cc Merge branch 'v25-07' into development 2025-10-05 15:28:29 +01:00
Dan Brown
f8e4ea82c6 Updated translator & dependency attribution before release v25.07.3 2025-10-05 15:26:37 +01:00
Dan Brown
047195c033 Updated translations with latest Crowdin changes (#5786) 2025-10-05 15:22:37 +01:00
Yugo Takano
a7b30c284c Add crossorigin attribute to manifest link 2025-10-05 15:18:40 +01:00
Dan Brown
c3412d8c1c Deps: Updated PHP package versions 2025-10-05 15:17:16 +01:00
Dan Brown
4db7135231 Updated translations with latest Crowdin changes (#5786) 2025-10-05 15:09:34 +01:00
Dan Brown
009d146185 Merge pull request #5820 from tfnh621/patch-1
Fix PWA manifest access behind authenticated proxies
2025-10-05 15:08:59 +01:00
Yugo Takano
fcef1a7948 Add crossorigin attribute to manifest link 2025-10-02 21:39:22 +09:00
Dan Brown
08dfff05f4 Sponsors: Updated diagrams.net sponsor level 2025-09-11 18:58:26 +01:00
Dan Brown
fc10520e10 Merge pull request #5793 from BookStackApp/role_permission_refactor
Permissions: Use of enum references and RolePermission cleanup
2025-09-10 12:16:40 +01:00
Dan Brown
a70c733f27 Permissions: Cleanup after review of enum implementation PR 2025-09-10 11:36:54 +01:00
Dan Brown
573d692a59 Permissions: Fixed check method to allow enum usage 2025-09-10 10:44:54 +01:00
Dan Brown
419dbadcfd Permissions: Updated use of helpers to use enums
Also added middlware method to Permission enum to allow easier usage
with controller middleware.
2025-09-09 09:48:19 +01:00
Dan Brown
33a0237f87 Permissions: Updated usage of controller methods to use enum 2025-09-08 18:14:38 +01:00
Dan Brown
5fc11d46d5 Permissions: Added enum usage to controller helpers
Also fixed various missing types or spelling/formatting points.
Added down action for role_permission table changes in migration.
2025-09-08 16:15:42 +01:00
Dan Brown
c8716df284 Permissions: Removed unused role-perm columns, added permission enum
Updated main permission check methods to support our new enum.
2025-09-08 15:59:25 +01:00
Dan Brown
1ac74099ca Merge pull request #5790 from BookStackApp/timezones
Timezones: Seperate display timezone and consistency update
2025-09-04 16:36:04 +01:00
Dan Brown
36cb243d5e Timezones: Updated date displays to use consistent formats 2025-09-04 16:11:35 +01:00
Dan Brown
579c1bf424 Timezones: Seperated out store & display timezones to two options 2025-09-04 15:06:58 +01:00
Dan Brown
242b7dfb1b Merge pull request #5785 from BookStackApp/phpstan_level2
PHPstan level 3
2025-09-03 15:53:11 +01:00
Dan Brown
7d1c316202 Maintenance: Updated larastan target level, fixed issues from tests 2025-09-03 15:42:50 +01:00
Dan Brown
318b486e0b Maintenance: Finished changes to meet phpstan level 3 2025-09-03 15:18:49 +01:00
Dan Brown
e05ec7da36 Maintenance: Addressed a range of phpstan level 3 issues 2025-09-03 10:47:45 +01:00
Dan Brown
cee23de6c5 Maintenance: Reached PHPstan level 2
Reworked some stuff around slugs to use interface in a better way.
Also standardised phpdoc to use @return instead of @returns
2025-09-02 16:02:52 +01:00
Dan Brown
1e34954554 Maintenance: Continued work towards PHPstan level 2
Updated html description code to be behind a proper interface.
Set new convention for mode traits/interfaces.
2025-09-02 11:10:47 +01:00
Dan Brown
5ea4e1e935 Maintenance: Removed unused comments text column
Has been redundant and unused for a about a year now.
Closes #4821
2025-09-02 10:20:10 +01:00
Dan Brown
a27ce6e915 Packages: Updated npm packages
Spent way too many hours debugging through issues from jsdom changes.
2025-08-30 22:18:09 +01:00
Dan Brown
64b06bcf61 Packages: Updated predis 2025-08-30 11:47:22 +01:00
Dan Brown
cdbac63b40 Framework: Updated to Laravel 12 2025-08-30 11:10:11 +01:00
Dan Brown
d6296ac7a5 Merge pull request #5749 from BookStackApp/admin_command_updates
Create Admin Command: New Flags
2025-08-30 10:47:14 +01:00
Dan Brown
481f356068 Updated translator & dependency attribution before release v25.07.2 2025-08-28 17:39:10 +01:00
Dan Brown
955837c9aa Packages: Upgraded php deps to latest versions 2025-08-28 15:02:26 +01:00
Dan Brown
c6e35c2e7c Merge pull request #5775 from BookStackApp/lexical_aug25
Lexical: August 2025 fixes
2025-08-28 15:00:16 +01:00
Dan Brown
0436ccfebf Updated translations with latest Crowdin changes (#5759) 2025-08-28 14:59:36 +01:00
Dan Brown
f5da31037d Lexical: Fixed details tests
Updated to use new test pattern while there.
2025-08-28 11:17:18 +01:00
Dan Brown
46613f76f6 Lexical: Added backspace handling for details
Allows more reliable removal of details block on backspace at first
child position with the details block.
2025-08-27 14:09:38 +01:00
Dan Brown
519acaf324 Lexical: Added better selection display for collapisble blocks 2025-08-27 12:51:36 +01:00
Dan Brown
849bc4d6c3 Lexical: Improved nested details interaction
- Set to open by default on insert.
- Updated selection handling not to always fully cascade to lowest
  editable child on selection, so parents can be reliably selected.
- Updated mouse handling to treat details panes like the root element,
  inserting within-details where relevant.
2025-08-26 14:45:15 +01:00
Dan Brown
ee994fa2b7 Testing: Addressed deprecation in test helper
Also updated version in phpunit config
2025-08-25 15:01:13 +01:00
Dan Brown
13a79b3f96 Shelves: Addressed book edits removing non-visible books
Tracks the non-visible existing books on change, to retain as part of
the assigned books sync.
Added test to cover.

For #5728
2025-08-25 14:17:55 +01:00
Dan Brown
7c79b10fb6 Imports: Fixed drawing IDs not being updated in content
Would leave imported content with inaccessible images in many cases (or
wrong references) although the drawing was still being uploaded &
related to the page.
Added test to cover.

For #5761
2025-08-24 14:02:21 +01:00
Dan Brown
5c481b4282 Testing: Added more deprecation output 2025-08-15 12:42:44 +01:00
Dan Brown
9443682ae4 Maintenance: Addressed a range of deprecations
Updated deps to address deprecations fixed in newer Laravel framework
version.
2025-08-15 12:20:35 +01:00
Dan Brown
0311e3d2d7 Readme: Updated sponsor link
Was leading to a 404.
2025-08-14 16:00:46 +01:00
Dan Brown
a50a256939 ZIP Exports: Fixed reference handling for images
Recent changes could mean missed references for images in non-page
locations. This fixes that, and tries to ensure images are used if we
already have a page-based image as part of the ZIP, otherwise ensure we
have a page as part of the export to attach the image to.
2025-08-11 14:19:48 +01:00
Dan Brown
4830248a1e Release: Updated licenses and translator attribution 2025-08-11 13:41:31 +01:00
Dan Brown
1256b30ad4 Updated translations with latest Crowdin changes (#5740) 2025-08-11 13:38:47 +01:00
Dan Brown
777cca76da Deps: Bumped PHP composer deps again 2025-08-11 13:36:06 +01:00
Dan Brown
a2d13124af Testing: Added mail port to testing env options
Prevents conflict with potential user-set option.
For #5755
2025-08-11 13:33:57 +01:00
Dan Brown
bd966ef99e phpstan: Address a range of level 2 issues 2025-08-09 11:09:50 +01:00
Dan Brown
a6b5733ec2 Deps: Updated PHP packages via composer 2025-08-09 10:12:24 +01:00
Dan Brown
e899066e96 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2025-08-08 17:44:40 +01:00
Dan Brown
f4f2435856 Imports: Fixed errors causing user logout on import run
Fixes #5754
2025-08-08 17:43:58 +01:00
Dan Brown
fca4a0563e Merge pull request #5753 from BookStackApp/a11y_menu_updates
A11y: Improved menu tagging
2025-08-08 17:00:07 +01:00
Dan Brown
0bc9ddd780 A11y: Updated other dropdown menus with correct tagging
Made some form improvements at the same time.
2025-08-07 16:37:18 +01:00
Dan Brown
c66f3b2a37 A11y: Improved tagging of profile menu
- Swapped toggle out to actual button.
- Ensured menu items have proper menu item role.
- Added extra roles/labels where is makes sense.
2025-08-07 14:32:20 +01:00
Dan Brown
f36e6fb929 Commands: Updated create admin skip return
Return status for skipped --initial creation will now return 2, so that
it can be identified seperate from a creation and from an error.
2025-08-07 13:16:49 +01:00
Dan Brown
7bc0d54af1 Readme: Swapped codeclimate reference for custom phpmetrics 2025-08-05 22:00:55 +01:00
Dan Brown
2eefbd21c1 Commands: Added testing for initial admin changes
- Also changed first-admin to initial.
- Updated initial handling to not require email/name to be passed, using
  defaults instead.
- Adds missing existing email use check.
2025-08-05 16:43:06 +01:00
Dan Brown
a961552c23 Commands: Updated create admin comment to accept extra flags
Added flags to target changes to the first default admin user, and to
generate a password.
This is related to #4575.
2025-08-05 13:39:30 +01:00
367 changed files with 5345 additions and 3190 deletions

View File

@@ -36,10 +36,14 @@ APP_LANG=en
# APP_LANG will be used if such a header is not provided. # APP_LANG will be used if such a header is not provided.
APP_AUTO_LANG_PUBLIC=true APP_AUTO_LANG_PUBLIC=true
# Application timezone # Application timezones
# Used where dates are displayed such as on exported content. # 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 # Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
APP_TIMEZONE=UTC APP_TIMEZONE=UTC
APP_DISPLAY_TIMEZONE=UTC
# Application theme # Application theme
# Used to specific a themes/<APP_THEME> folder where BookStack UI # Used to specific a themes/<APP_THEME> folder where BookStack UI

View File

@@ -438,7 +438,7 @@ javadataherian :: Persian
Ludo-code :: French Ludo-code :: French
hollsten :: Swedish hollsten :: Swedish
Ngoc Lan Phung (lanpncz) :: Vietnamese Ngoc Lan Phung (lanpncz) :: Vietnamese
Worive :: Catalan Worive :: Catalan; French
Илья Скаба (skabailya) :: Russian Илья Скаба (skabailya) :: Russian
Irjan Olsen (Irch) :: Norwegian Bokmal Irjan Olsen (Irch) :: Norwegian Bokmal
Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic) Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic)
@@ -500,3 +500,12 @@ LiZerui (iamzrli) :: Chinese Traditional
Ticker (ticker.com) :: Hebrew Ticker (ticker.com) :: Hebrew
CrazyComputer :: Chinese Simplified CrazyComputer :: Chinese Simplified
Firr (FirrV) :: Russian Firr (FirrV) :: Russian
João Faro (FaroJoaoFaro) :: Portuguese
Danilo dos Santos Barbosa (bozochegou) :: Portuguese, Brazilian
Chris (furesoft) :: German
Silvia Isern (eiendragon) :: Catalan
Dennis Kron Pedersen (ahjdp) :: Danish
iamwhoiamwhoami :: Swedish
Grogui :: French
MrCharlesIII :: Arabic
David Olsen (dawin) :: Danish

6
.gitignore vendored
View File

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

View File

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

View File

@@ -3,23 +3,18 @@
namespace BookStack\Access\Guards; 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 * 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 * into the default laravel 'Guard' auth flow. Instead, most of the logic is done via the relevant
* via the Saml2 controller & Saml2Service. This class provides a safer, thin * controller and services. This class provides a safer, thin version of SessionGuard.
* version of SessionGuard.
*/ */
class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
{ {
/** /**
* Validate a user's credentials. * Validate a user's credentials.
*
* @param array $credentials
*
* @return bool
*/ */
public function validate(array $credentials = []) public function validate(array $credentials = []): bool
{ {
return false; return false;
} }
@@ -27,12 +22,9 @@ class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
/** /**
* Attempt to authenticate a user using the given credentials. * Attempt to authenticate a user using the given credentials.
* *
* @param array $credentials
* @param bool $remember * @param bool $remember
*
* @return bool
*/ */
public function attempt(array $credentials = [], $remember = false) public function attempt(array $credentials = [], $remember = false): bool
{ {
return false; return false;
} }

View File

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

View File

@@ -35,13 +35,9 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
/** /**
* Validate a user's credentials. * Validate a user's credentials.
* *
* @param array $credentials
*
* @throws LdapException * @throws LdapException
*
* @return bool
*/ */
public function validate(array $credentials = []) public function validate(array $credentials = []): bool
{ {
$userDetails = $this->ldapService->getUserDetails($credentials['username']); $userDetails = $this->ldapService->getUserDetails($credentials['username']);
@@ -57,16 +53,13 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
/** /**
* Attempt to authenticate a user using the given credentials. * Attempt to authenticate a user using the given credentials.
* *
* @param array $credentials
* @param bool $remember * @param bool $remember
* *
* @throws LdapException*@throws \BookStack\Exceptions\JsonDebugException * @throws LdapException
* @throws LoginAttemptException * @throws LoginAttemptException
* @throws JsonDebugException * @throws JsonDebugException
*
* @return bool
*/ */
public function attempt(array $credentials = [], $remember = false) public function attempt(array $credentials = [], $remember = false): bool
{ {
$username = $credentials['username']; $username = $credentials['username'];
$userDetails = $this->ldapService->getUserDetails($username); $userDetails = $this->ldapService->getUserDetails($username);

View File

@@ -9,6 +9,7 @@ use BookStack\Exceptions\LoginAttemptInvalidUserException;
use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Facades\Theme; use BookStack\Facades\Theme;
use BookStack\Permissions\Permission;
use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Exception; use Exception;
@@ -50,7 +51,7 @@ class LoginService
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user); Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user);
// Authenticate on all session guards if a likely admin // 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']; $guards = ['standard', 'ldap', 'saml2', 'oidc'];
foreach ($guards as $guard) { foreach ($guards as $guard) {
auth($guard)->login($user); auth($guard)->login($user);
@@ -95,7 +96,7 @@ class LoginService
{ {
$value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY); $value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
if (!$value) { if (!$value) {
return ['user_id' => null, 'method' => null]; return ['user_id' => null, 'method' => null, 'remember' => false];
} }
[$id, $method, $remember, $time] = explode(':', $value); [$id, $method, $remember, $time] = explode(':', $value);
@@ -103,18 +104,18 @@ class LoginService
if ($time < $hourAgo) { if ($time < $hourAgo) {
$this->clearLastLoginAttempted(); $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)]; 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 * 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( session()->put(
self::LAST_LOGIN_ATTEMPTED_SESSION_KEY, self::LAST_LOGIN_ATTEMPTED_SESSION_KEY,

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ use BookStack\Entities\Tools\MixedEntityListLoader;
use BookStack\Permissions\PermissionApplicator; use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
class ActivityQueries class ActivityQueries
@@ -67,6 +68,7 @@ class ActivityQueries
$activity = $query->orderBy('created_at', 'desc') $activity = $query->orderBy('created_at', 'desc')
->with(['loggable' => function (Relation $query) { ->with(['loggable' => function (Relation $query) {
/** @var MorphTo<Entity, Activity> $query */
$query->withTrashed(); $query->withTrashed();
}, 'user.avatar']) }, 'user.avatar'])
->skip($count * ($page - 1)) ->skip($count * ($page - 1))

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ use BookStack\Activity\Tools\CommentTree;
use BookStack\Activity\Tools\CommentTreeNode; use BookStack\Activity\Tools\CommentTreeNode;
use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Queries\PageQueries;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -42,7 +43,7 @@ class CommentController extends Controller
} }
// Create a new comment. // Create a new comment.
$this->checkPermission('comment-create-all'); $this->checkPermission(Permission::CommentCreateAll);
$contentRef = $input['content_ref'] ?? ''; $contentRef = $input['content_ref'] ?? '';
$comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef); $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef);
@@ -64,8 +65,8 @@ class CommentController extends Controller
]); ]);
$comment = $this->commentRepo->getById($commentId); $comment = $this->commentRepo->getById($commentId);
$this->checkOwnablePermission('page-view', $comment->entity); $this->checkOwnablePermission(Permission::PageView, $comment->entity);
$this->checkOwnablePermission('comment-update', $comment); $this->checkOwnablePermission(Permission::CommentUpdate, $comment);
$comment = $this->commentRepo->update($comment, $input['html']); $comment = $this->commentRepo->update($comment, $input['html']);
@@ -81,8 +82,8 @@ class CommentController extends Controller
public function archive(int $id) public function archive(int $id)
{ {
$comment = $this->commentRepo->getById($id); $comment = $this->commentRepo->getById($id);
$this->checkOwnablePermission('page-view', $comment->entity); $this->checkOwnablePermission(Permission::PageView, $comment->entity);
if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) { if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) {
$this->showPermissionError(); $this->showPermissionError();
} }
@@ -101,8 +102,8 @@ class CommentController extends Controller
public function unarchive(int $id) public function unarchive(int $id)
{ {
$comment = $this->commentRepo->getById($id); $comment = $this->commentRepo->getById($id);
$this->checkOwnablePermission('page-view', $comment->entity); $this->checkOwnablePermission(Permission::PageView, $comment->entity);
if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) { if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) {
$this->showPermissionError(); $this->showPermissionError();
} }
@@ -121,7 +122,7 @@ class CommentController extends Controller
public function destroy(int $id) public function destroy(int $id)
{ {
$comment = $this->commentRepo->getById($id); $comment = $this->commentRepo->getById($id);
$this->checkOwnablePermission('comment-delete', $comment); $this->checkOwnablePermission(Permission::CommentDelete, $comment);
$this->commentRepo->delete($comment); $this->commentRepo->delete($comment);

View File

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

View File

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

View File

@@ -4,6 +4,8 @@ namespace BookStack\Activity\Models;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\Users\Models\HasCreatorAndUpdater; use BookStack\Users\Models\HasCreatorAndUpdater;
use BookStack\Users\Models\OwnableInterface;
use BookStack\Users\Models\User;
use BookStack\Util\HtmlContentFilter; use BookStack\Util\HtmlContentFilter;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -17,12 +19,10 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
* @property int $local_id * @property int $local_id
* @property string $entity_type * @property string $entity_type
* @property int $entity_id * @property int $entity_id
* @property int $created_by
* @property int $updated_by
* @property string $content_ref * @property string $content_ref
* @property bool $archived * @property bool $archived
*/ */
class Comment extends Model implements Loggable class Comment extends Model implements Loggable, OwnableInterface
{ {
use HasFactory; use HasFactory;
use HasCreatorAndUpdater; use HasCreatorAndUpdater;
@@ -39,6 +39,7 @@ class Comment extends Model implements Loggable
/** /**
* Get the parent comment this is in reply to (if existing). * Get the parent comment this is in reply to (if existing).
* @return BelongsTo<Comment, $this>
*/ */
public function parent(): BelongsTo public function parent(): BelongsTo
{ {

View File

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

View File

@@ -20,7 +20,8 @@ class PageUpdateNotificationHandler extends BaseNotificationHandler
throw new \InvalidArgumentException("Detail for page update notifications must be a page"); throw new \InvalidArgumentException("Detail for page update notifications must be a page");
} }
// Get last update from activity // Get the last update from activity
/** @var ?Activity $lastUpdate */
$lastUpdate = $detail->activity() $lastUpdate = $detail->activity()
->where('type', '=', ActivityType::PAGE_UPDATE) ->where('type', '=', ActivityType::PAGE_UPDATE)
->where('id', '!=', $activity->id) ->where('id', '!=', $activity->id)

View File

@@ -4,6 +4,7 @@ namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Comment; use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Permissions\Permission;
class CommentTree class CommentTree
{ {
@@ -70,7 +71,7 @@ class CommentTree
public function canUpdateAny(): bool public function canUpdateAny(): bool
{ {
foreach ($this->comments as $comment) { foreach ($this->comments as $comment) {
if (userCan('comment-update', $comment)) { if (userCan(Permission::CommentUpdate, $comment)) {
return true; return true;
} }
} }

View File

@@ -6,6 +6,7 @@ use BookStack\Activity\Models\Tag;
use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Permissions\Permission;
class TagClassGenerator class TagClassGenerator
{ {
@@ -26,14 +27,14 @@ class TagClassGenerator
array_push($classes, ...$this->generateClassesForTag($tag)); 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; $bookTags = $this->entity->book->tags;
foreach ($bookTags as $bookTag) { foreach ($bookTags as $bookTag) {
array_push($classes, ...$this->generateClassesForTag($bookTag, 'book-')); 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; $chapterTags = $this->entity->chapter->tags;
foreach ($chapterTags as $chapterTag) { foreach ($chapterTags as $chapterTag) {
array_push($classes, ...$this->generateClassesForTag($chapterTag, 'chapter-')); 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\BookChild;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Permissions\Permission;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -22,7 +23,7 @@ class UserEntityWatchOptions
public function canWatch(): bool 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 public function getWatchLevel(): string

View File

@@ -50,7 +50,7 @@ class WebhookFormatter
} }
if ($this->detail instanceof Model) { if ($this->detail instanceof Model) {
$data['related_item'] = $this->formatModel(); $data['related_item'] = $this->formatModel($this->detail);
} }
return $data; return $data;
@@ -83,10 +83,8 @@ class WebhookFormatter
); );
} }
protected function formatModel(): array protected function formatModel(Model $model): array
{ {
/** @var Model $model */
$model = $this->detail;
$model->unsetRelations(); $model->unsetRelations();
foreach ($this->modelFormatters as $formatter) { foreach ($this->modelFormatters as $formatter) {

View File

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

View File

@@ -4,6 +4,7 @@ namespace BookStack\Api;
use BookStack\Access\LoginService; use BookStack\Access\LoginService;
use BookStack\Exceptions\ApiAuthException; use BookStack\Exceptions\ApiAuthException;
use BookStack\Permissions\Permission;
use Illuminate\Auth\GuardHelpers; use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard; use Illuminate\Contracts\Auth\Guard;
@@ -146,7 +147,7 @@ class ApiTokenGuard implements Guard
throw new ApiAuthException(trans('errors.api_user_token_expired'), 403); 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); 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\Activity\ActivityType;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -16,8 +17,8 @@ class UserApiTokenController extends Controller
*/ */
public function create(Request $request, int $userId) public function create(Request $request, int $userId)
{ {
$this->checkPermission('access-api'); $this->checkPermission(Permission::AccessApi);
$this->checkPermissionOrCurrentUser('users-manage', $userId); $this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId);
$this->updateContext($request); $this->updateContext($request);
$user = User::query()->findOrFail($userId); $user = User::query()->findOrFail($userId);
@@ -35,8 +36,8 @@ class UserApiTokenController extends Controller
*/ */
public function store(Request $request, int $userId) public function store(Request $request, int $userId)
{ {
$this->checkPermission('access-api'); $this->checkPermission(Permission::AccessApi);
$this->checkPermissionOrCurrentUser('users-manage', $userId); $this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId);
$this->validate($request, [ $this->validate($request, [
'name' => ['required', 'max:250'], 'name' => ['required', 'max:250'],
@@ -143,8 +144,8 @@ class UserApiTokenController extends Controller
*/ */
protected function checkPermissionAndFetchUserToken(int $userId, int $tokenId): array protected function checkPermissionAndFetchUserToken(int $userId, int $tokenId): array
{ {
$this->checkPermissionOr('users-manage', function () use ($userId) { $this->checkPermissionOr(Permission::UsersManage, function () use ($userId) {
return $userId === user()->id && userCan('access-api'); return $userId === user()->id && userCan(Permission::AccessApi);
}); });
$user = User::query()->findOrFail($userId); $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. * 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 * @return mixed
*/ */

View File

@@ -59,8 +59,8 @@ class AuthServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
Auth::provider('external-users', function ($app, array $config) { Auth::provider('external-users', function () {
return new ExternalBaseUserProvider($config['model']); return new ExternalBaseUserProvider();
}); });
// Bind and provide the default system user as a singleton to the app instance when needed. // 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. * The event listener mappings for the application.
* *
* @var array<class-string, array<int, class-string>> * @var array<class-string, array<int, string>>
*/ */
protected $listen = [ protected $listen = [
SocialiteWasCalled::class => [ SocialiteWasCalled::class => [

View File

@@ -3,6 +3,7 @@
namespace BookStack\App\Providers; namespace BookStack\App\Providers;
use BookStack\Entities\BreadcrumbsViewComposer; use BookStack\Entities\BreadcrumbsViewComposer;
use BookStack\Util\DateFormatter;
use Illuminate\Pagination\Paginator; use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
@@ -10,6 +11,15 @@ use Illuminate\Support\ServiceProvider;
class ViewTweaksServiceProvider extends 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. * Bootstrap services.
*/ */
@@ -21,6 +31,9 @@ class ViewTweaksServiceProvider extends ServiceProvider
// View Composers // View Composers
View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class); View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class);
// View Globals
View::share('dates', $this->app->make(DateFormatter::class));
// Custom blade view directives // Custom blade view directives
Blade::directive('icon', function ($expression) { Blade::directive('icon', function ($expression) {
return "<?php echo (new \BookStack\Util\SvgIcon($expression))->toHtml(); ?>"; 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. * Assigned to models that can have slugs.
* Must have the below properties. * Must have the below properties.
*
* @property int $id
* @property string $name
*/ */
interface Sluggable interface SluggableInterface
{ {
/** /**
* Regenerate the slug for this model. * Regenerate the slug for this model.

View File

@@ -3,6 +3,7 @@
use BookStack\App\AppVersion; use BookStack\App\AppVersion;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\Facades\Theme; use BookStack\Facades\Theme;
use BookStack\Permissions\Permission;
use BookStack\Permissions\PermissionApplicator; use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\SettingService; use BookStack\Settings\SettingService;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
@@ -39,7 +40,7 @@ function user(): User
* Check if the current user has a permission. If an ownable element * Check if the current user has a permission. If an ownable element
* is passed in the jointPermissions are checked against that particular item. * 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)) { if (is_null($ownable)) {
return user()->can($permission); 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. * 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. * 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); $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. // 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. // This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
// Host-specific functionality (usually controlled via other options) like auth // Host-specific functionality (usually controlled via other options) like auth
// or user avatars for example, won't use this list. // or user avatars, for example, won't use this list.
// Space seperated if multiple. Can use '*' as a wildcard. // Space separated if multiple. Can use '*' as a wildcard.
// Values will be compared prefix-matched, case-insensitive, against called SSR urls. // Values will be compared prefix-matched, case-insensitive, against called SSR urls.
// Defaults to allow all hosts. // Defaults to allow all hosts.
'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'), 'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'),
@@ -80,8 +80,10 @@ return [
// Integer value between 0 (IP hidden) to 4 (Full IP usage) // Integer value between 0 (IP hidden) to 4 (Full IP usage)
'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4), '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'), '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 // Default locale to use
// A default variant is also stored since Laravel can overwrite // 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', 'collation' => 'utf8mb4_unicode_ci',
// Prefixes are only semi-supported and may be unstable // Prefixes are only semi-supported and may be unstable
// since they are not tested as part of our automated test suite. // 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' => env('DB_TABLE_PREFIX', ''),
'prefix_indexes' => true, 'prefix_indexes' => true,
'strict' => false, 'strict' => false,
@@ -103,9 +103,7 @@ return [
], ],
// Migration Repository Table // Migration Repository Table
// This table keeps track of all the migrations that have already run for // This table keeps track of all the migrations that have already run for the application.
// your application. Using this information, we can determine which of
// the migrations on disk haven't actually been run in the database.
'migrations' => 'migrations', 'migrations' => 'migrations',
// Redis configuration to use if set // Redis configuration to use if set

View File

@@ -8,7 +8,6 @@ use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\Rules\Unique;
class CreateAdminCommand extends Command class CreateAdminCommand extends Command
{ {
@@ -21,7 +20,9 @@ class CreateAdminCommand extends Command
{--email= : The email address for the new admin user} {--email= : The email address for the new admin user}
{--name= : The name of the new admin user} {--name= : The name of the new admin user}
{--password= : The password to assign to 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. * The console command description.
@@ -35,26 +36,12 @@ class CreateAdminCommand extends Command
*/ */
public function handle(UserRepo $userRepo): int public function handle(UserRepo $userRepo): int
{ {
$details = $this->snakeCaseOptions(); $initialAdminOnly = $this->option('initial');
$shouldGeneratePassword = $this->option('generate-password');
if (empty($details['email'])) { $details = $this->gatherDetails($shouldGeneratePassword, $initialAdminOnly);
$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);
}
}
$validator = Validator::make($details, [ $validator = Validator::make($details, [
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')], 'email' => ['required', 'email', 'min:5'],
'name' => ['required', 'min:2'], 'name' => ['required', 'min:2'],
'password' => ['required_without:external_auth_id', Password::default()], 'password' => ['required_without:external_auth_id', Password::default()],
'external_auth_id' => ['required_without:password'], 'external_auth_id' => ['required_without:password'],
@@ -68,16 +55,101 @@ class CreateAdminCommand extends Command
return 1; 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 = $userRepo->createWithoutActivity($validator->validated());
$user->attachRole(Role::getSystemRole('admin')); $user->attachRole($adminRole);
$user->email_confirmed = true; $user->email_confirmed = true;
$user->save(); $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; 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 protected function snakeCaseOptions(): array
{ {
$returnOpts = []; $returnOpts = [];

View File

@@ -52,7 +52,7 @@ class UpdateUrlCommand extends Command
'page_revisions' => ['html', 'text', 'markdown'], 'page_revisions' => ['html', 'text', 'markdown'],
'images' => ['url'], 'images' => ['url'],
'settings' => ['value'], 'settings' => ['value'],
'comments' => ['html', 'text'], 'comments' => ['html'],
]; ];
foreach ($columnsToUpdateByTable as $table => $columns) { foreach ($columnsToUpdateByTable as $table => $columns) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,14 @@
namespace BookStack\Entities\Controllers; namespace BookStack\Entities\Controllers;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Queries\ChapterQueries; use BookStack\Entities\Queries\ChapterQueries;
use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Exceptions\PermissionsException; use BookStack\Exceptions\PermissionsException;
use BookStack\Http\ApiController; use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -64,7 +66,7 @@ class ChapterApiController extends ApiController
$bookId = $request->get('book_id'); $bookId = $request->get('book_id');
$book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId)); $book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId));
$this->checkOwnablePermission('chapter-create', $book); $this->checkOwnablePermission(Permission::ChapterCreate, $book);
$chapter = $this->chapterRepo->create($requestData, $book); $chapter = $this->chapterRepo->create($requestData, $book);
@@ -100,10 +102,10 @@ class ChapterApiController extends ApiController
{ {
$requestData = $this->validate($request, $this->rules()['update']); $requestData = $this->validate($request, $this->rules()['update']);
$chapter = $this->queries->findVisibleByIdOrFail(intval($id)); $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'])) { if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) {
$this->checkOwnablePermission('chapter-delete', $chapter); $this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
try { try {
$this->chapterRepo->move($chapter, "book:{$requestData['book_id']}"); $this->chapterRepo->move($chapter, "book:{$requestData['book_id']}");
@@ -128,7 +130,7 @@ class ChapterApiController extends ApiController
public function delete(string $id) public function delete(string $id)
{ {
$chapter = $this->queries->findVisibleByIdOrFail(intval($id)); $chapter = $this->queries->findVisibleByIdOrFail(intval($id));
$this->checkOwnablePermission('chapter-delete', $chapter); $this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
$this->chapterRepo->destroy($chapter); $this->chapterRepo->destroy($chapter);
@@ -143,7 +145,10 @@ class ChapterApiController extends ApiController
$chapter->load(['tags']); $chapter->load(['tags']);
$chapter->makeVisible('description_html'); $chapter->makeVisible('description_html');
$chapter->setAttribute('description_html', $chapter->descriptionHtml()); $chapter->setAttribute('description_html', $chapter->descriptionHtml());
$chapter->setAttribute('book_slug', $chapter->book()->first()->slug);
/** @var Book $book */
$book = $chapter->book()->first();
$chapter->setAttribute('book_slug', $book->slug);
return $chapter; return $chapter;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,10 +26,10 @@ use Illuminate\Support\Collection;
* @property ?Page $defaultTemplate * @property ?Page $defaultTemplate
* @property ?SortRule $sortRule * @property ?SortRule $sortRule
*/ */
class Book extends Entity implements HasCoverImage class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterface
{ {
use HasFactory; use HasFactory;
use HasHtmlDescription; use HtmlDescriptionTrait;
public float $searchFactor = 1.2; public float $searchFactor = 1.2;
@@ -95,6 +95,7 @@ class Book extends Entity implements HasCoverImage
/** /**
* Get all pages within this book. * Get all pages within this book.
* @return HasMany<Page, $this>
*/ */
public function pages(): HasMany public function pages(): HasMany
{ {
@@ -111,6 +112,7 @@ class Book extends Entity implements HasCoverImage
/** /**
* Get all chapters within this book. * Get all chapters within this book.
* @return HasMany<Chapter, $this>
*/ */
public function chapters(): HasMany public function chapters(): HasMany
{ {

View File

@@ -8,10 +8,10 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Bookshelf extends Entity implements HasCoverImage class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionInterface
{ {
use HasFactory; use HasFactory;
use HasHtmlDescription; use HtmlDescriptionTrait;
protected $table = 'bookshelves'; protected $table = 'bookshelves';
@@ -70,6 +70,7 @@ class Bookshelf extends Entity implements HasCoverImage
/** /**
* Get the cover image of the shelf. * Get the cover image of the shelf.
* @return BelongsTo<Image, $this>
*/ */
public function cover(): BelongsTo public function cover(): BelongsTo
{ {

View File

@@ -14,10 +14,10 @@ use Illuminate\Support\Collection;
* @property ?int $default_template_id * @property ?int $default_template_id
* @property ?Page $defaultTemplate * @property ?Page $defaultTemplate
*/ */
class Chapter extends BookChild class Chapter extends BookChild implements HtmlDescriptionInterface
{ {
use HasFactory; use HasFactory;
use HasHtmlDescription; use HtmlDescriptionTrait;
public float $searchFactor = 1.2; public float $searchFactor = 1.2;
@@ -27,7 +27,7 @@ class Chapter extends BookChild
/** /**
* Get the pages that this chapter contains. * Get the pages that this chapter contains.
* *
* @return HasMany<Page> * @return HasMany<Page, $this>
*/ */
public function pages(string $dir = 'ASC'): HasMany public function pages(string $dir = 'ASC'): HasMany
{ {
@@ -60,7 +60,7 @@ class Chapter extends BookChild
/** /**
* Get the visible pages in this chapter. * Get the visible pages in this chapter.
* @returns Collection<Page> * @return Collection<Page>
*/ */
public function getVisiblePages(): Collection public function getVisiblePages(): Collection
{ {

View File

@@ -4,7 +4,7 @@ namespace BookStack\Entities\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
interface HasCoverImage interface CoverImageInterface
{ {
/** /**
* Get the cover image for this item. * Get the cover image for this item.

View File

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

View File

@@ -13,7 +13,7 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
* @property int $deleted_by * @property int $deleted_by
* @property string $deletable_type * @property string $deletable_type
* @property int $deletable_id * @property int $deletable_id
* @property Deletable $deletable * @property DeletableInterface $deletable
*/ */
class Deletion extends Model implements Loggable class Deletion extends Model implements Loggable
{ {

View File

@@ -12,7 +12,7 @@ use BookStack\Activity\Models\View;
use BookStack\Activity\Models\Viewable; use BookStack\Activity\Models\Viewable;
use BookStack\Activity\Models\Watch; use BookStack\Activity\Models\Watch;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\App\Sluggable; use BookStack\App\SluggableInterface;
use BookStack\Entities\Tools\SlugGenerator; use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Permissions\JointPermissionBuilder; use BookStack\Permissions\JointPermissionBuilder;
use BookStack\Permissions\Models\EntityPermission; use BookStack\Permissions\Models\EntityPermission;
@@ -22,10 +22,12 @@ use BookStack\References\Reference;
use BookStack\Search\SearchIndex; use BookStack\Search\SearchIndex;
use BookStack\Search\SearchTerm; use BookStack\Search\SearchTerm;
use BookStack\Users\Models\HasCreatorAndUpdater; use BookStack\Users\Models\HasCreatorAndUpdater;
use BookStack\Users\Models\HasOwner; use BookStack\Users\Models\OwnableInterface;
use BookStack\Users\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
@@ -42,17 +44,23 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property Carbon $deleted_at * @property Carbon $deleted_at
* @property int $created_by * @property int $created_by
* @property int $updated_by * @property int $updated_by
* @property int $owned_by
* @property Collection $tags * @property Collection $tags
* *
* @method static Entity|Builder visible() * @method static Entity|Builder visible()
* @method static Builder withLastView() * @method static Builder withLastView()
* @method static Builder withViewCount() * @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 SoftDeletes;
use HasCreatorAndUpdater; use HasCreatorAndUpdater;
use HasOwner;
/** /**
* @var string - Name of property where the main text content is found * @var string - Name of property where the main text content is found
@@ -199,6 +207,20 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
return $this->morphMany(JointPermission::class, 'entity'); 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. * Get the related delete records for this entity.
*/ */
@@ -283,10 +305,14 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
public function getParent(): ?self public function getParent(): ?self
{ {
if ($this instanceof Page) { if ($this instanceof Page) {
return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book()->withTrashed()->first(); /** @var BelongsTo<Chapter|Book, Page> $builder */
$builder = $this->chapter_id ? $this->chapter() : $this->book();
return $builder->withTrashed()->first();
} }
if ($this instanceof Chapter) { if ($this instanceof Chapter) {
return $this->book()->withTrashed()->first(); /** @var BelongsTo<Book, Page> $builder */
$builder = $this->book();
return $builder->withTrashed()->first();
} }
return null; return null;
@@ -295,7 +321,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
/** /**
* Rebuild the permissions for this entity. * Rebuild the permissions for this entity.
*/ */
public function rebuildPermissions() public function rebuildPermissions(): void
{ {
app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this); app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this);
} }
@@ -303,7 +329,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
/** /**
* Index the current entity for search. * Index the current entity for search.
*/ */
public function indexForSearch() public function indexForSearch(): void
{ {
app()->make(SearchIndex::class)->indexEntity(clone $this); app()->make(SearchIndex::class)->indexEntity(clone $this);
} }
@@ -313,7 +339,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/ */
public function refreshSlug(): string 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; return $this->slug;
} }

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,9 @@ use BookStack\Entities\Models\Chapter;
use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotFoundException;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
/**
* @implements ProvidesEntityQueries<Chapter>
*/
class ChapterQueries implements ProvidesEntityQueries class ChapterQueries implements ProvidesEntityQueries
{ {
protected static array $listAttributes = [ protected static array $listAttributes = [

View File

@@ -35,6 +35,7 @@ class EntityQueries
/** /**
* Start a query of visible entities of the given type, * Start a query of visible entities of the given type,
* suitable for listing display. * suitable for listing display.
* @return Builder<Entity>
*/ */
public function visibleForList(string $entityType): Builder public function visibleForList(string $entityType): Builder
{ {
@@ -44,7 +45,6 @@ class EntityQueries
protected function getQueriesForType(string $type): ProvidesEntityQueries protected function getQueriesForType(string $type): ProvidesEntityQueries
{ {
/** @var ?ProvidesEntityQueries $queries */
$queries = match ($type) { $queries = match ($type) {
'page' => $this->pages, 'page' => $this->pages,
'chapter' => $this->chapters, 'chapter' => $this->chapters,

View File

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

View File

@@ -7,28 +7,32 @@ use Illuminate\Database\Eloquent\Builder;
/** /**
* Interface for our classes which provide common queries for our * 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. * these classes.
* Any added methods should return a builder instances to allow extension * Any added methods should return a builder instances to allow extension
* via building on the query, unless the method starts with 'find' * via building on the query, unless the method starts with 'find'
* in which case an entity object should be returned. * in which case an entity object should be returned.
* (nullable unless it's a *OrFail method). * (nullable unless it's a *OrFail method).
*
* @template TModel of Entity
*/ */
interface ProvidesEntityQueries interface ProvidesEntityQueries
{ {
/** /**
* Start a new query for this entity type. * Start a new query for this entity type.
* @return Builder<TModel>
*/ */
public function start(): Builder; 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; public function findVisibleById(int $id): ?Entity;
/** /**
* Start a query for items that are visible, with selection * Start a query for items that are visible, with selection
* configured for list display of this item. * configured for list display of this item.
* @return Builder<TModel>
*/ */
public function visibleForList(): Builder; public function visibleForList(): Builder;
} }

View File

@@ -7,8 +7,9 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage; use BookStack\Entities\Models\CoverImageInterface;
use BookStack\Entities\Models\HasHtmlDescription; use BookStack\Entities\Models\HtmlDescriptionInterface;
use BookStack\Entities\Models\HtmlDescriptionTrait;
use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Queries\PageQueries;
use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore; use BookStack\References\ReferenceStore;
@@ -88,12 +89,10 @@ class BaseRepo
/** /**
* Update the given items' cover image, or clear it. * Update the given items' cover image, or clear it.
* *
* @param Entity&HasCoverImage $entity
*
* @throws ImageUploadException * @throws ImageUploadException
* @throws \Exception * @throws \Exception
*/ */
public function updateCoverImage($entity, ?UploadedFile $coverImage, bool $removeImage = false) public function updateCoverImage(Entity&CoverImageInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false)
{ {
if ($coverImage) { if ($coverImage) {
$imageType = $entity->coverImageTypeKey(); $imageType = $entity->coverImageTypeKey();
@@ -105,7 +104,7 @@ class BaseRepo
if ($removeImage) { if ($removeImage) {
$this->imageRepo->destroyImage($entity->cover()->first()); $this->imageRepo->destroyImage($entity->cover()->first());
$entity->image_id = 0; $entity->cover()->dissociate();
$entity->save(); $entity->save();
} }
} }
@@ -150,18 +149,17 @@ class BaseRepo
protected function updateDescription(Entity $entity, array $input): void protected function updateDescription(Entity $entity, array $input): void
{ {
if (!in_array(HasHtmlDescription::class, class_uses($entity))) { if (!($entity instanceof HtmlDescriptionInterface)) {
return; return;
} }
/** @var HasHtmlDescription $entity */
if (isset($input['description_html'])) { if (isset($input['description_html'])) {
$entity->description_html = HtmlDescriptionFilter::filterFromString($input['description_html']); $entity->setDescriptionHtml(
$entity->description = html_entity_decode(strip_tags($input['description_html'])); HtmlDescriptionFilter::filterFromString($input['description_html']),
html_entity_decode(strip_tags($input['description_html']))
);
} else if (isset($input['description'])) { } else if (isset($input['description'])) {
$entity->description = $input['description']; $entity->setDescriptionHtml('', $input['description']);
$entity->description_html = '';
$entity->description_html = $entity->descriptionHtml();
} }
} }
} }

View File

@@ -56,20 +56,37 @@ class BookshelfRepo
/** /**
* Update which books are assigned to this shelf by syncing the given book ids. * Update which books are assigned to this shelf by syncing the given book ids.
* Function ensures the books are visible to the current user and existing. * Function ensures the managed books are visible to the current user and existing,
* and that the user does not alter the assignment of books that are not visible to them.
*/ */
protected function updateBooks(Bookshelf $shelf, array $bookIds) protected function updateBooks(Bookshelf $shelf, array $bookIds): void
{ {
$numericIDs = collect($bookIds)->map(function ($id) { $numericIDs = collect($bookIds)->map(function ($id) {
return intval($id); return intval($id);
}); });
$syncData = $this->bookQueries->visibleForList() $existingBookIds = $shelf->books()->pluck('id')->toArray();
$visibleExistingBookIds = $this->bookQueries->visibleForList()
->whereIn('id', $existingBookIds)
->pluck('id')
->toArray();
$nonVisibleExistingBookIds = array_values(array_diff($existingBookIds, $visibleExistingBookIds));
$newIdsToAssign = $this->bookQueries->visibleForList()
->whereIn('id', $bookIds) ->whereIn('id', $bookIds)
->pluck('id') ->pluck('id')
->mapWithKeys(function ($bookId) use ($numericIDs) { ->toArray();
return [$bookId => ['order' => $numericIDs->search($bookId)]];
}); $maxNewIndex = max($numericIDs->keys()->toArray() ?: [0]);
$syncData = [];
foreach ($newIdsToAssign as $id) {
$syncData[$id] = ['order' => $numericIDs->search($id)];
}
foreach ($nonVisibleExistingBookIds as $index => $id) {
$syncData[$id] = ['order' => $maxNewIndex + ($index + 1)];
}
$shelf->books()->sync($syncData); $shelf->books()->sync($syncData);
} }

View File

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

View File

@@ -16,6 +16,7 @@ use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\PermissionsException; use BookStack\Exceptions\PermissionsException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceStore; use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater; use BookStack\References\ReferenceUpdater;
use BookStack\Util\DatabaseTransaction; use BookStack\Util\DatabaseTransaction;
@@ -55,7 +56,7 @@ class PageRepo
} }
$defaultTemplate = $page->chapter->defaultTemplate ?? $page->book->defaultTemplate; $defaultTemplate = $page->chapter->defaultTemplate ?? $page->book->defaultTemplate;
if ($defaultTemplate && userCan('view', $defaultTemplate)) { if ($defaultTemplate && userCan(Permission::PageView, $defaultTemplate)) {
$page->forceFill([ $page->forceFill([
'html' => $defaultTemplate->html, 'html' => $defaultTemplate->html,
'markdown' => $defaultTemplate->markdown, 'markdown' => $defaultTemplate->markdown,
@@ -142,7 +143,7 @@ class PageRepo
protected function updateTemplateStatusAndContentFromInput(Page $page, array $input): void 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'); $page->template = ($input['template'] === 'true');
} }
@@ -165,7 +166,7 @@ class PageRepo
$pageContent->setNewHTML($input['html'], user()); $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; $page->editor = $newEditor->value;
} elseif (empty($page->editor)) { } elseif (empty($page->editor)) {
$page->editor = $defaultEditor->value; $page->editor = $defaultEditor->value;
@@ -271,7 +272,7 @@ class PageRepo
throw new MoveOperationException('Book or chapter to move page into not found'); 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'); throw new PermissionsException('User does not have permission to create a page within the new parent');
} }

View File

@@ -7,11 +7,12 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage; use BookStack\Entities\Models\CoverImageInterface;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Repos\PageRepo; use BookStack\Entities\Repos\PageRepo;
use BookStack\Permissions\Permission;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService; use BookStack\Uploads\ImageService;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@@ -49,7 +50,7 @@ class Cloner
$copyChapter = $this->chapterRepo->create($chapterDetails, $parent); $copyChapter = $this->chapterRepo->create($chapterDetails, $parent);
if (userCan('page-create', $copyChapter)) { if (userCan(Permission::PageCreate, $copyChapter)) {
/** @var Page $page */ /** @var Page $page */
foreach ($original->getVisiblePages() as $page) { foreach ($original->getVisiblePages() as $page) {
$this->clonePage($page, $copyChapter, $page->name); $this->clonePage($page, $copyChapter, $page->name);
@@ -61,7 +62,7 @@ class Cloner
/** /**
* Clone the given book. * Clone the given book.
* Clones all child chapters & pages. * Clones all child chapters and pages.
*/ */
public function cloneBook(Book $original, string $newName): Book public function cloneBook(Book $original, string $newName): Book
{ {
@@ -74,11 +75,11 @@ class Cloner
// Clone contents // Clone contents
$directChildren = $original->getDirectVisibleChildren(); $directChildren = $original->getDirectVisibleChildren();
foreach ($directChildren as $child) { 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); $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); $this->clonePage($child, $copyBook, $child->name);
} }
} }
@@ -86,7 +87,7 @@ class Cloner
// Clone bookshelf relationships // Clone bookshelf relationships
/** @var Bookshelf $shelf */ /** @var Bookshelf $shelf */
foreach ($original->shelves as $shelf) { foreach ($original->shelves as $shelf) {
if (userCan('bookshelf-update', $shelf)) { if (userCan(Permission::BookshelfUpdate, $shelf)) {
$shelf->appendBook($copyBook); $shelf->appendBook($copyBook);
} }
} }
@@ -105,7 +106,7 @@ class Cloner
$inputData['tags'] = $this->entityTagsToInputArray($entity); $inputData['tags'] = $this->entityTagsToInputArray($entity);
// Add a cover to the data if existing on the original entity // Add a cover to the data if existing on the original entity
if ($entity instanceof HasCoverImage) { if ($entity instanceof CoverImageInterface) {
$cover = $entity->cover()->first(); $cover = $entity->cover()->first();
if ($cover) { if ($cover) {
$inputData['image'] = $this->imageToUploadedFile($cover); $inputData['image'] = $this->imageToUploadedFile($cover);

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown; use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
use BookStack\Entities\Tools\Markdown\MarkdownToHtml; use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
use BookStack\Permissions\Permission;
class PageEditorData class PageEditorData
{ {
@@ -98,9 +99,9 @@ class PageEditorData
{ {
$editorType = PageEditorType::forPage($page) ?: PageEditorType::getSystemDefault(); $editorType = PageEditorType::forPage($page) ?: PageEditorType::getSystemDefault();
// Use requested editor if valid and if we have permission // Use the requested editor if valid and if we have permission
$requestedType = PageEditorType::fromRequestValue($this->requestedEditor); $requestedType = PageEditorType::fromRequestValue($this->requestedEditor);
if ($requestedType && userCan('editor-change')) { if ($requestedType && userCan(Permission::EditorChange)) {
$editorType = $requestedType; $editorType = $requestedType;
} }

View File

@@ -7,15 +7,14 @@ use Closure;
use DOMDocument; use DOMDocument;
use DOMElement; use DOMElement;
use DOMNode; use DOMNode;
use DOMText;
class PageIncludeParser class PageIncludeParser
{ {
protected static string $includeTagRegex = "/{{@\s?([0-9].*?)}}/"; protected static string $includeTagRegex = "/{{@\s?([0-9].*?)}}/";
/** /**
* Elements to clean up and remove if left empty after a parsing operation. * Nodes to clean up and remove if left empty after a parsing operation.
* @var DOMElement[] * @var DOMNode[]
*/ */
protected array $toCleanup = []; protected array $toCleanup = [];
@@ -159,7 +158,7 @@ class PageIncludeParser
/** /**
* Splits the given $parentNode at the location of the $domNode within it. * Splits the given $parentNode at the location of the $domNode within it.
* Attempts replicate the original $parentNode, moving some of their parent * Attempts to replicate the original $parentNode, moving some of their parent
* children in where needed, before adding the $domNode between. * children in where needed, before adding the $domNode between.
*/ */
protected function splitNodeAtChildNode(DOMElement $parentNode, DOMNode $domNode): void protected function splitNodeAtChildNode(DOMElement $parentNode, DOMNode $domNode): void
@@ -171,6 +170,10 @@ class PageIncludeParser
} }
$parentClone = $parentNode->cloneNode(); $parentClone = $parentNode->cloneNode();
if (!($parentClone instanceof DOMElement)) {
return;
}
$parentNode->parentNode->insertBefore($parentClone, $parentNode); $parentNode->parentNode->insertBefore($parentClone, $parentNode);
$parentClone->removeAttribute('id'); $parentClone->removeAttribute('id');
@@ -203,7 +206,7 @@ class PageIncludeParser
} }
/** /**
* Cleanup after a parse operation. * Clean up after a parse operation.
* Removes stranded elements we may have left during the parse. * Removes stranded elements we may have left during the parse.
*/ */
protected function cleanup(): void protected function cleanup(): void

View File

@@ -8,6 +8,7 @@ use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Permissions\Models\EntityPermission; use BookStack\Permissions\Models\EntityPermission;
use BookStack\Permissions\Permission;
use BookStack\Users\Models\Role; use BookStack\Users\Models\Role;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -93,8 +94,9 @@ class PermissionsUpdater
foreach ($permissions as $roleId => $info) { foreach ($permissions as $roleId => $info) {
$entityPermissionData = ['role_id' => $roleId]; $entityPermissionData = ['role_id' => $roleId];
foreach (EntityPermission::PERMISSIONS as $permission) { foreach (Permission::genericForEntity() as $permission) {
$entityPermissionData[$permission] = (($info[$permission] ?? false) === "true"); $permName = $permission->value;
$entityPermissionData[$permName] = (($info[$permName] ?? false) === "true");
} }
$formatted[] = $entityPermissionData; $formatted[] = $entityPermissionData;
} }
@@ -108,8 +110,9 @@ class PermissionsUpdater
foreach ($permissions as $requestPermissionData) { foreach ($permissions as $requestPermissionData) {
$entityPermissionData = ['role_id' => $requestPermissionData['role_id']]; $entityPermissionData = ['role_id' => $requestPermissionData['role_id']];
foreach (EntityPermission::PERMISSIONS as $permission) { foreach (Permission::genericForEntity() as $permission) {
$entityPermissionData[$permission] = boolval($requestPermissionData[$permission] ?? false); $permName = $permission->value;
$entityPermissionData[$permName] = boolval($requestPermissionData[$permName] ?? false);
} }
$formatted[] = $entityPermissionData; $formatted[] = $entityPermissionData;
} }
@@ -147,7 +150,7 @@ class PermissionsUpdater
/** @var Book $book */ /** @var Book $book */
foreach ($shelfBooks as $book) { foreach ($shelfBooks as $book) {
if ($checkUserPermissions && !userCan('restrictions-manage', $book)) { if ($checkUserPermissions && !userCan(Permission::RestrictionsManage, $book)) {
continue; continue;
} }
$book->permissions()->delete(); $book->permissions()->delete();

View File

@@ -3,7 +3,7 @@
namespace BookStack\Entities\Tools; namespace BookStack\Entities\Tools;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\App\Sluggable; use BookStack\App\SluggableInterface;
use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\BookChild;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -13,9 +13,9 @@ class SlugGenerator
* Generate a fresh slug for the given entity. * Generate a fresh slug for the given entity.
* The slug will be generated so that it doesn't conflict within the same parent item. * The slug will be generated so that it doesn't conflict within the same parent item.
*/ */
public function generate(Sluggable $model): string public function generate(SluggableInterface&Model $model, string $slugSource): string
{ {
$slug = $this->formatNameAsSlug($model->name); $slug = $this->formatNameAsSlug($slugSource);
while ($this->slugInUse($slug, $model)) { while ($this->slugInUse($slug, $model)) {
$slug .= '-' . Str::random(3); $slug .= '-' . Str::random(3);
} }
@@ -24,7 +24,7 @@ class SlugGenerator
} }
/** /**
* Format a name as a url slug. * Format a name as a URL slug.
*/ */
protected function formatNameAsSlug(string $name): string protected function formatNameAsSlug(string $name): string
{ {
@@ -39,10 +39,8 @@ class SlugGenerator
/** /**
* Check if a slug is already in-use for this * Check if a slug is already in-use for this
* type of model within the same parent. * type of model within the same parent.
*
* @param Sluggable&Model $model
*/ */
protected function slugInUse(string $slug, Sluggable $model): bool protected function slugInUse(string $slug, SluggableInterface&Model $model): bool
{ {
$query = $model->newQuery()->where('slug', '=', $slug); $query = $model->newQuery()->where('slug', '=', $slug);

View File

@@ -8,7 +8,7 @@ use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Deletion; use BookStack\Entities\Models\Deletion;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage; use BookStack\Entities\Models\CoverImageInterface;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\EntityQueries;
use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\NotifyException;
@@ -398,7 +398,7 @@ class TrashCan
$entity->referencesTo()->delete(); $entity->referencesTo()->delete();
$entity->referencesFrom()->delete(); $entity->referencesFrom()->delete();
if ($entity instanceof HasCoverImage && $entity->cover()->exists()) { if ($entity instanceof CoverImageInterface && $entity->cover()->exists()) {
$imageService = app()->make(ImageService::class); $imageService = app()->make(ImageService::class);
$imageService->destroy($entity->cover()->first()); $imageService->destroy($entity->cover()->first());
} }

View File

@@ -2,7 +2,6 @@
namespace BookStack\Exceptions; namespace BookStack\Exceptions;
use Exception;
use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
@@ -12,6 +11,7 @@ use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Symfony\Component\ErrorHandler\Error\FatalError; use Symfony\Component\ErrorHandler\Error\FatalError;
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Throwable; use Throwable;
@@ -20,7 +20,7 @@ class Handler extends ExceptionHandler
/** /**
* A list of the exception types that are not reported. * A list of the exception types that are not reported.
* *
* @var array<int, class-string<\Throwable>> * @var array<int, class-string<Throwable>>
*/ */
protected $dontReport = [ protected $dontReport = [
NotFoundException::class, NotFoundException::class,
@@ -50,11 +50,11 @@ class Handler extends ExceptionHandler
/** /**
* Report or log an exception. * Report or log an exception.
* *
* @param \Throwable $exception * @param Throwable $exception
*
* @throws \Throwable
* *
* @return void * @return void
*@throws Throwable
*
*/ */
public function report(Throwable $exception) public function report(Throwable $exception)
{ {
@@ -64,12 +64,9 @@ class Handler extends ExceptionHandler
/** /**
* Render an exception into an HTTP response. * Render an exception into an HTTP response.
* *
* @param \Illuminate\Http\Request $request * @param Request $request
* @param Exception $e
*
* @return \Illuminate\Http\Response
*/ */
public function render($request, Throwable $e) public function render($request, Throwable $e): SymfonyResponse
{ {
if ($e instanceof FatalError && str_contains($e->getMessage(), 'bytes exhausted (tried to allocate') && $this->onOutOfMemory) { if ($e instanceof FatalError && str_contains($e->getMessage(), 'bytes exhausted (tried to allocate') && $this->onOutOfMemory) {
$response = call_user_func($this->onOutOfMemory); $response = call_user_func($this->onOutOfMemory);
@@ -94,7 +91,7 @@ class Handler extends ExceptionHandler
* If the callable returns a response, this response will be returned * If the callable returns a response, this response will be returned
* to the request upon error. * to the request upon error.
*/ */
public function prepareForOutOfMemory(callable $onOutOfMemory) public function prepareForOutOfMemory(callable $onOutOfMemory): void
{ {
$this->onOutOfMemory = $onOutOfMemory; $this->onOutOfMemory = $onOutOfMemory;
} }
@@ -102,7 +99,7 @@ class Handler extends ExceptionHandler
/** /**
* Forget the current out of memory handler, if existing. * Forget the current out of memory handler, if existing.
*/ */
public function forgetOutOfMemoryHandler() public function forgetOutOfMemoryHandler(): void
{ {
$this->onOutOfMemory = null; $this->onOutOfMemory = null;
} }
@@ -152,12 +149,9 @@ class Handler extends ExceptionHandler
/** /**
* Convert an authentication exception into an unauthenticated response. * Convert an authentication exception into an unauthenticated response.
* *
* @param \Illuminate\Http\Request $request * @param Request $request
* @param \Illuminate\Auth\AuthenticationException $exception
*
* @return \Illuminate\Http\Response
*/ */
protected function unauthenticated($request, AuthenticationException $exception) protected function unauthenticated($request, AuthenticationException $exception): SymfonyResponse
{ {
if ($request->expectsJson()) { if ($request->expectsJson()) {
return response()->json(['error' => 'Unauthenticated.'], 401); return response()->json(['error' => 'Unauthenticated.'], 401);
@@ -169,12 +163,9 @@ class Handler extends ExceptionHandler
/** /**
* Convert a validation exception into a JSON response. * Convert a validation exception into a JSON response.
* *
* @param \Illuminate\Http\Request $request * @param Request $request
* @param \Illuminate\Validation\ValidationException $exception
*
* @return \Illuminate\Http\JsonResponse
*/ */
protected function invalidJson($request, ValidationException $exception) protected function invalidJson($request, ValidationException $exception): JsonResponse
{ {
return response()->json($exception->errors(), $exception->status); return response()->json($exception->errors(), $exception->status);
} }

View File

@@ -6,6 +6,7 @@ use BookStack\Entities\Queries\BookQueries;
use BookStack\Exports\ExportFormatter; use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Exports\ZipExports\ZipExportBuilder;
use BookStack\Http\ApiController; use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use Throwable; use Throwable;
class BookExportApiController extends ApiController class BookExportApiController extends ApiController
@@ -14,7 +15,7 @@ class BookExportApiController extends ApiController
protected ExportFormatter $exportFormatter, protected ExportFormatter $exportFormatter,
protected BookQueries $queries, protected BookQueries $queries,
) { ) {
$this->middleware('can:content-export'); $this->middleware(Permission::ContentExport->middleware());
} }
/** /**

View File

@@ -7,6 +7,7 @@ use BookStack\Exceptions\NotFoundException;
use BookStack\Exports\ExportFormatter; use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Exports\ZipExports\ZipExportBuilder;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use Throwable; use Throwable;
class BookExportController extends Controller class BookExportController extends Controller
@@ -15,7 +16,7 @@ class BookExportController extends Controller
protected BookQueries $queries, protected BookQueries $queries,
protected ExportFormatter $exportFormatter, protected ExportFormatter $exportFormatter,
) { ) {
$this->middleware('can:content-export'); $this->middleware(Permission::ContentExport->middleware());
$this->middleware('throttle:exports'); $this->middleware('throttle:exports');
} }

View File

@@ -6,6 +6,7 @@ use BookStack\Entities\Queries\ChapterQueries;
use BookStack\Exports\ExportFormatter; use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Exports\ZipExports\ZipExportBuilder;
use BookStack\Http\ApiController; use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use Throwable; use Throwable;
class ChapterExportApiController extends ApiController class ChapterExportApiController extends ApiController
@@ -14,7 +15,7 @@ class ChapterExportApiController extends ApiController
protected ExportFormatter $exportFormatter, protected ExportFormatter $exportFormatter,
protected ChapterQueries $queries, protected ChapterQueries $queries,
) { ) {
$this->middleware('can:content-export'); $this->middleware(Permission::ContentExport->middleware());
} }
/** /**

View File

@@ -7,6 +7,7 @@ use BookStack\Exceptions\NotFoundException;
use BookStack\Exports\ExportFormatter; use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Exports\ZipExports\ZipExportBuilder;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use Throwable; use Throwable;
class ChapterExportController extends Controller class ChapterExportController extends Controller
@@ -15,7 +16,7 @@ class ChapterExportController extends Controller
protected ChapterQueries $queries, protected ChapterQueries $queries,
protected ExportFormatter $exportFormatter, protected ExportFormatter $exportFormatter,
) { ) {
$this->middleware('can:content-export'); $this->middleware(Permission::ContentExport->middleware());
$this->middleware('throttle:exports'); $this->middleware('throttle:exports');
} }

View File

@@ -8,6 +8,7 @@ use BookStack\Exceptions\ZipImportException;
use BookStack\Exceptions\ZipValidationException; use BookStack\Exceptions\ZipValidationException;
use BookStack\Exports\ImportRepo; use BookStack\Exports\ImportRepo;
use BookStack\Http\ApiController; use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use BookStack\Uploads\AttachmentService; use BookStack\Uploads\AttachmentService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -18,7 +19,7 @@ class ImportApiController extends ApiController
public function __construct( public function __construct(
protected ImportRepo $imports, protected ImportRepo $imports,
) { ) {
$this->middleware('can:content-import'); $this->middleware(Permission::ContentImport->middleware());
} }
/** /**

View File

@@ -8,6 +8,7 @@ use BookStack\Exceptions\ZipImportException;
use BookStack\Exceptions\ZipValidationException; use BookStack\Exceptions\ZipValidationException;
use BookStack\Exports\ImportRepo; use BookStack\Exports\ImportRepo;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Uploads\AttachmentService; use BookStack\Uploads\AttachmentService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -16,7 +17,7 @@ class ImportController extends Controller
public function __construct( public function __construct(
protected ImportRepo $imports, protected ImportRepo $imports,
) { ) {
$this->middleware('can:content-import'); $this->middleware(Permission::ContentImport->middleware());
} }
/** /**
@@ -89,7 +90,7 @@ class ImportController extends Controller
try { try {
$entity = $this->imports->runImport($import, $parent); $entity = $this->imports->runImport($import, $parent);
} catch (ZipImportException $exception) { } catch (ZipImportException $exception) {
session()->flush(); session()->forget(['success', 'warning']);
$this->showErrorNotification(trans('errors.import_zip_failed_notification')); $this->showErrorNotification(trans('errors.import_zip_failed_notification'));
return redirect($import->getUrl())->with('import_errors', $exception->errors); return redirect($import->getUrl())->with('import_errors', $exception->errors);
} }

View File

@@ -6,6 +6,7 @@ use BookStack\Entities\Queries\PageQueries;
use BookStack\Exports\ExportFormatter; use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Exports\ZipExports\ZipExportBuilder;
use BookStack\Http\ApiController; use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use Throwable; use Throwable;
class PageExportApiController extends ApiController class PageExportApiController extends ApiController
@@ -14,7 +15,7 @@ class PageExportApiController extends ApiController
protected ExportFormatter $exportFormatter, protected ExportFormatter $exportFormatter,
protected PageQueries $queries, protected PageQueries $queries,
) { ) {
$this->middleware('can:content-export'); $this->middleware(Permission::ContentExport->middleware());
} }
/** /**

View File

@@ -8,6 +8,7 @@ use BookStack\Exceptions\NotFoundException;
use BookStack\Exports\ExportFormatter; use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExports\ZipExportBuilder; use BookStack\Exports\ZipExports\ZipExportBuilder;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use Throwable; use Throwable;
class PageExportController extends Controller class PageExportController extends Controller
@@ -16,7 +17,7 @@ class PageExportController extends Controller
protected PageQueries $queries, protected PageQueries $queries,
protected ExportFormatter $exportFormatter, protected ExportFormatter $exportFormatter,
) { ) {
$this->middleware('can:content-export'); $this->middleware(Permission::ContentExport->middleware());
$this->middleware('throttle:exports'); $this->middleware('throttle:exports');
} }

View File

@@ -16,6 +16,7 @@ use BookStack\Exports\ZipExports\ZipExportReader;
use BookStack\Exports\ZipExports\ZipExportValidator; use BookStack\Exports\ZipExports\ZipExportValidator;
use BookStack\Exports\ZipExports\ZipImportRunner; use BookStack\Exports\ZipExports\ZipImportRunner;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Permissions\Permission;
use BookStack\Uploads\FileStorage; use BookStack\Uploads\FileStorage;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@@ -39,11 +40,14 @@ class ImportRepo
return $this->queryVisible()->get(); return $this->queryVisible()->get();
} }
/**
* @return Builder<Import>
*/
public function queryVisible(): Builder public function queryVisible(): Builder
{ {
$query = Import::query(); $query = Import::query();
if (!userCan('settings-manage')) { if (!userCan(Permission::SettingsManage)) {
$query->where('created_by', user()->id); $query->where('created_by', user()->id);
} }
@@ -54,7 +58,7 @@ class ImportRepo
{ {
$query = Import::query(); $query = Import::query();
if (!userCan('settings-manage')) { if (!userCan(Permission::SettingsManage)) {
$query->where('created_by', user()->id); $query->where('created_by', user()->id);
} }

View File

@@ -6,7 +6,7 @@ use BookStack\Exports\ZipExports\ZipExportFiles;
use BookStack\Exports\ZipExports\ZipValidationHelper; use BookStack\Exports\ZipExports\ZipValidationHelper;
use BookStack\Uploads\Attachment; use BookStack\Uploads\Attachment;
class ZipExportAttachment extends ZipExportModel final class ZipExportAttachment extends ZipExportModel
{ {
public ?int $id = null; public ?int $id = null;
public string $name; public string $name;
@@ -52,9 +52,9 @@ class ZipExportAttachment extends ZipExportModel
return $context->validateData($data, $rules); return $context->validateData($data, $rules);
} }
public static function fromArray(array $data): self public static function fromArray(array $data): static
{ {
$model = new self(); $model = new static();
$model->id = $data['id'] ?? null; $model->id = $data['id'] ?? null;
$model->name = $data['name']; $model->name = $data['name'];

View File

@@ -8,7 +8,7 @@ use BookStack\Entities\Models\Page;
use BookStack\Exports\ZipExports\ZipExportFiles; use BookStack\Exports\ZipExports\ZipExportFiles;
use BookStack\Exports\ZipExports\ZipValidationHelper; use BookStack\Exports\ZipExports\ZipValidationHelper;
class ZipExportBook extends ZipExportModel final class ZipExportBook extends ZipExportModel
{ {
public ?int $id = null; public ?int $id = null;
public string $name; public string $name;
@@ -101,9 +101,9 @@ class ZipExportBook extends ZipExportModel
return $errors; return $errors;
} }
public static function fromArray(array $data): self public static function fromArray(array $data): static
{ {
$model = new self(); $model = new static();
$model->id = $data['id'] ?? null; $model->id = $data['id'] ?? null;
$model->name = $data['name']; $model->name = $data['name'];

View File

@@ -7,7 +7,7 @@ use BookStack\Entities\Models\Page;
use BookStack\Exports\ZipExports\ZipExportFiles; use BookStack\Exports\ZipExports\ZipExportFiles;
use BookStack\Exports\ZipExports\ZipValidationHelper; use BookStack\Exports\ZipExports\ZipValidationHelper;
class ZipExportChapter extends ZipExportModel final class ZipExportChapter extends ZipExportModel
{ {
public ?int $id = null; public ?int $id = null;
public string $name; public string $name;
@@ -79,9 +79,9 @@ class ZipExportChapter extends ZipExportModel
return $errors; return $errors;
} }
public static function fromArray(array $data): self public static function fromArray(array $data): static
{ {
$model = new self(); $model = new static();
$model->id = $data['id'] ?? null; $model->id = $data['id'] ?? null;
$model->name = $data['name']; $model->name = $data['name'];

View File

@@ -7,7 +7,7 @@ use BookStack\Exports\ZipExports\ZipValidationHelper;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
class ZipExportImage extends ZipExportModel final class ZipExportImage extends ZipExportModel
{ {
public ?int $id = null; public ?int $id = null;
public string $name; public string $name;
@@ -43,9 +43,9 @@ class ZipExportImage extends ZipExportModel
return $context->validateData($data, $rules); return $context->validateData($data, $rules);
} }
public static function fromArray(array $data): self public static function fromArray(array $data): static
{ {
$model = new self(); $model = new static();
$model->id = $data['id'] ?? null; $model->id = $data['id'] ?? null;
$model->name = $data['name']; $model->name = $data['name'];

View File

@@ -30,12 +30,12 @@ abstract class ZipExportModel implements JsonSerializable
/** /**
* Decode the array of data into this export model. * Decode the array of data into this export model.
*/ */
abstract public static function fromArray(array $data): self; abstract public static function fromArray(array $data): static;
/** /**
* Decode an array of array data into an array of export models. * Decode an array of array data into an array of export models.
* @param array[] $data * @param array[] $data
* @return self[] * @return static[]
*/ */
public static function fromManyArray(array $data): array public static function fromManyArray(array $data): array
{ {

View File

@@ -7,7 +7,7 @@ use BookStack\Entities\Tools\PageContent;
use BookStack\Exports\ZipExports\ZipExportFiles; use BookStack\Exports\ZipExports\ZipExportFiles;
use BookStack\Exports\ZipExports\ZipValidationHelper; use BookStack\Exports\ZipExports\ZipValidationHelper;
class ZipExportPage extends ZipExportModel final class ZipExportPage extends ZipExportModel
{ {
public ?int $id = null; public ?int $id = null;
public string $name; public string $name;
@@ -86,9 +86,9 @@ class ZipExportPage extends ZipExportModel
return $errors; return $errors;
} }
public static function fromArray(array $data): self public static function fromArray(array $data): static
{ {
$model = new self(); $model = new static();
$model->id = $data['id'] ?? null; $model->id = $data['id'] ?? null;
$model->name = $data['name']; $model->name = $data['name'];

View File

@@ -5,7 +5,7 @@ namespace BookStack\Exports\ZipExports\Models;
use BookStack\Activity\Models\Tag; use BookStack\Activity\Models\Tag;
use BookStack\Exports\ZipExports\ZipValidationHelper; use BookStack\Exports\ZipExports\ZipValidationHelper;
class ZipExportTag extends ZipExportModel final class ZipExportTag extends ZipExportModel
{ {
public string $name; public string $name;
public ?string $value = null; public ?string $value = null;
@@ -39,9 +39,9 @@ class ZipExportTag extends ZipExportModel
return $context->validateData($data, $rules); return $context->validateData($data, $rules);
} }
public static function fromArray(array $data): self public static function fromArray(array $data): static
{ {
$model = new self(); $model = new static();
$model->name = $data['name']; $model->name = $data['name'];
$model->value = $data['value'] ?? null; $model->value = $data['value'] ?? null;

View File

@@ -76,7 +76,7 @@ class ZipExportBuilder
$zipFile = tempnam(sys_get_temp_dir(), 'bszip-'); $zipFile = tempnam(sys_get_temp_dir(), 'bszip-');
$zip = new ZipArchive(); $zip = new ZipArchive();
$opened = $zip->open($zipFile, ZipArchive::CREATE); $opened = $zip->open($zipFile, ZipArchive::OVERWRITE);
if ($opened !== true) { if ($opened !== true) {
throw new ZipExportException('Failed to create zip file for export.'); throw new ZipExportException('Failed to create zip file for export.');
} }

View File

@@ -12,22 +12,23 @@ use BookStack\Exports\ZipExports\Models\ZipExportChapter;
use BookStack\Exports\ZipExports\Models\ZipExportImage; use BookStack\Exports\ZipExports\Models\ZipExportImage;
use BookStack\Exports\ZipExports\Models\ZipExportModel; use BookStack\Exports\ZipExports\Models\ZipExportModel;
use BookStack\Exports\ZipExports\Models\ZipExportPage; use BookStack\Exports\ZipExports\Models\ZipExportPage;
use BookStack\Permissions\Permission;
use BookStack\Uploads\Attachment; use BookStack\Uploads\Attachment;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
class ZipExportReferences class ZipExportReferences
{ {
/** @var ZipExportPage[] */ /** @var array<int, ZipExportPage> */
protected array $pages = []; protected array $pages = [];
/** @var ZipExportChapter[] */ /** @var array<int, ZipExportChapter> */
protected array $chapters = []; protected array $chapters = [];
/** @var ZipExportBook[] */ /** @var array<int, ZipExportBook> */
protected array $books = []; protected array $books = [];
/** @var ZipExportAttachment[] */ /** @var array<int, ZipExportAttachment> */
protected array $attachments = []; protected array $attachments = [];
/** @var ZipExportImage[] */ /** @var array<int, ZipExportImage> */
protected array $images = []; protected array $images = [];
public function __construct( public function __construct(
@@ -134,11 +135,12 @@ class ZipExportReferences
// Find and include images if in visibility // Find and include images if in visibility
$page = $model->getPage(); $page = $model->getPage();
if ($page && userCan('view', $page)) { $pageExportModel = $this->pages[$page->id] ?? ($exportModel instanceof ZipExportPage ? $exportModel : null);
if (isset($this->images[$model->id]) || ($page && $pageExportModel && userCan(Permission::PageView, $page))) {
if (!isset($this->images[$model->id])) { if (!isset($this->images[$model->id])) {
$exportImage = ZipExportImage::fromModel($model, $files); $exportImage = ZipExportImage::fromModel($model, $files);
$this->images[$model->id] = $exportImage; $this->images[$model->id] = $exportImage;
$exportModel->images[] = $exportImage; $pageExportModel->images[] = $exportImage;
} }
return "[[bsexport:image:{$model->id}]]"; return "[[bsexport:image:{$model->id}]]";
} }

View File

@@ -29,7 +29,10 @@ class ZipImportReferences
/** @var Image[] */ /** @var Image[] */
protected array $images = []; protected array $images = [];
/** @var array<string, Model> */ /**
* Mapping keyed by "type:old-reference-id" with values being the new imported equivalent model.
* @var array<string, Model>
*/
protected array $referenceMap = []; protected array $referenceMap = [];
/** @var array<int, ZipExportPage> */ /** @var array<int, ZipExportPage> */
@@ -108,6 +111,22 @@ class ZipImportReferences
return null; return null;
} }
protected function replaceDrawingIdReferences(string $content): string
{
$referenceRegex = '/\sdrawio-diagram=[\'"](\d+)[\'"]/';
$result = preg_replace_callback($referenceRegex, function ($matches) {
$key = 'image:' . $matches[1];
$model = $this->referenceMap[$key] ?? null;
if ($model instanceof Image && $model->type === 'drawio') {
return ' drawio-diagram="' . $model->id . '"';
}
return $matches[0];
}, $content);
return $result ?: $content;
}
public function replaceReferences(): void public function replaceReferences(): void
{ {
foreach ($this->books as $book) { foreach ($this->books as $book) {
@@ -134,7 +153,9 @@ class ZipImportReferences
$exportPage = $this->zipExportPageMap[$page->id]; $exportPage = $this->zipExportPageMap[$page->id];
$contentType = $exportPage->markdown ? 'markdown' : 'html'; $contentType = $exportPage->markdown ? 'markdown' : 'html';
$content = $exportPage->markdown ?: ($exportPage->html ?: ''); $content = $exportPage->markdown ?: ($exportPage->html ?: '');
$parsed = $this->parser->parseReferences($content, $this->handleReference(...)); $parsed = $this->parser->parseReferences($content, $this->handleReference(...));
$parsed = $this->replaceDrawingIdReferences($parsed);
$this->pageRepo->setContentFromInput($page, [ $this->pageRepo->setContentFromInput($page, [
$contentType => $parsed, $contentType => $parsed,

View File

@@ -18,6 +18,7 @@ use BookStack\Exports\ZipExports\Models\ZipExportChapter;
use BookStack\Exports\ZipExports\Models\ZipExportImage; use BookStack\Exports\ZipExports\Models\ZipExportImage;
use BookStack\Exports\ZipExports\Models\ZipExportPage; use BookStack\Exports\ZipExports\Models\ZipExportPage;
use BookStack\Exports\ZipExports\Models\ZipExportTag; use BookStack\Exports\ZipExports\Models\ZipExportTag;
use BookStack\Permissions\Permission;
use BookStack\Uploads\Attachment; use BookStack\Uploads\Attachment;
use BookStack\Uploads\AttachmentService; use BookStack\Uploads\AttachmentService;
use BookStack\Uploads\FileStorage; use BookStack\Uploads\FileStorage;
@@ -288,7 +289,7 @@ class ZipImportRunner
$attachments = []; $attachments = [];
if ($exportModel instanceof ZipExportBook) { if ($exportModel instanceof ZipExportBook) {
if (!userCan('book-create-all')) { if (!userCan(Permission::BookCreateAll)) {
$errors[] = trans('errors.import_perms_books'); $errors[] = trans('errors.import_perms_books');
} }
array_push($pages, ...$exportModel->pages); array_push($pages, ...$exportModel->pages);
@@ -317,11 +318,11 @@ class ZipImportRunner
if (count($pages) > 0) { if (count($pages) > 0) {
if ($parent) { if ($parent) {
if (!userCan('page-create', $parent)) { if (!userCan(Permission::PageCreate, $parent)) {
$errors[] = trans('errors.import_perms_pages'); $errors[] = trans('errors.import_perms_pages');
} }
} else { } else {
$hasPermission = userCan('page-create-all') || userCan('page-create-own'); $hasPermission = userCan(Permission::PageCreateAll) || userCan(Permission::PageCreateOwn);
if (!$hasPermission) { if (!$hasPermission) {
$errors[] = trans('errors.import_perms_pages'); $errors[] = trans('errors.import_perms_pages');
} }
@@ -329,13 +330,13 @@ class ZipImportRunner
} }
if (count($images) > 0) { if (count($images) > 0) {
if (!userCan('image-create-all')) { if (!userCan(Permission::ImageCreateAll)) {
$errors[] = trans('errors.import_perms_images'); $errors[] = trans('errors.import_perms_images');
} }
} }
if (count($attachments) > 0) { if (count($attachments) > 0) {
if (!userCan('attachment-create-all')) { if (!userCan(Permission::AttachmentCreateAll)) {
$errors[] = trans('errors.import_perms_attachments'); $errors[] = trans('errors.import_perms_attachments');
} }
} }

View File

@@ -6,6 +6,7 @@ use BookStack\Activity\Models\Loggable;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\NotifyException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Permissions\Permission;
use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -27,10 +28,9 @@ abstract class Controller extends BaseController
} }
/** /**
* Stops the application and shows a permission error if * Stops the application and shows a permission error if the application is in demo mode.
* the application is in demo mode.
*/ */
protected function preventAccessInDemoMode() protected function preventAccessInDemoMode(): void
{ {
if (config('app.env') === 'demo') { if (config('app.env') === 'demo') {
$this->showPermissionError(); $this->showPermissionError();
@@ -40,14 +40,13 @@ abstract class Controller extends BaseController
/** /**
* Adds the page title into the view. * Adds the page title into the view.
*/ */
public function setPageTitle(string $title) public function setPageTitle(string $title): void
{ {
view()->share('pageTitle', $title); view()->share('pageTitle', $title);
} }
/** /**
* On a permission error redirect to home and display. * On a permission error redirect to home and display the error as a notification.
* the error as a notification.
* *
* @throws NotifyException * @throws NotifyException
*/ */
@@ -61,7 +60,7 @@ abstract class Controller extends BaseController
/** /**
* Checks that the current user has the given permission otherwise throw an exception. * Checks that the current user has the given permission otherwise throw an exception.
*/ */
protected function checkPermission(string $permission): void protected function checkPermission(string|Permission $permission): void
{ {
if (!user() || !user()->can($permission)) { if (!user() || !user()->can($permission)) {
$this->showPermissionError(); $this->showPermissionError();
@@ -81,7 +80,7 @@ abstract class Controller extends BaseController
/** /**
* Check the current user's permissions against an ownable item otherwise throw an exception. * Check the current user's permissions against an ownable item otherwise throw an exception.
*/ */
protected function checkOwnablePermission(string $permission, Model $ownable, string $redirectLocation = '/'): void protected function checkOwnablePermission(string|Permission $permission, Model $ownable, string $redirectLocation = '/'): void
{ {
if (!userCan($permission, $ownable)) { if (!userCan($permission, $ownable)) {
$this->showPermissionError($redirectLocation); $this->showPermissionError($redirectLocation);
@@ -92,7 +91,7 @@ abstract class Controller extends BaseController
* Check if a user has a permission or bypass the permission * Check if a user has a permission or bypass the permission
* check if the given callback resolves true. * check if the given callback resolves true.
*/ */
protected function checkPermissionOr(string $permission, callable $callback): void protected function checkPermissionOr(string|Permission $permission, callable $callback): void
{ {
if ($callback() !== true) { if ($callback() !== true) {
$this->checkPermission($permission); $this->checkPermission($permission);
@@ -103,7 +102,7 @@ abstract class Controller extends BaseController
* Check if the current user has a permission or bypass if the provided user * Check if the current user has a permission or bypass if the provided user
* id matches the current user. * id matches the current user.
*/ */
protected function checkPermissionOrCurrentUser(string $permission, int $userId): void protected function checkPermissionOrCurrentUser(string|Permission $permission, int $userId): void
{ {
$this->checkPermissionOr($permission, function () use ($userId) { $this->checkPermissionOr($permission, function () use ($userId) {
return $userId === user()->id; return $userId === user()->id;
@@ -111,7 +110,7 @@ abstract class Controller extends BaseController
} }
/** /**
* Send back a json error message. * Send back a JSON error message.
*/ */
protected function jsonError(string $messageText = '', int $statusCode = 500): JsonResponse protected function jsonError(string $messageText = '', int $statusCode = 500): JsonResponse
{ {
@@ -127,7 +126,7 @@ abstract class Controller extends BaseController
} }
/** /**
* Show a positive, successful notification to the user on next view load. * Show a positive, successful notification to the user on the next view load.
*/ */
protected function showSuccessNotification(string $message): void protected function showSuccessNotification(string $message): void
{ {
@@ -135,7 +134,7 @@ abstract class Controller extends BaseController
} }
/** /**
* Show a warning notification to the user on next view load. * Show a warning notification to the user on the next view load.
*/ */
protected function showWarningNotification(string $message): void protected function showWarningNotification(string $message): void
{ {
@@ -143,7 +142,7 @@ abstract class Controller extends BaseController
} }
/** /**
* Show an error notification to the user on next view load. * Show an error notification to the user on the next view load.
*/ */
protected function showErrorNotification(string $message): void protected function showErrorNotification(string $message): void
{ {

View File

@@ -9,6 +9,8 @@ class Kernel extends HttpKernel
/** /**
* The application's global HTTP middleware stack. * The application's global HTTP middleware stack.
* These middleware are run during every request to your application. * These middleware are run during every request to your application.
*
* @var list<class-string>
*/ */
protected $middleware = [ protected $middleware = [
\BookStack\Http\Middleware\PreventRequestsDuringMaintenance::class, \BookStack\Http\Middleware\PreventRequestsDuringMaintenance::class,
@@ -21,7 +23,7 @@ class Kernel extends HttpKernel
/** /**
* The application's route middleware groups. * The application's route middleware groups.
* *
* @var array * @var array<string, array<int, class-string>>
*/ */
protected $middlewareGroups = [ protected $middlewareGroups = [
'web' => [ 'web' => [
@@ -47,7 +49,7 @@ class Kernel extends HttpKernel
/** /**
* The application's middleware aliases. * The application's middleware aliases.
* *
* @var array * @var array<string, class-string>
*/ */
protected $middlewareAliases = [ protected $middlewareAliases = [
'auth' => \BookStack\Http\Middleware\Authenticate::class, 'auth' => \BookStack\Http\Middleware\Authenticate::class,

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