Compare commits

..

1 Commits

Author SHA1 Message Date
Dan Brown
a9f5e98ba9 Drawings: Added class to extract drawio data from png files 2025-06-19 17:23:56 +01:00
1031 changed files with 9334 additions and 23222 deletions

View File

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

View File

@@ -36,14 +36,10 @@ 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 timezones # Application timezone
# The first option is used to determine what timezone is used for date storage. # Used where dates are displayed such as on exported content.
# 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

@@ -177,7 +177,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish Michał Stelmach (stelmach-web) :: Polish
arniom :: French arniom :: French
REMOVED_USER :: French; German; Dutch; Portuguese, Brazilian; Portuguese; Turkish; REMOVED_USER :: French; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
林祖年 (contagion) :: Chinese Traditional 林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@@ -222,7 +222,7 @@ SmokingCrop :: Dutch
Maciej Lebiest (Szwendacz) :: Polish Maciej Lebiest (Szwendacz) :: Polish
DiscordDigital :: German; German Informal DiscordDigital :: German; German Informal
Gábor Marton (dodver) :: Hungarian Gábor Marton (dodver) :: Hungarian
Jakob Åsell (Jasell) :: Swedish Jasell :: Swedish
Ghost_chu (ghostchu) :: Chinese Simplified Ghost_chu (ghostchu) :: Chinese Simplified
Ravid Shachar (ravidshachar) :: Hebrew Ravid Shachar (ravidshachar) :: Hebrew
Helga Guchshenskaya (guchshenskaya) :: Russian Helga Guchshenskaya (guchshenskaya) :: Russian
@@ -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; French Worive :: Catalan
Илья Скаба (skabailya) :: Russian Илья Скаба (skabailya) :: Russian
Irjan Olsen (Irch) :: Norwegian Bokmal Irjan Olsen (Irch) :: Norwegian Bokmal
Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic) Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic)
@@ -489,44 +489,3 @@ Hari (muhhari) :: Indonesian
仙君御 (xjy) :: Chinese Simplified 仙君御 (xjy) :: Chinese Simplified
TapioM :: Finnish TapioM :: Finnish
lingb58 :: Chinese Traditional lingb58 :: Chinese Traditional
Angel Pandey (angel-pandey) :: Nepali
Supriya Shrestha (supriyashrestha) :: Nepali
gprabhat :: Nepali
CellCat :: Chinese Simplified
Al Desrahim (aldesrahim) :: Indonesian
ahmad abbaspour (deshneh.dar.diss) :: Persian
Erjon K. (ekr) :: Albanian
LiZerui (iamzrli) :: Chinese Traditional
Ticker (ticker.com) :: Hebrew
CrazyComputer :: Chinese Simplified
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
ltnzr :: French
Frank Holler (holler.frank) :: German; German Informal
Korab Arifi (korabidev) :: Albanian
Petr Husák (petrhusak) :: Czech
Bernardo Maia (bernardo.bmaia2) :: Portuguese, Brazilian
Amr (amr3k) :: Arabic
Tahsin Ahmed (tahsinahmed2012) :: Bengali
bojan_che :: Serbian (Cyrillic)
setiawan setiawan (culture.setiawan) :: Indonesian
Donald Mac Kenzie (kiuman) :: Norwegian Bokmal
Gabriel Silver (GabrielBSilver) :: Hebrew
Tomas Darius Davainis (Tomasdd) :: Lithuanian
CriedHero :: Chinese Simplified
Henrik (henrik2105) :: Norwegian Bokmal
FoW (fofwisdom) :: Korean
serinf-lauza :: French
Diyan Nikolaev (nikolaev.diyan) :: Bulgarian
Shadluk Avan (quldosh) :: Uzbek
Marci (MartonPoto) :: Hungarian
Michał Sadurski (wheeskeey) :: Polish
JanDziaslo :: Polish

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2015-2026, Dan Brown and the BookStack project contributors. Copyright (c) 2015-2025, Dan Brown and the BookStack project contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -9,9 +9,11 @@ use Illuminate\Http\Request;
class OidcController extends Controller class OidcController extends Controller
{ {
public function __construct( protected OidcService $oidcService;
protected OidcService $oidcService
) { public function __construct(OidcService $oidcService)
{
$this->oidcService = $oidcService;
$this->middleware('guard:oidc'); $this->middleware('guard:oidc');
} }
@@ -28,7 +30,7 @@ class OidcController extends Controller
return redirect('/login'); return redirect('/login');
} }
session()->put('oidc_state', time() . ':' . $loginDetails['state']); session()->flash('oidc_state', $loginDetails['state']);
return redirect($loginDetails['url']); return redirect($loginDetails['url']);
} }
@@ -39,16 +41,10 @@ class OidcController extends Controller
*/ */
public function callback(Request $request) public function callback(Request $request)
{ {
$storedState = session()->pull('oidc_state');
$responseState = $request->query('state'); $responseState = $request->query('state');
$splitState = explode(':', session()->pull('oidc_state', ':'), 2);
if (count($splitState) !== 2) {
$splitState = [null, null];
}
[$storedStateTime, $storedState] = $splitState; if ($storedState !== $responseState) {
$threeMinutesAgo = time() - 3 * 60;
if (!$storedState || $storedState !== $responseState || intval($storedStateTime) < $threeMinutesAgo) {
$this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')])); $this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
return redirect('/login'); return redirect('/login');
@@ -66,7 +62,7 @@ class OidcController extends Controller
} }
/** /**
* Log the user out, then start the OIDC RP-initiated logout process. * Log the user out then start the OIDC RP-initiated logout process.
*/ */
public function logout() public function logout()
{ {

View File

@@ -2,18 +2,33 @@
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 User::query()->find($identifier); return $this->createModel()->newQuery()->find($identifier);
} }
/** /**
@@ -44,7 +59,10 @@ class ExternalBaseUserProvider implements UserProvider
*/ */
public function retrieveByCredentials(array $credentials): ?Authenticatable public function retrieveByCredentials(array $credentials): ?Authenticatable
{ {
return User::query() // Search current user base by looking up a uid
$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,18 +3,23 @@
namespace BookStack\Access\Guards; namespace BookStack\Access\Guards;
/** /**
* External Auth Session Guard. * Saml2 Session Guard.
* *
* The login process for external auth (SAML2/OIDC) is async in nature, meaning it does not fit very well * The saml2 login process is async in nature meaning it does not fit very well
* into the default laravel 'Guard' auth flow. Instead, most of the logic is done via the relevant * into the default laravel 'Guard' auth flow. Instead most of the logic is done
* controller and services. This class provides a safer, thin version of SessionGuard. * via the Saml2 controller & Saml2Service. This class provides a safer, thin
* 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 = []): bool public function validate(array $credentials = [])
{ {
return false; return false;
} }
@@ -22,9 +27,12 @@ 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): bool public function attempt(array $credentials = [], $remember = false)
{ {
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; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
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,31 +24,43 @@ 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 readonly string $name; protected $name;
/** /**
* The user we last attempted to retrieve. * The user we last attempted to retrieve.
*
* @var \Illuminate\Contracts\Auth\Authenticatable
*/ */
protected Authenticatable|null $lastAttempted; protected $lastAttempted;
/** /**
* The session used by the guard. * The session used by the guard.
*
* @var \Illuminate\Contracts\Session\Session
*/ */
protected Session $session; protected $session;
/** /**
* Indicates if the logout method has been called. * Indicates if the logout method has been called.
*
* @var bool
*/ */
protected bool $loggedOut = false; protected $loggedOut = false;
/** /**
* Service to handle common registration actions. * Service to handle common registration actions.
*
* @var RegistrationService
*/ */
protected RegistrationService $registrationService; protected $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)
{ {
@@ -60,11 +72,13 @@ class ExternalBaseSessionGuard implements StatefulGuard
/** /**
* Get the currently authenticated user. * Get the currently authenticated user.
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/ */
public function user(): Authenticatable|null public function user()
{ {
if ($this->loggedOut) { if ($this->loggedOut) {
return null; return;
} }
// 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
@@ -87,11 +101,13 @@ 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(): int|null public function id()
{ {
if ($this->loggedOut) { if ($this->loggedOut) {
return null; return;
} }
return $this->user() return $this->user()
@@ -101,8 +117,12 @@ 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 = []): bool public function once(array $credentials = [])
{ {
if ($this->validate($credentials)) { if ($this->validate($credentials)) {
$this->setUser($this->lastAttempted); $this->setUser($this->lastAttempted);
@@ -115,8 +135,12 @@ 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): Authenticatable|false public function onceUsingId($id)
{ {
if (!is_null($user = $this->provider->retrieveById($id))) { if (!is_null($user = $this->provider->retrieveById($id))) {
$this->setUser($user); $this->setUser($user);
@@ -129,26 +153,38 @@ class ExternalBaseSessionGuard implements StatefulGuard
/** /**
* Validate a user's credentials. * Validate a user's credentials.
*
* @param array $credentials
*
* @return bool
*/ */
public function validate(array $credentials = []): bool public function validate(array $credentials = [])
{ {
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): bool public function attempt(array $credentials = [], $remember = false)
{ {
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(mixed $id, $remember = false): Authenticatable|false public function loginUsingId($id, $remember = 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.
@@ -158,9 +194,12 @@ class ExternalBaseSessionGuard implements StatefulGuard
/** /**
* Log a user into the application. * Log a user into the application.
* *
* @param bool $remember * @param \Illuminate\Contracts\Auth\Authenticatable $user
* @param bool $remember
*
* @return void
*/ */
public function login(Authenticatable $user, $remember = false): void public function login(AuthenticatableContract $user, $remember = false)
{ {
$this->updateSession($user->getAuthIdentifier()); $this->updateSession($user->getAuthIdentifier());
@@ -169,8 +208,12 @@ 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(string|int $id): void protected function updateSession($id)
{ {
$this->session->put($this->getName(), $id); $this->session->put($this->getName(), $id);
@@ -179,8 +222,10 @@ class ExternalBaseSessionGuard implements StatefulGuard
/** /**
* Log the user out of the application. * Log the user out of the application.
*
* @return void
*/ */
public function logout(): void public function logout()
{ {
$this->clearUserDataFromStorage(); $this->clearUserDataFromStorage();
@@ -194,48 +239,62 @@ 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(): void protected function clearUserDataFromStorage()
{ {
$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(): Authenticatable public function getLastAttempted()
{ {
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(): string public function getName()
{ {
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(): bool public function viaRemember()
{ {
return false; return false;
} }
/** /**
* Return the currently cached user. * Return the currently cached user.
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/ */
public function getUser(): Authenticatable|null public function getUser()
{ {
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(Authenticatable $user): self public function setUser(AuthenticatableContract $user)
{ {
$this->user = $user; $this->user = $user;

View File

@@ -35,9 +35,13 @@ 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 = []): bool public function validate(array $credentials = [])
{ {
$userDetails = $this->ldapService->getUserDetails($credentials['username']); $userDetails = $this->ldapService->getUserDetails($credentials['username']);
@@ -53,13 +57,16 @@ 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 LdapException*@throws \BookStack\Exceptions\JsonDebugException
* @throws LoginAttemptException * @throws LoginAttemptException
* @throws JsonDebugException * @throws JsonDebugException
*
* @return bool
*/ */
public function attempt(array $credentials = [], $remember = false): bool public function attempt(array $credentials = [], $remember = false)
{ {
$username = $credentials['username']; $username = $credentials['username'];
$userDetails = $this->ldapService->getUserDetails($username); $userDetails = $this->ldapService->getUserDetails($username);

View File

@@ -9,7 +9,6 @@ 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;
@@ -51,7 +50,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(Permission::UsersManage) && $user->can(Permission::UserRolesManage)) { if ($user->can('users-manage') && $user->can('user-roles-manage')) {
$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);
@@ -96,7 +95,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, 'remember' => false]; return ['user_id' => null, 'method' => null];
} }
[$id, $method, $remember, $time] = explode(':', $value); [$id, $method, $remember, $time] = explode(':', $value);
@@ -104,18 +103,18 @@ class LoginService
if ($time < $hourAgo) { if ($time < $hourAgo) {
$this->clearLastLoginAttempted(); $this->clearLastLoginAttempted();
return ['user_id' => null, 'method' => null, 'remember' => false]; return ['user_id' => null, 'method' => null];
} }
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): void protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember)
{ {
session()->put( session()->put(
self::LAST_LOGIN_ATTEMPTED_SESSION_KEY, self::LAST_LOGIN_ATTEMPTED_SESSION_KEY,

View File

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

View File

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

View File

@@ -14,9 +14,10 @@ use PragmaRX\Google2FA\Support\Constants;
class TotpService class TotpService
{ {
public function __construct( protected $google2fa;
protected Google2FA $google2fa
) { public function __construct(Google2FA $google2fa)
{
$this->google2fa = $google2fa; $this->google2fa = $google2fa;
// Use SHA1 as a default, Personal testing of other options in 2021 found // Use SHA1 as a default, Personal testing of other options in 2021 found
// many apps lack support for other algorithms yet still will scan // many apps lack support for other algorithms yet still will scan
@@ -34,7 +35,7 @@ class TotpService
} }
/** /**
* Generate a TOTP URL from a secret key. * Generate a TOTP URL from secret key.
*/ */
public function generateUrl(string $secret, User $user): string public function generateUrl(string $secret, User $user): string
{ {

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
* @return array{url: string, id: ?string} * @returns array{url: string, id: ?string}
*/ */
public function logout(User $user): array public function logout(User $user): array
{ {

View File

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

View File

@@ -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.
* @return array<string, string> * @returns array<string, string>
*/ */
public function getActive(): array public function getActive(): array
{ {

View File

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

View File

@@ -4,7 +4,6 @@ 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
{ {
@@ -17,8 +16,8 @@ class AuditLogApiController extends ApiController
*/ */
public function list() public function list()
{ {
$this->checkPermission(Permission::SettingsManage); $this->checkPermission('settings-manage');
$this->checkPermission(Permission::UsersManage); $this->checkPermission('users-manage');
$query = Activity::query()->with(['user']); $query = Activity::query()->with(['user']);

View File

@@ -5,7 +5,6 @@ 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;
@@ -14,8 +13,8 @@ class AuditLogController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
$this->checkPermission(Permission::SettingsManage); $this->checkPermission('settings-manage');
$this->checkPermission(Permission::UsersManage); $this->checkPermission('users-manage');
$sort = $request->get('sort', 'activity_date'); $sort = $request->get('sort', 'activity_date');
$order = $request->get('order', 'desc'); $order = $request->get('order', 'desc');

View File

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

View File

@@ -7,7 +7,6 @@ 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;
@@ -22,7 +21,7 @@ class CommentController extends Controller
/** /**
* Save a new comment for a Page. * Save a new comment for a Page.
* *
* @throws ValidationException|\Exception * @throws ValidationException
*/ */
public function savePageComment(Request $request, int $pageId) public function savePageComment(Request $request, int $pageId)
{ {
@@ -37,8 +36,13 @@ class CommentController extends Controller
return response('Not found', 404); return response('Not found', 404);
} }
// Prevent adding comments to draft pages
if ($page->draft) {
return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400);
}
// Create a new comment. // Create a new comment.
$this->checkPermission(Permission::CommentCreateAll); $this->checkPermission('comment-create-all');
$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);
@@ -60,8 +64,8 @@ class CommentController extends Controller
]); ]);
$comment = $this->commentRepo->getById($commentId); $comment = $this->commentRepo->getById($commentId);
$this->checkOwnablePermission(Permission::PageView, $comment->entity); $this->checkOwnablePermission('page-view', $comment->entity);
$this->checkOwnablePermission(Permission::CommentUpdate, $comment); $this->checkOwnablePermission('comment-update', $comment);
$comment = $this->commentRepo->update($comment, $input['html']); $comment = $this->commentRepo->update($comment, $input['html']);
@@ -77,8 +81,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(Permission::PageView, $comment->entity); $this->checkOwnablePermission('page-view', $comment->entity);
if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) { if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
$this->showPermissionError(); $this->showPermissionError();
} }
@@ -97,8 +101,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(Permission::PageView, $comment->entity); $this->checkOwnablePermission('page-view', $comment->entity);
if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) { if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
$this->showPermissionError(); $this->showPermissionError();
} }
@@ -117,7 +121,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(Permission::CommentDelete, $comment); $this->checkOwnablePermission('comment-delete', $comment);
$this->commentRepo->delete($comment); $this->commentRepo->delete($comment);

View File

@@ -5,14 +5,13 @@ 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(Permission::ReceiveNotifications); $this->checkPermission('receive-notifications');
$this->preventGuestAccess(); $this->preventGuestAccess();
$requestData = $this->validate($request, array_merge([ $requestData = $this->validate($request, array_merge([

View File

@@ -6,7 +6,6 @@ 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;
@@ -15,7 +14,7 @@ class WebhookController extends Controller
public function __construct() public function __construct()
{ {
$this->middleware([ $this->middleware([
Permission::SettingsManage->middleware() 'can:settings-manage',
]); ]);
} }

View File

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

View File

@@ -3,68 +3,48 @@
namespace BookStack\Activity\Models; namespace BookStack\Activity\Models;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\Permissions\Models\JointPermission;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\HasCreatorAndUpdater; use BookStack\Users\Models\HasCreatorAndUpdater;
use BookStack\Users\Models\OwnableInterface;
use BookStack\Util\HtmlContentFilter; use BookStack\Util\HtmlContentFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
/** /**
* @property int $id * @property int $id
* @property string $text - Deprecated & now unused (#4821)
* @property string $html * @property string $html
* @property int|null $parent_id - Relates to local_id, not id * @property int|null $parent_id - Relates to local_id, not id
* @property int $local_id * @property int $local_id
* @property string $commentable_type * @property string $entity_type
* @property int $commentable_id * @property int $entity_id
* @property 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, OwnableInterface class Comment extends Model implements Loggable
{ {
use HasFactory; use HasFactory;
use HasCreatorAndUpdater; use HasCreatorAndUpdater;
protected $fillable = ['parent_id']; protected $fillable = ['parent_id'];
protected $hidden = ['html'];
protected $casts = [
'archived' => 'boolean',
];
/** /**
* Get the entity that this comment belongs to. * Get the entity that this comment belongs to.
*/ */
public function entity(): MorphTo public function entity(): MorphTo
{ {
// We specifically define null here to avoid the different name (commentable) return $this->morphTo('entity');
// being used by Laravel eager loading instead of the method name, which it was doing
// in some scenarios like when deserialized when going through the queue system.
// So we instead specify the type and id column names to use.
// Related to:
// https://github.com/laravel/framework/pull/24815
// https://github.com/laravel/framework/issues/27342
// https://github.com/laravel/framework/issues/47953
// (and probably more)
// Ultimately, we could just align the method name to 'commentable' but that would be a potential
// breaking change and not really worthwhile in a patch due to the risk of creating extra problems.
return $this->morphTo(null, 'commentable_type', 'commentable_id');
} }
/** /**
* 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
{ {
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent') return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
->where('commentable_type', '=', $this->commentable_type) ->where('entity_type', '=', $this->entity_type)
->where('commentable_id', '=', $this->commentable_id); ->where('entity_id', '=', $this->entity_id);
} }
/** /**
@@ -77,27 +57,11 @@ class Comment extends Model implements Loggable, OwnableInterface
public function logDescriptor(): string public function logDescriptor(): string
{ {
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->commentable_type} (ID: {$this->commentable_id})"; return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
} }
public function safeHtml(): string public function safeHtml(): string
{ {
return HtmlContentFilter::removeActiveContentFromHtmlString($this->html ?? ''); return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id')
->whereColumn('joint_permissions.entity_type', '=', 'comments.commentable_type');
}
/**
* Scope the query to just the comments visible to the user based upon the
* user visibility of what has been commented on.
*/
public function scopeVisible(Builder $query): Builder
{
return app()->make(PermissionApplicator::class)
->restrictEntityRelationQuery($query, 'comments', 'commentable_id', 'commentable_type');
} }
} }

View File

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

View File

@@ -1,20 +0,0 @@
<?php
namespace BookStack\Activity\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property string $mentionable_type
* @property int $mentionable_id
* @property int $from_user_id
* @property int $to_user_id
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class MentionHistory extends Model
{
protected $table = 'mention_history';
}

View File

@@ -12,8 +12,6 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
* @property int $id * @property int $id
* @property string $name * @property string $name
* @property string $value * @property string $value
* @property int $entity_id
* @property string $entity_type
* @property int $order * @property int $order
*/ */
class Tag extends Model class Tag extends Model

View File

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

View File

@@ -5,7 +5,6 @@ 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;
@@ -20,7 +19,6 @@ abstract class BaseNotificationHandler implements NotificationHandler
{ {
$users = User::query()->whereIn('id', array_unique($userIds))->get(); $users = User::query()->whereIn('id', array_unique($userIds))->get();
/** @var User $user */
foreach ($users as $user) { foreach ($users as $user) {
// Prevent sending to the user that initiated the activity // Prevent sending to the user that initiated the activity
if ($user->id === $initiator->id) { if ($user->id === $initiator->id) {
@@ -28,7 +26,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(Permission::ReceiveNotifications)) { if (!$user->can('receive-notifications')) {
continue; continue;
} }

View File

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

View File

@@ -1,85 +0,0 @@
<?php
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\MentionHistory;
use BookStack\Activity\Notifications\Messages\CommentMentionNotification;
use BookStack\Activity\Tools\MentionParser;
use BookStack\Entities\Models\Page;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
class CommentMentionNotificationHandler extends BaseNotificationHandler
{
public function handle(Activity $activity, Loggable|string $detail, User $user): void
{
if (!($detail instanceof Comment) || !($detail->entity instanceof Page)) {
throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page");
}
/** @var Page $page */
$page = $detail->entity;
$parser = new MentionParser();
$mentionedUserIds = $parser->parseUserIdsFromHtml($detail->html);
$realMentionedUsers = User::whereIn('id', $mentionedUserIds)->get();
$receivingNotifications = $realMentionedUsers->filter(function (User $user) {
$prefs = new UserNotificationPreferences($user);
return $prefs->notifyOnCommentMentions();
});
$receivingNotificationsUserIds = $receivingNotifications->pluck('id')->toArray();
$userMentionsToLog = $realMentionedUsers;
// When an edit, we check our history to see if we've already notified the user about this comment before
// so that we can filter them out to avoid double notifications.
if ($activity->type === ActivityType::COMMENT_UPDATE) {
$previouslyNotifiedUserIds = $this->getPreviouslyNotifiedUserIds($detail);
$receivingNotificationsUserIds = array_values(array_diff($receivingNotificationsUserIds, $previouslyNotifiedUserIds));
$userMentionsToLog = $userMentionsToLog->filter(function (User $user) use ($previouslyNotifiedUserIds) {
return !in_array($user->id, $previouslyNotifiedUserIds);
});
}
$this->logMentions($userMentionsToLog, $detail, $user);
$this->sendNotificationToUserIds(CommentMentionNotification::class, $receivingNotificationsUserIds, $user, $detail, $page);
}
/**
* @param Collection<User> $mentionedUsers
*/
protected function logMentions(Collection $mentionedUsers, Comment $comment, User $fromUser): void
{
$mentions = [];
$now = Carbon::now();
foreach ($mentionedUsers as $mentionedUser) {
$mentions[] = [
'mentionable_type' => $comment->getMorphClass(),
'mentionable_id' => $comment->id,
'from_user_id' => $fromUser->id,
'to_user_id' => $mentionedUser->id,
'created_at' => $now,
'updated_at' => $now,
];
}
MentionHistory::query()->insert($mentions);
}
protected function getPreviouslyNotifiedUserIds(Comment $comment): array
{
return MentionHistory::query()
->where('mentionable_id', $comment->id)
->where('mentionable_type', $comment->getMorphClass())
->pluck('to_user_id')
->toArray();
}
}

View File

@@ -20,8 +20,7 @@ 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 the last update from activity // Get 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)
@@ -39,8 +38,8 @@ class PageUpdateNotificationHandler extends BaseNotificationHandler
$watchers = new EntityWatchers($detail, WatchLevels::UPDATES); $watchers = new EntityWatchers($detail, WatchLevels::UPDATES);
$watcherIds = $watchers->getWatcherUserIds(); $watcherIds = $watchers->getWatcherUserIds();
// Add the page owner if preferences allow // Add page owner if preferences allow
if ($detail->owned_by && !$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) { if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy); $userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
if ($userNotificationPrefs->notifyOnOwnPageChanges()) { if ($userNotificationPrefs->notifyOnOwnPageChanges()) {
$watcherIds[] = $detail->owned_by; $watcherIds[] = $detail->owned_by;

View File

@@ -1,37 +0,0 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class CommentMentionNotification extends BaseActivityNotification
{
public function toMail(User $notifiable): MailMessage
{
/** @var Comment $comment */
$comment = $this->detail;
/** @var Page $page */
$page = $comment->entity;
$locale = $notifiable->getLocale();
$listLines = array_filter([
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
$locale->trans('notifications.detail_commenter') => $this->user->name,
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
]);
return $this->newMailMessage($locale)
->subject($locale->trans('notifications.comment_mention_subject', ['pageName' => $page->getShortName()]))
->line($locale->trans('notifications.comment_mention_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine($listLines))
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
->line($this->buildReasonFooterLine($locale));
}
}

View File

@@ -6,7 +6,6 @@ use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity; use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable; use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler; use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\CommentMentionNotificationHandler;
use BookStack\Activity\Notifications\Handlers\NotificationHandler; use BookStack\Activity\Notifications\Handlers\NotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler; use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler; use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
@@ -49,7 +48,5 @@ class NotificationManager
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class); $this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class); $this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class); $this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class);
} }
} }

View File

@@ -4,7 +4,6 @@ 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
{ {
@@ -13,11 +12,6 @@ class CommentTree
* @var CommentTreeNode[] * @var CommentTreeNode[]
*/ */
protected array $tree; protected array $tree;
/**
* A linear array of loaded comments.
* @var Comment[]
*/
protected array $comments; protected array $comments;
public function __construct( public function __construct(
@@ -44,7 +38,7 @@ class CommentTree
public function getActive(): array public function getActive(): array
{ {
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived)); return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived);
} }
public function activeThreadCount(): int public function activeThreadCount(): int
@@ -54,7 +48,7 @@ class CommentTree
public function getArchived(): array public function getArchived(): array
{ {
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived)); return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived);
} }
public function archivedThreadCount(): int public function archivedThreadCount(): int
@@ -76,7 +70,7 @@ class CommentTree
public function canUpdateAny(): bool public function canUpdateAny(): bool
{ {
foreach ($this->comments as $comment) { foreach ($this->comments as $comment) {
if (userCan(Permission::CommentUpdate, $comment)) { if (userCan('comment-update', $comment)) {
return true; return true;
} }
} }
@@ -84,14 +78,6 @@ class CommentTree
return false; return false;
} }
public function loadVisibleHtml(): void
{
foreach ($this->comments as $comment) {
$comment->setAttribute('html', $comment->safeHtml());
$comment->makeVisible('html');
}
}
/** /**
* @param Comment[] $comments * @param Comment[] $comments
* @return CommentTreeNode[] * @return CommentTreeNode[]
@@ -136,9 +122,6 @@ class CommentTree
return new CommentTreeNode($byId[$id], $depth, $children); return new CommentTreeNode($byId[$id], $depth, $children);
} }
/**
* @return Comment[]
*/
protected function loadComments(): array protected function loadComments(): array
{ {
if (!$this->enabled()) { if (!$this->enabled()) {

View File

@@ -1,28 +0,0 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Util\HtmlDocument;
use DOMElement;
class MentionParser
{
public function parseUserIdsFromHtml(string $html): array
{
$doc = new HtmlDocument($html);
$ids = [];
$mentionLinks = $doc->queryXPath('//a[@data-mention-user-id]');
foreach ($mentionLinks as $link) {
if ($link instanceof DOMElement) {
$id = intval($link->getAttribute('data-mention-user-id'));
if ($id > 0) {
$ids[] = $id;
}
}
}
return array_values(array_unique($ids));
}
}

View File

@@ -3,16 +3,17 @@
namespace BookStack\Activity\Tools; namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Tag; use BookStack\Activity\Models\Tag;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Permissions\Permission;
class TagClassGenerator class TagClassGenerator
{ {
public function __construct( protected array $tags;
protected Entity $entity
) { /**
* @param Tag[] $tags
*/
public function __construct(array $tags)
{
$this->tags = $tags;
} }
/** /**
@@ -21,23 +22,14 @@ class TagClassGenerator
public function generate(): array public function generate(): array
{ {
$classes = []; $classes = [];
$tags = $this->entity->tags->all();
foreach ($tags as $tag) { foreach ($this->tags as $tag) {
array_push($classes, ...$this->generateClassesForTag($tag)); $name = $this->normalizeTagClassString($tag->name);
} $value = $this->normalizeTagClassString($tag->value);
$classes[] = 'tag-name-' . $name;
if ($this->entity instanceof BookChild && userCan(Permission::BookView, $this->entity->book)) { if ($value) {
$bookTags = $this->entity->book->tags; $classes[] = 'tag-value-' . $value;
foreach ($bookTags as $bookTag) { $classes[] = 'tag-pair-' . $name . '-' . $value;
array_push($classes, ...$this->generateClassesForTag($bookTag, 'book-'));
}
}
if ($this->entity instanceof Page && $this->entity->chapter && userCan(Permission::ChapterView, $this->entity->chapter)) {
$chapterTags = $this->entity->chapter->tags;
foreach ($chapterTags as $chapterTag) {
array_push($classes, ...$this->generateClassesForTag($chapterTag, 'chapter-'));
} }
} }
@@ -49,22 +41,6 @@ class TagClassGenerator
return implode(' ', $this->generate()); return implode(' ', $this->generate());
} }
/**
* @return string[]
*/
protected function generateClassesForTag(Tag $tag, string $prefix = ''): array
{
$classes = [];
$name = $this->normalizeTagClassString($tag->name);
$value = $this->normalizeTagClassString($tag->value);
$classes[] = "{$prefix}tag-name-{$name}";
if ($value) {
$classes[] = "{$prefix}tag-value-{$value}";
$classes[] = "{$prefix}tag-pair-{$name}-{$value}";
}
return $classes;
}
protected function normalizeTagClassString(string $value): string protected function normalizeTagClassString(string $value): string
{ {
$value = str_replace(' ', '', strtolower($value)); $value = str_replace(' ', '', strtolower($value));

View File

@@ -7,7 +7,6 @@ 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;
@@ -23,7 +22,7 @@ class UserEntityWatchOptions
public function canWatch(): bool public function canWatch(): bool
{ {
return $this->user->can(Permission::ReceiveNotifications) && !$this->user->isGuest(); return $this->user->can('receive-notifications') && !$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($this->detail); $data['related_item'] = $this->formatModel();
} }
return $data; return $data;
@@ -83,8 +83,10 @@ class WebhookFormatter
); );
} }
protected function formatModel(Model $model): array protected function formatModel(): 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.
* @return array<string, int> * @returns 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.
* @return array<string, int> * @returns array<string, int>
*/ */
public static function allSuitedFor(Entity $entity): array public static function allSuitedFor(Entity $entity): array
{ {

View File

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

View File

@@ -4,7 +4,6 @@ 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;
@@ -147,7 +146,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(Permission::AccessApi)) { if (!$token->user->can('access-api')) {
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,7 +4,6 @@ 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;
@@ -17,8 +16,8 @@ class UserApiTokenController extends Controller
*/ */
public function create(Request $request, int $userId) public function create(Request $request, int $userId)
{ {
$this->checkPermission(Permission::AccessApi); $this->checkPermission('access-api');
$this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId); $this->checkPermissionOrCurrentUser('users-manage', $userId);
$this->updateContext($request); $this->updateContext($request);
$user = User::query()->findOrFail($userId); $user = User::query()->findOrFail($userId);
@@ -36,8 +35,8 @@ class UserApiTokenController extends Controller
*/ */
public function store(Request $request, int $userId) public function store(Request $request, int $userId)
{ {
$this->checkPermission(Permission::AccessApi); $this->checkPermission('access-api');
$this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId); $this->checkPermissionOrCurrentUser('users-manage', $userId);
$this->validate($request, [ $this->validate($request, [
'name' => ['required', 'max:250'], 'name' => ['required', 'max:250'],
@@ -144,8 +143,8 @@ class UserApiTokenController extends Controller
*/ */
protected function checkPermissionAndFetchUserToken(int $userId, int $tokenId): array protected function checkPermissionAndFetchUserToken(int $userId, int $tokenId): array
{ {
$this->checkPermissionOr(Permission::UsersManage, function () use ($userId) { $this->checkPermissionOr('users-manage', function () use ($userId) {
return $userId === user()->id && userCan(Permission::AccessApi); return $userId === user()->id && userCan('access-api');
}); });
$user = User::query()->findOrFail($userId); $user = User::query()->findOrFail($userId);

View File

@@ -83,7 +83,7 @@ class HomeController extends Controller
if ($homepageOption === 'bookshelves') { if ($homepageOption === 'bookshelves') {
$shelves = $this->queries->shelves->visibleForListWithCover() $shelves = $this->queries->shelves->visibleForListWithCover()
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder()) ->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000)); ->paginate(18);
$data = array_merge($commonData, ['shelves' => $shelves]); $data = array_merge($commonData, ['shelves' => $shelves]);
return view('home.shelves', $data); return view('home.shelves', $data);
@@ -92,7 +92,7 @@ class HomeController extends Controller
if ($homepageOption === 'books') { if ($homepageOption === 'books') {
$books = $this->queries->books->visibleForListWithCover() $books = $this->queries->books->visibleForListWithCover()
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder()) ->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); ->paginate(18);
$data = array_merge($commonData, ['books' => $books]); $data = array_merge($commonData, ['books' => $books]);
return view('home.books', $data); return view('home.books', $data);

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

@@ -3,7 +3,6 @@
namespace BookStack\App\Providers; namespace BookStack\App\Providers;
use BookStack\Access\SocialDriverManager; use BookStack\Access\SocialDriverManager;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Tools\ActivityLogger; use BookStack\Activity\Tools\ActivityLogger;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Bookshelf;
@@ -74,7 +73,6 @@ class AppServiceProvider extends ServiceProvider
'book' => Book::class, 'book' => Book::class,
'chapter' => Chapter::class, 'chapter' => Chapter::class,
'page' => Page::class, 'page' => Page::class,
'comment' => Comment::class,
]); ]);
} }
} }

View File

@@ -59,8 +59,8 @@ class AuthServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
Auth::provider('external-users', function () { Auth::provider('external-users', function ($app, array $config) {
return new ExternalBaseUserProvider(); return new ExternalBaseUserProvider($config['model']);
}); });
// 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, string>> * @var array<class-string, array<int, class-string>>
*/ */
protected $listen = [ protected $listen = [
SocialiteWasCalled::class => [ SocialiteWasCalled::class => [

View File

@@ -4,8 +4,6 @@ namespace BookStack\App\Providers;
use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeEvents;
use BookStack\Theming\ThemeService; use BookStack\Theming\ThemeService;
use BookStack\Theming\ThemeViews;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class ThemeServiceProvider extends ServiceProvider class ThemeServiceProvider extends ServiceProvider
@@ -26,26 +24,7 @@ class ThemeServiceProvider extends ServiceProvider
{ {
// Boot up the theme system // Boot up the theme system
$themeService = $this->app->make(ThemeService::class); $themeService = $this->app->make(ThemeService::class);
$viewFactory = $this->app->make('view');
$themeViews = new ThemeViews($viewFactory->getFinder());
// Use a custom include so that we can insert theme views before/after includes.
// This is done, even if no theme is active, so that view caching does not create problems
// when switching between themes or when switching a theme on/off.
$viewFactory->share('__themeViews', $themeViews);
Blade::directive('include', function ($expression) {
return "<?php echo \$__themeViews->handleViewInclude({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>";
});
if (!$themeService->getTheme()) {
return;
}
$themeService->loadModules();
$themeService->readThemeActions(); $themeService->readThemeActions();
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app); $themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
$themeViews->registerViewPathsForTheme($themeService->getModules());
$themeService->dispatch(ThemeEvents::THEME_REGISTER_VIEWS, $themeViews);
} }
} }

View File

@@ -3,7 +3,6 @@
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;
@@ -11,15 +10,6 @@ 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.
*/ */
@@ -31,9 +21,6 @@ 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(); ?>";

18
app/App/Sluggable.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
namespace BookStack\App;
/**
* Assigned to models that can have slugs.
* Must have the below properties.
*
* @property int $id
* @property string $name
*/
interface Sluggable
{
/**
* Regenerate the slug for this model.
*/
public function refreshSlug(): string;
}

View File

@@ -1,13 +0,0 @@
<?php
namespace BookStack\App;
/**
* Assigned to models that can have slugs.
* Must have the below properties.
*
* @property string $slug
*/
interface SluggableInterface
{
}

View File

@@ -3,7 +3,6 @@
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;
@@ -40,7 +39,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 $permission, ?Model $ownable = null): bool function userCan(string $permission, ?Model $ownable = null): bool
{ {
if (is_null($ownable)) { if (is_null($ownable)) {
return user()->can($permission); return user()->can($permission);
@@ -56,7 +55,7 @@ function userCan(string|Permission $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|Permission $action, string $entityClass = ''): bool function userCanOnAny(string $action, string $entityClass = ''): bool
{ {
$permissions = app()->make(PermissionApplicator::class); $permissions = app()->make(PermissionApplicator::class);
@@ -81,7 +80,8 @@ function setting(?string $key = null, mixed $default = null): mixed
/** /**
* Get a path to a theme resource. * Get a path to a theme resource.
* Returns null if a theme is not configured, and therefore a full path is not available for use. * Returns null if a theme is not configured and
* therefore a full path is not available for use.
*/ */
function theme_path(string $path = ''): ?string function theme_path(string $path = ''): ?string
{ {

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 separated if multiple. Can use '*' as a wildcard. // Space seperated 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,10 +80,8 @@ 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 stored date/time values. // Application timezone for back-end date functions.
'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', 'bookstack_cache_'), 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache_'),
]; ];

View File

@@ -75,14 +75,13 @@ 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,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([ 'options' => extension_loaded('pdo_mysql') ? array_filter([
// @phpstan-ignore class.notFound PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [], ]) : [],
], ],
@@ -104,7 +103,9 @@ return [
], ],
// Migration Repository Table // Migration Repository Table
// This table keeps track of all the migrations that have already run for the application. // This table keeps track of all the migrations that have already run for
// your application. Using this information, we can determine which of
// the migrations on disk haven't actually been run in the database.
'migrations' => 'migrations', 'migrations' => 'migrations',
// Redis configuration to use if set // Redis configuration to use if set

View File

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

View File

@@ -11,7 +11,6 @@
// Configured mail encryption method. // Configured mail encryption method.
// STARTTLS should still be attempted, but tls/ssl forces TLS usage. // STARTTLS should still be attempted, but tls/ssl forces TLS usage.
$mailEncryption = env('MAIL_ENCRYPTION', null); $mailEncryption = env('MAIL_ENCRYPTION', null);
$mailPort = intval(env('MAIL_PORT', 587));
return [ return [
@@ -34,13 +33,13 @@ return [
'transport' => 'smtp', 'transport' => 'smtp',
'scheme' => null, 'scheme' => null,
'host' => env('MAIL_HOST', 'smtp.mailgun.org'), 'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
'port' => $mailPort, 'port' => env('MAIL_PORT', 587),
'username' => env('MAIL_USERNAME'), 'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'), 'password' => env('MAIL_PASSWORD'),
'verify_peer' => env('MAIL_VERIFY_SSL', true), 'verify_peer' => env('MAIL_VERIFY_SSL', true),
'timeout' => null, 'timeout' => null,
'local_domain' => null, 'local_domain' => null,
'require_tls' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl' || $mailPort === 465), 'tls_required' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl'),
], ],
'sendmail' => [ 'sendmail' => [

View File

@@ -41,7 +41,6 @@ return [
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'), 'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'), 'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'), 'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'),
'notifications#comment-mentions' => true,
], ],
]; ];

View File

@@ -8,6 +8,12 @@
* Do not edit this file unless you're happy to maintain any changes yourself. * Do not edit this file unless you're happy to maintain any changes yourself.
*/ */
// Join up possible view locations
$viewPaths = [realpath(base_path('resources/views'))];
if ($theme = env('APP_THEME', false)) {
array_unshift($viewPaths, base_path('themes/' . $theme));
}
return [ return [
// App theme // App theme
@@ -20,7 +26,7 @@ return [
// Most templating systems load templates from disk. Here you may specify // Most templating systems load templates from disk. Here you may specify
// an array of paths that should be checked for your views. Of course // an array of paths that should be checked for your views. Of course
// the usual Laravel view path has already been registered for you. // the usual Laravel view path has already been registered for you.
'paths' => [realpath(base_path('resources/views'))], 'paths' => $viewPaths,
// Compiled View Path // Compiled View Path
// This option determines where all the compiled Blade templates will be // This option determines where all the compiled Blade templates will be

View File

@@ -8,6 +8,7 @@ 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
{ {
@@ -20,9 +21,7 @@ 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.
@@ -36,12 +35,26 @@ class CreateAdminCommand extends Command
*/ */
public function handle(UserRepo $userRepo): int public function handle(UserRepo $userRepo): int
{ {
$initialAdminOnly = $this->option('initial'); $details = $this->snakeCaseOptions();
$shouldGeneratePassword = $this->option('generate-password');
$details = $this->gatherDetails($shouldGeneratePassword, $initialAdminOnly); if (empty($details['email'])) {
$details['email'] = $this->ask('Please specify an email address for the new admin user');
}
if (empty($details['name'])) {
$details['name'] = $this->ask('Please specify a name for the new admin user');
}
if (empty($details['password'])) {
if (empty($details['external_auth_id'])) {
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
} else {
$details['password'] = Str::random(32);
}
}
$validator = Validator::make($details, [ $validator = Validator::make($details, [
'email' => ['required', 'email', 'min:5'], 'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
'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'],
@@ -55,101 +68,16 @@ 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($adminRole); $user->attachRole(Role::getSystemRole('admin'));
$user->email_confirmed = true; $user->email_confirmed = true;
$user->save(); $user->save();
if ($shouldGeneratePassword) { $this->info("Admin account with email \"{$user->email}\" successfully created!");
$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

@@ -1,305 +0,0 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeModule;
use BookStack\Theming\ThemeModuleException;
use BookStack\Theming\ThemeModuleManager;
use BookStack\Theming\ThemeModuleZip;
use GuzzleHttp\Psr7\Request;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class InstallModuleCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:install-module
{location : The URL or path of the module file}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Install a module to the currently configured theme';
protected array $cleanupActions = [];
/**
* Execute the console command.
*/
public function handle(): int
{
$location = $this->argument('location');
// Get the ZIP file containing the module files
$zipPath = $this->getPathToZip($location);
if (!$zipPath) {
$this->cleanup();
return 1;
}
// Validate module zip file (metadata, size, etc...) and get module instance
$zip = new ThemeModuleZip($zipPath);
$themeModule = $this->validateAndGetModuleInfoFromZip($zip);
if (!$themeModule) {
$this->cleanup();
return 1;
}
// Get the theme folder in use, attempting to create one if no active theme in use
$themeFolder = $this->getThemeFolder();
if (!$themeFolder) {
$this->cleanup();
return 1;
}
// Get the modules folder of the theme, attempting to create it if not existing,
// and create a new module manager instance.
$moduleFolder = $this->getModuleFolder($themeFolder);
if (!$moduleFolder) {
$this->cleanup();
return 1;
}
$manager = new ThemeModuleManager($moduleFolder);
// Handle existing modules with the same name
$exitingModulesWithName = $manager->getByName($themeModule->name);
$shouldContinue = $this->handleExistingModulesWithSameName($exitingModulesWithName, $manager);
if (!$shouldContinue) {
$this->cleanup();
return 1;
}
// Extract module ZIP into the theme modules folder
try {
$newModule = $manager->addFromZip($themeModule->name, $zip);
} catch (ThemeModuleException $exception) {
$this->error("ERROR: Failed to install module with error: {$exception->getMessage()}");
$this->cleanup();
return 1;
}
$this->info("Module \"{$newModule->name}\" ({$newModule->getVersion()}) successfully installed!");
$this->info("Install location: {$moduleFolder}/{$newModule->folderName}");
$this->cleanup();
return 0;
}
/**
* @param ThemeModule[] $existingModules
*/
protected function handleExistingModulesWithSameName(array $existingModules, ThemeModuleManager $manager): bool
{
if (count($existingModules) === 0) {
return true;
}
$this->warn("The following modules already exist with the same name:");
foreach ($existingModules as $folder => $module) {
$this->line("{$module->name} ({$folder}:{$module->getVersion()}) - {$module->description}");
}
$this->line('');
$choices = ['Cancel module install', 'Add alongside existing module'];
if (count($existingModules) === 1) {
$choices[] = 'Replace existing module';
}
$choice = $this->choice("What would you like to do?", $choices, 0, null, false);
if ($choice === 'Cancel module install') {
return false;
}
if ($choice === 'Replace existing module') {
$existingModuleFolder = array_key_first($existingModules);
$this->info("Replacing existing module in {$existingModuleFolder} folder");
$manager->deleteModuleFolder($existingModuleFolder);
}
return true;
}
protected function getModuleFolder(string $themeFolder): string|null
{
$path = $themeFolder . DIRECTORY_SEPARATOR . 'modules';
if (file_exists($path) && !is_dir($path)) {
$this->error("ERROR: Cannot create a modules folder, file already exists at {$path}");
return null;
}
if (!file_exists($path)) {
$created = mkdir($path, 0755, true);
if (!$created) {
$this->error("ERROR: Failed to create a modules folder at {$path}");
return null;
}
}
return $path;
}
protected function getThemeFolder(): string|null
{
$path = theme_path('');
if (!$path || !is_dir($path)) {
$shouldCreate = $this->confirm('No active theme folder found, would you like to create one?');
if (!$shouldCreate) {
return null;
}
$folder = 'custom';
while (file_exists(base_path("themes" . DIRECTORY_SEPARATOR . $folder))) {
$folder = 'custom-' . Str::random(4);
}
$path = base_path("themes/{$folder}");
$created = mkdir($path, 0755, true);
if (!$created) {
$this->error('Failed to create a theme folder to use. This may be a permissions issue. Try manually configuring an active theme');
return null;
}
$this->info("Created theme folder at {$path}");
$this->warn("You will need to set APP_THEME={$folder} in your BookStack env configuration to enable this theme!");
}
return $path;
}
protected function validateAndGetModuleInfoFromZip(ThemeModuleZip $zip): ThemeModule|null
{
if (!$zip->exists()) {
$this->error("ERROR: Cannot open ZIP file at {$zip->getPath()}");
return null;
}
if ($zip->getContentsSize() > (50 * 1024 * 1024)) {
$this->error("ERROR: Module ZIP file contents are too large. Maximum size is 50MB");
return null;
}
try {
$themeModule = $zip->getModuleInstance();
} catch (ThemeModuleException $exception) {
$this->error("ERROR: Failed to read module metadata with error: {$exception->getMessage()}");
return null;
}
return $themeModule;
}
protected function downloadModuleFile(string $location): string|null
{
$httpRequests = app()->make(HttpRequestService::class);
$client = $httpRequests->buildClient(30, ['stream' => true]);
$originalUrl = parse_url($location);
$currentLocation = $location;
$maxRedirects = 3;
$redirectCount = 0;
// Follow redirects up to 3 times for the same hostname
do {
$resp = $client->sendRequest(new Request('GET', $currentLocation));
$statusCode = $resp->getStatusCode();
if ($statusCode >= 300 && $statusCode < 400 && $redirectCount < $maxRedirects) {
$redirectLocation = $resp->getHeaderLine('Location');
if ($redirectLocation) {
$redirectUrl = parse_url($redirectLocation);
if (
($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '')
&& ($originalUrl['scheme'] ?? '') === ($redirectUrl['scheme'] ?? '')
&& ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? '')
) {
$currentLocation = $redirectLocation;
$redirectCount++;
continue;
}
}
}
break;
} while (true);
if ($resp->getStatusCode() >= 300) {
$this->error("ERROR: Failed to download module from {$location}");
$this->error("Download failed with status code {$resp->getStatusCode()}");
return null;
}
$tempFile = tempnam(sys_get_temp_dir(), 'bookstack_module_');
$fileHandle = fopen($tempFile, 'w');
$respBody = $resp->getBody();
$size = 0;
$maxSize = 50 * 1024 * 1024;
while (!$respBody->eof()) {
fwrite($fileHandle, $respBody->read(1024));
$size += 1024;
if ($size > $maxSize) {
fclose($fileHandle);
unlink($tempFile);
$this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB");
return '';
}
}
fclose($fileHandle);
$this->cleanupActions[] = function () use ($tempFile) {
unlink($tempFile);
};
return $tempFile;
}
protected function getPathToZip(string $location): string|null
{
$lowerLocation = strtolower($location);
$isRemote = str_starts_with($lowerLocation, 'http://') || str_starts_with($lowerLocation, 'https://');
if ($isRemote) {
// Warning about fetching from source
$host = parse_url($location, PHP_URL_HOST);
$this->warn("This will download a module from {$host}. Modules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.");
$trustHost = $this->confirm('Are you sure you trust this source?');
if (!$trustHost) {
return null;
}
// Check if the connection is http. If so, warn the user.
if (str_starts_with($lowerLocation, 'http://')) {
$this->warn("You are downloading a module from an insecure HTTP source.\nWe recommend only using HTTPS sources to avoid various security risks.");
if (!$this->confirm('Are you sure you want to continue without HTTPS?')) {
return null;
}
}
// Download ZIP and get its location
return $this->downloadModuleFile($location);
}
// Validate file and get full location
$zipPath = realpath($location);
if (!$zipPath || !is_file($zipPath)) {
$this->error("ERROR: Module file not found at {$location}");
return null;
}
return $zipPath;
}
protected function cleanup(): void
{
foreach ($this->cleanupActions as $action) {
$action();
}
}
}

View File

@@ -45,12 +45,14 @@ class UpdateUrlCommand extends Command
$columnsToUpdateByTable = [ $columnsToUpdateByTable = [
'attachments' => ['path'], 'attachments' => ['path'],
'entity_page_data' => ['html', 'text', 'markdown'], 'pages' => ['html', 'text', 'markdown'],
'entity_container_data' => ['description_html'], 'chapters' => ['description_html'],
'books' => ['description_html'],
'bookshelves' => ['description_html'],
'page_revisions' => ['html', 'text', 'markdown'], 'page_revisions' => ['html', 'text', 'markdown'],
'images' => ['url'], 'images' => ['url'],
'settings' => ['value'], 'settings' => ['value'],
'comments' => ['html'], 'comments' => ['html', 'text'],
]; ];
foreach ($columnsToUpdateByTable as $table => $columns) { foreach ($columnsToUpdateByTable as $table => $columns) {

View File

@@ -11,7 +11,6 @@ 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;
@@ -48,7 +47,7 @@ class BookApiController extends ApiController
*/ */
public function create(Request $request) public function create(Request $request)
{ {
$this->checkPermission(Permission::BookCreateAll); $this->checkPermission('book-create-all');
$requestData = $this->validate($request, $this->rules()['create']); $requestData = $this->validate($request, $this->rules()['create']);
$book = $this->bookRepo->create($requestData); $book = $this->bookRepo->create($requestData);
@@ -58,7 +57,7 @@ class BookApiController extends ApiController
/** /**
* View the details of a single book. * View the details of a single book.
* The response data will contain a 'content' property listing the chapter and pages directly within, in * The response data will contain 'content' property listing the chapter and pages directly within, in
* the same structure as you'd see within the BookStack interface when viewing a book. Top-level * the same structure as you'd see within the BookStack interface when viewing a book. Top-level
* contents will have a 'type' property to distinguish between pages & chapters. * contents will have a 'type' property to distinguish between pages & chapters.
*/ */
@@ -93,7 +92,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(Permission::BookUpdate, $book); $this->checkOwnablePermission('book-update', $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);
@@ -110,7 +109,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(Permission::BookDelete, $book); $this->checkOwnablePermission('book-delete', $book);
$this->bookRepo->destroy($book); $this->bookRepo->destroy($book);
@@ -122,10 +121,9 @@ class BookApiController extends ApiController
$book = clone $book; $book = clone $book;
$book->unsetRelations()->refresh(); $book->unsetRelations()->refresh();
$book->load(['tags']); $book->load(['tags', 'cover']);
$book->makeVisible(['cover', 'description_html']) $book->makeVisible('description_html')
->setAttribute('description_html', $book->descriptionInfo()->getHtml()) ->setAttribute('description_html', $book->descriptionHtml());
->setAttribute('cover', $book->coverInfo()->getImage());
return $book; return $book;
} }

View File

@@ -8,7 +8,6 @@ use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries; use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner; use BookStack\Entities\Tools\Cloner;
@@ -18,9 +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\SimpleListOptions; use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -32,7 +29,6 @@ class BookController extends Controller
protected ShelfContext $shelfContext, protected ShelfContext $shelfContext,
protected BookRepo $bookRepo, protected BookRepo $bookRepo,
protected BookQueries $queries, protected BookQueries $queries,
protected EntityQueries $entityQueries,
protected BookshelfQueries $shelfQueries, protected BookshelfQueries $shelfQueries,
protected ReferenceFetcher $referenceFetcher, protected ReferenceFetcher $referenceFetcher,
) { ) {
@@ -52,7 +48,7 @@ class BookController extends Controller
$books = $this->queries->visibleForListWithCover() $books = $this->queries->visibleForListWithCover()
->orderBy($listOptions->getSort(), $listOptions->getOrder()) ->orderBy($listOptions->getSort(), $listOptions->getOrder())
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); ->paginate(18);
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false; $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
$popular = $this->queries->popularForList()->take(4)->get(); $popular = $this->queries->popularForList()->take(4)->get();
$new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get(); $new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get();
@@ -76,12 +72,12 @@ class BookController extends Controller
*/ */
public function create(?string $shelfSlug = null) public function create(?string $shelfSlug = null)
{ {
$this->checkPermission(Permission::BookCreateAll); $this->checkPermission('book-create-all');
$bookshelf = null; $bookshelf = null;
if ($shelfSlug !== null) { if ($shelfSlug !== null) {
$bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug); $bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
$this->checkOwnablePermission(Permission::BookshelfUpdate, $bookshelf); $this->checkOwnablePermission('bookshelf-update', $bookshelf);
} }
$this->setPageTitle(trans('entities.books_create')); $this->setPageTitle(trans('entities.books_create'));
@@ -99,7 +95,7 @@ class BookController extends Controller
*/ */
public function store(Request $request, ?string $shelfSlug = null) public function store(Request $request, ?string $shelfSlug = null)
{ {
$this->checkPermission(Permission::BookCreateAll); $this->checkPermission('book-create-all');
$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'],
@@ -111,7 +107,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(Permission::BookshelfUpdate, $bookshelf); $this->checkOwnablePermission('bookshelf-update', $bookshelf);
} }
$book = $this->bookRepo->create($validated); $book = $this->bookRepo->create($validated);
@@ -129,16 +125,7 @@ class BookController extends Controller
*/ */
public function show(Request $request, ActivityQueries $activities, string $slug) public function show(Request $request, ActivityQueries $activities, string $slug)
{ {
try { $book = $this->queries->findVisibleBySlugOrFail($slug);
$book = $this->queries->findVisibleBySlugOrFail($slug);
} catch (NotFoundException $exception) {
$book = $this->entityQueries->findVisibleByOldSlugs('book', $slug);
if (is_null($book)) {
throw $exception;
}
return redirect($book->getUrl());
}
$bookChildren = (new BookContents($book))->getTree(true); $bookChildren = (new BookContents($book))->getTree(true);
$bookParentShelves = $book->shelves()->scopes('visible')->get(); $bookParentShelves = $book->shelves()->scopes('visible')->get();
@@ -166,7 +153,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(Permission::BookUpdate, $book); $this->checkOwnablePermission('book-update', $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]);
@@ -182,7 +169,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(Permission::BookUpdate, $book); $this->checkOwnablePermission('book-update', $book);
$validated = $this->validate($request, [ $validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
@@ -209,7 +196,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(Permission::BookDelete, $book); $this->checkOwnablePermission('book-delete', $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]);
@@ -223,7 +210,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(Permission::BookDelete, $book); $this->checkOwnablePermission('book-delete', $book);
$this->bookRepo->destroy($book); $this->bookRepo->destroy($book);
@@ -238,7 +225,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(Permission::BookView, $book); $this->checkOwnablePermission('book-view', $book);
session()->flashInput(['name' => $book->name]); session()->flashInput(['name' => $book->name]);
@@ -255,8 +242,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(Permission::BookView, $book); $this->checkOwnablePermission('book-view', $book);
$this->checkPermission(Permission::BookCreateAll); $this->checkPermission('book-create-all');
$newName = $request->get('name') ?: $book->name; $newName = $request->get('name') ?: $book->name;
$bookCopy = $cloner->cloneBook($book, $newName); $bookCopy = $cloner->cloneBook($book, $newName);
@@ -271,14 +258,12 @@ 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(Permission::BookUpdate, $book); $this->checkOwnablePermission('book-update', $book);
$this->checkOwnablePermission(Permission::BookDelete, $book); $this->checkOwnablePermission('book-delete', $book);
$this->checkPermission(Permission::BookshelfCreateAll); $this->checkPermission('bookshelf-create-all');
$this->checkPermission(Permission::BookCreateAll); $this->checkPermission('book-create-all');
$shelf = (new DatabaseTransaction(function () use ($book, $transformer) { $shelf = $transformer->transformBookToShelf($book);
return $transformer->transformBookToShelf($book);
}))->run();
return redirect($shelf->getUrl()); return redirect($shelf->getUrl());
} }

View File

@@ -6,7 +6,6 @@ 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;
@@ -46,7 +45,7 @@ class BookshelfApiController extends ApiController
*/ */
public function create(Request $request) public function create(Request $request)
{ {
$this->checkPermission(Permission::BookshelfCreateAll); $this->checkPermission('bookshelf-create-all');
$requestData = $this->validate($request, $this->rules()['create']); $requestData = $this->validate($request, $this->rules()['create']);
$bookIds = $request->get('books', []); $bookIds = $request->get('books', []);
@@ -85,7 +84,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(Permission::BookshelfUpdate, $shelf); $this->checkOwnablePermission('bookshelf-update', $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);
@@ -104,7 +103,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(Permission::BookshelfDelete, $shelf); $this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->bookshelfRepo->destroy($shelf); $this->bookshelfRepo->destroy($shelf);
@@ -116,10 +115,9 @@ class BookshelfApiController extends ApiController
$shelf = clone $shelf; $shelf = clone $shelf;
$shelf->unsetRelations()->refresh(); $shelf->unsetRelations()->refresh();
$shelf->load(['tags']); $shelf->load(['tags', 'cover']);
$shelf->makeVisible(['cover', 'description_html']) $shelf->makeVisible('description_html')
->setAttribute('description_html', $shelf->descriptionInfo()->getHtml()) ->setAttribute('description_html', $shelf->descriptionHtml());
->setAttribute('cover', $shelf->coverInfo()->getImage());
return $shelf; return $shelf;
} }

View File

@@ -6,13 +6,11 @@ use BookStack\Activity\ActivityQueries;
use BookStack\Activity\Models\View; use BookStack\Activity\Models\View;
use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries; use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Repos\BookshelfRepo; use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Entities\Tools\ShelfContext; 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;
@@ -24,7 +22,6 @@ class BookshelfController extends Controller
public function __construct( public function __construct(
protected BookshelfRepo $shelfRepo, protected BookshelfRepo $shelfRepo,
protected BookshelfQueries $queries, protected BookshelfQueries $queries,
protected EntityQueries $entityQueries,
protected BookQueries $bookQueries, protected BookQueries $bookQueries,
protected ShelfContext $shelfContext, protected ShelfContext $shelfContext,
protected ReferenceFetcher $referenceFetcher, protected ReferenceFetcher $referenceFetcher,
@@ -45,7 +42,7 @@ class BookshelfController extends Controller
$shelves = $this->queries->visibleForListWithCover() $shelves = $this->queries->visibleForListWithCover()
->orderBy($listOptions->getSort(), $listOptions->getOrder()) ->orderBy($listOptions->getSort(), $listOptions->getOrder())
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000)); ->paginate(18);
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false; $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
$popular = $this->queries->popularForList()->get(); $popular = $this->queries->popularForList()->get();
$new = $this->queries->visibleForList() $new = $this->queries->visibleForList()
@@ -71,7 +68,7 @@ class BookshelfController extends Controller
*/ */
public function create() public function create()
{ {
$this->checkPermission(Permission::BookshelfCreateAll); $this->checkPermission('bookshelf-create-all');
$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'));
@@ -86,7 +83,7 @@ class BookshelfController extends Controller
*/ */
public function store(Request $request) public function store(Request $request)
{ {
$this->checkPermission(Permission::BookshelfCreateAll); $this->checkPermission('bookshelf-create-all');
$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'],
@@ -107,17 +104,8 @@ class BookshelfController extends Controller
*/ */
public function show(Request $request, ActivityQueries $activities, string $slug) public function show(Request $request, ActivityQueries $activities, string $slug)
{ {
try { $shelf = $this->queries->findVisibleBySlugOrFail($slug);
$shelf = $this->queries->findVisibleBySlugOrFail($slug); $this->checkOwnablePermission('bookshelf-view', $shelf);
} catch (NotFoundException $exception) {
$shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug);
if (is_null($shelf)) {
throw $exception;
}
return redirect($shelf->getUrl());
}
$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'),
@@ -127,7 +115,6 @@ class BookshelfController extends Controller
]); ]);
$sort = $listOptions->getSort(); $sort = $listOptions->getSort();
$sortedVisibleShelfBooks = $shelf->visibleBooks() $sortedVisibleShelfBooks = $shelf->visibleBooks()
->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder()) ->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder())
->get() ->get()
@@ -156,7 +143,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(Permission::BookshelfUpdate, $shelf); $this->checkOwnablePermission('bookshelf-update', $shelf);
$shelfBookIds = $shelf->books()->get(['id'])->pluck('id'); $shelfBookIds = $shelf->books()->get(['id'])->pluck('id');
$books = $this->bookQueries->visibleForList() $books = $this->bookQueries->visibleForList()
@@ -182,7 +169,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(Permission::BookshelfUpdate, $shelf); $this->checkOwnablePermission('bookshelf-update', $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'],
@@ -208,7 +195,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(Permission::BookshelfDelete, $shelf); $this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()])); $this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()]));
@@ -223,7 +210,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(Permission::BookshelfDelete, $shelf); $this->checkOwnablePermission('bookshelf-delete', $shelf);
$this->shelfRepo->destroy($shelf); $this->shelfRepo->destroy($shelf);

View File

@@ -2,20 +2,19 @@
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\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class ChapterApiController extends ApiController class ChapterApiController extends ApiController
{ {
protected array $rules = [ protected $rules = [
'create' => [ 'create' => [
'book_id' => ['required', 'integer'], 'book_id' => ['required', 'integer'],
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
@@ -66,7 +65,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(Permission::ChapterCreate, $book); $this->checkOwnablePermission('chapter-create', $book);
$chapter = $this->chapterRepo->create($requestData, $book); $chapter = $this->chapterRepo->create($requestData, $book);
@@ -102,10 +101,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(Permission::ChapterUpdate, $chapter); $this->checkOwnablePermission('chapter-update', $chapter);
if ($request->has('book_id') && $chapter->book_id !== (intval($requestData['book_id']) ?: null)) { if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) {
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter); $this->checkOwnablePermission('chapter-delete', $chapter);
try { try {
$this->chapterRepo->move($chapter, "book:{$requestData['book_id']}"); $this->chapterRepo->move($chapter, "book:{$requestData['book_id']}");
@@ -130,7 +129,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(Permission::ChapterDelete, $chapter); $this->checkOwnablePermission('chapter-delete', $chapter);
$this->chapterRepo->destroy($chapter); $this->chapterRepo->destroy($chapter);
@@ -144,11 +143,8 @@ class ChapterApiController extends ApiController
$chapter->load(['tags']); $chapter->load(['tags']);
$chapter->makeVisible('description_html'); $chapter->makeVisible('description_html');
$chapter->setAttribute('description_html', $chapter->descriptionInfo()->getHtml()); $chapter->setAttribute('description_html', $chapter->descriptionHtml());
$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,9 +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 Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Throwable; use Throwable;
@@ -40,7 +38,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(Permission::ChapterCreate, $book); $this->checkOwnablePermission('chapter-create', $book);
$this->setPageTitle(trans('entities.chapters_create')); $this->setPageTitle(trans('entities.chapters_create'));
@@ -65,7 +63,7 @@ class ChapterController extends Controller
]); ]);
$book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug); $book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission(Permission::ChapterCreate, $book); $this->checkOwnablePermission('chapter-create', $book);
$chapter = $this->chapterRepo->create($validated, $book); $chapter = $this->chapterRepo->create($validated, $book);
@@ -77,15 +75,8 @@ class ChapterController extends Controller
*/ */
public function show(string $bookSlug, string $chapterSlug) public function show(string $bookSlug, string $chapterSlug)
{ {
try { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $this->checkOwnablePermission('chapter-view', $chapter);
} catch (NotFoundException $exception) {
$chapter = $this->entityQueries->findVisibleByOldSlugs('chapter', $chapterSlug, $bookSlug);
if (is_null($chapter)) {
throw $exception;
}
return redirect($chapter->getUrl());
}
$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();
@@ -114,7 +105,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(Permission::ChapterUpdate, $chapter); $this->checkOwnablePermission('chapter-update', $chapter);
$this->setPageTitle(trans('entities.chapters_edit_named', ['chapterName' => $chapter->getShortName()])); $this->setPageTitle(trans('entities.chapters_edit_named', ['chapterName' => $chapter->getShortName()]));
@@ -136,9 +127,9 @@ class ChapterController extends Controller
]); ]);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); $this->checkOwnablePermission('chapter-update', $chapter);
$chapter = $this->chapterRepo->update($chapter, $validated); $this->chapterRepo->update($chapter, $validated);
return redirect($chapter->getUrl()); return redirect($chapter->getUrl());
} }
@@ -151,7 +142,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(Permission::ChapterDelete, $chapter); $this->checkOwnablePermission('chapter-delete', $chapter);
$this->setPageTitle(trans('entities.chapters_delete_named', ['chapterName' => $chapter->getShortName()])); $this->setPageTitle(trans('entities.chapters_delete_named', ['chapterName' => $chapter->getShortName()]));
@@ -167,7 +158,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(Permission::ChapterDelete, $chapter); $this->checkOwnablePermission('chapter-delete', $chapter);
$this->chapterRepo->destroy($chapter); $this->chapterRepo->destroy($chapter);
@@ -183,8 +174,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(Permission::ChapterUpdate, $chapter); $this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter); $this->checkOwnablePermission('chapter-delete', $chapter);
return view('chapters.move', [ return view('chapters.move', [
'chapter' => $chapter, 'chapter' => $chapter,
@@ -200,8 +191,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(Permission::ChapterUpdate, $chapter); $this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter); $this->checkOwnablePermission('chapter-delete', $chapter);
$entitySelection = $request->get('entity_selection', null); $entitySelection = $request->get('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') { if ($entitySelection === null || $entitySelection === '') {
@@ -229,6 +220,7 @@ 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]);
@@ -247,6 +239,7 @@ 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();
@@ -257,7 +250,7 @@ class ChapterController extends Controller
return redirect($chapter->getUrl('/copy')); return redirect($chapter->getUrl('/copy'));
} }
$this->checkOwnablePermission(Permission::ChapterCreate, $newParentBook); $this->checkOwnablePermission('chapter-create', $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);
@@ -272,13 +265,11 @@ 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(Permission::ChapterUpdate, $chapter); $this->checkOwnablePermission('chapter-update', $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter); $this->checkOwnablePermission('chapter-delete', $chapter);
$this->checkPermission(Permission::BookCreateAll); $this->checkPermission('book-create-all');
$book = (new DatabaseTransaction(function () use ($chapter, $transformer) { $book = $transformer->transformChapterToBook($chapter);
return $transformer->transformChapterToBook($chapter);
}))->run();
return redirect($book->getUrl()); return redirect($book->getUrl());
} }

View File

@@ -2,19 +2,17 @@
namespace BookStack\Entities\Controllers; namespace BookStack\Entities\Controllers;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\PageRepo; use BookStack\Entities\Repos\PageRepo;
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;
class PageApiController extends ApiController class PageApiController extends ApiController
{ {
protected array $rules = [ protected $rules = [
'create' => [ 'create' => [
'book_id' => ['required_without:chapter_id', 'integer'], 'book_id' => ['required_without:chapter_id', 'integer'],
'chapter_id' => ['required_without:book_id', 'integer'], 'chapter_id' => ['required_without:book_id', 'integer'],
@@ -78,7 +76,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(Permission::PageCreate, $parent); $this->checkOwnablePermission('page-create', $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'])));
@@ -89,32 +87,21 @@ class PageApiController extends ApiController
/** /**
* View the details of a single page. * View the details of a single page.
* Pages will always have HTML content. They may have markdown content * Pages will always have HTML content. They may have markdown content
* if the Markdown editor was used to last update the page. * if the markdown editor was used to last update the page.
* *
* The 'html' property is the fully rendered and escaped HTML content that BookStack * The 'html' property is the fully rendered & escaped HTML content that BookStack
* would show on page view, with page includes handled. * would show on page view, with page includes handled.
* The 'raw_html' property is the direct database stored HTML content, which would be * The 'raw_html' property is the direct database stored HTML content, which would be
* what BookStack shows on page edit. * what BookStack shows on page edit.
* *
* See the "Content Security" section of these docs for security considerations when using * See the "Content Security" section of these docs for security considerations when using
* the page content returned from this endpoint. * the page content returned from this endpoint.
*
* Comments for the page are provided in a tree-structure representing the hierarchy of top-level
* comments and replies, for both archived and active comments.
*/ */
public function read(string $id) public function read(string $id)
{ {
$page = $this->queries->findVisibleByIdOrFail($id); $page = $this->queries->findVisibleByIdOrFail($id);
$page = $page->forJsonDisplay(); return response()->json($page->forJsonDisplay());
$commentTree = (new CommentTree($page));
$commentTree->loadVisibleHtml();
$page->setAttribute('comments', [
'active' => $commentTree->getActive(),
'archived' => $commentTree->getArchived(),
]);
return response()->json($page);
} }
/** /**
@@ -129,7 +116,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(Permission::PageUpdate, $page); $this->checkOwnablePermission('page-update', $page);
$parent = null; $parent = null;
if ($request->has('chapter_id')) { if ($request->has('chapter_id')) {
@@ -139,7 +126,7 @@ class PageApiController extends ApiController
} }
if ($parent && !$parent->matches($page->getParent())) { if ($parent && !$parent->matches($page->getParent())) {
$this->checkOwnablePermission(Permission::PageDelete, $page); $this->checkOwnablePermission('page-delete', $page);
try { try {
$this->pageRepo->move($page, $parent->getType() . ':' . $parent->id); $this->pageRepo->move($page, $parent->getType() . ':' . $parent->id);
@@ -164,7 +151,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(Permission::PageDelete, $page); $this->checkOwnablePermission('page-delete', $page);
$this->pageRepo->destroy($page); $this->pageRepo->destroy($page);

View File

@@ -17,9 +17,9 @@ use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditActivity; use BookStack\Entities\Tools\PageEditActivity;
use BookStack\Entities\Tools\PageEditorData; use BookStack\Entities\Tools\PageEditorData;
use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotFoundException;
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 +50,7 @@ class PageController extends Controller
$parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug); $parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
} }
$this->checkOwnablePermission(Permission::PageCreate, $parent); $this->checkOwnablePermission('page-create', $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 +82,7 @@ class PageController extends Controller
$parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug); $parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
} }
$this->checkOwnablePermission(Permission::PageCreate, $parent); $this->checkOwnablePermission('page-create', $parent);
$page = $this->pageRepo->getNewDraftPage($parent); $page = $this->pageRepo->getNewDraftPage($parent);
$this->pageRepo->publishDraft($page, [ $this->pageRepo->publishDraft($page, [
@@ -100,7 +100,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(Permission::PageCreate, $draft->getParent()); $this->checkOwnablePermission('page-create', $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'));
@@ -119,9 +119,8 @@ class PageController extends Controller
$this->validate($request, [ $this->validate($request, [
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
]); ]);
$draftPage = $this->queries->findVisibleByIdOrFail($pageId); $draftPage = $this->queries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent()); $this->checkOwnablePermission('page-create', $draftPage->getParent());
$page = $this->pageRepo->publishDraft($draftPage, $request->all()); $page = $this->pageRepo->publishDraft($draftPage, $request->all());
@@ -139,7 +138,9 @@ class PageController extends Controller
try { try {
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
} catch (NotFoundException $e) { } catch (NotFoundException $e) {
$page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug); $revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
$page = $revision->page ?? null;
if (is_null($page)) { if (is_null($page)) {
throw $e; throw $e;
} }
@@ -147,6 +148,8 @@ 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);
@@ -194,7 +197,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(Permission::PageUpdate, $page, $page->getUrl()); $this->checkOwnablePermission('page-update', $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()) {
@@ -218,7 +221,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(Permission::PageUpdate, $page); $this->checkOwnablePermission('page-update', $page);
$this->pageRepo->update($page, $request->all()); $this->pageRepo->update($page, $request->all());
@@ -233,7 +236,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(Permission::PageUpdate, $page); $this->checkOwnablePermission('page-update', $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);
@@ -270,7 +273,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(Permission::PageDelete, $page); $this->checkOwnablePermission('page-delete', $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 ||
@@ -292,7 +295,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(Permission::PageUpdate, $page); $this->checkOwnablePermission('page-update', $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 ||
@@ -315,7 +318,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(Permission::PageDelete, $page); $this->checkOwnablePermission('page-delete', $page);
$parent = $page->getParent(); $parent = $page->getParent();
$this->pageRepo->destroy($page); $this->pageRepo->destroy($page);
@@ -334,13 +337,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(Permission::PageUpdate, $page); $this->checkOwnablePermission('page-update', $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(Permission::ChapterView, $chapter)) { if ($chapter && userCan('view', $chapter)) {
return redirect($chapter->getUrl()); return redirect($chapter->getUrl());
} }
@@ -381,8 +384,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(Permission::PageUpdate, $page); $this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission(Permission::PageDelete, $page); $this->checkOwnablePermission('page-delete', $page);
return view('pages.move', [ return view('pages.move', [
'book' => $page->book, 'book' => $page->book,
@@ -399,8 +402,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(Permission::PageUpdate, $page); $this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission(Permission::PageDelete, $page); $this->checkOwnablePermission('page-delete', $page);
$entitySelection = $request->get('entity_selection', null); $entitySelection = $request->get('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') { if ($entitySelection === null || $entitySelection === '') {
@@ -428,6 +431,7 @@ 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', [
@@ -445,7 +449,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(Permission::PageView, $page); $this->checkOwnablePermission('page-view', $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();
@@ -456,7 +460,7 @@ class PageController extends Controller
return redirect($page->getUrl('/copy')); return redirect($page->getUrl('/copy'));
} }
$this->checkOwnablePermission(Permission::PageCreate, $newParent); $this->checkOwnablePermission('page-create', $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,7 +11,6 @@ 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;
@@ -99,7 +98,7 @@ class PageRevisionController extends Controller
throw new NotFoundException(); throw new NotFoundException();
} }
$prev = $revision->getPreviousRevision(); $prev = $revision->getPrevious();
$prevContent = $prev->html ?? ''; $prevContent = $prev->html ?? '';
$diff = Diff::excecute($prevContent, $revision->html); $diff = Diff::excecute($prevContent, $revision->html);
@@ -125,7 +124,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(Permission::PageUpdate, $page); $this->checkOwnablePermission('page-update', $page);
$page = $this->pageRepo->restoreRevision($page, $revisionId); $page = $this->pageRepo->restoreRevision($page, $revisionId);
@@ -140,7 +139,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(Permission::PageDelete, $page); $this->checkOwnablePermission('page-delete', $page);
$revision = $page->revisions()->where('id', '=', $revId)->first(); $revision = $page->revisions()->where('id', '=', $revId)->first();
if ($revision === null) { if ($revision === null) {

View File

@@ -6,20 +6,18 @@ 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 BookStack\Permissions\Permission; use Closure;
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(Permission::SettingsManage); $this->checkPermission('settings-manage');
$this->checkPermission(Permission::RestrictionsManageAll); $this->checkPermission('restrictions-manage-all');
return $next($request); return $next($request);
}); });
@@ -42,7 +40,7 @@ class RecycleBinApiController extends ApiController
'updated_at', 'updated_at',
'deletable_type', 'deletable_type',
'deletable_id', 'deletable_id',
], [$this->listFormatter(...)]); ], [Closure::fromCallable([$this, 'listFormatter'])]);
} }
/** /**
@@ -71,9 +69,10 @@ 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): void protected function listFormatter(Deletion $deletion)
{ {
$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();
@@ -82,19 +81,11 @@ class RecycleBinApiController extends ApiController
} }
if ($deletable instanceof Book || $deletable instanceof Chapter) { if ($deletable instanceof Book || $deletable instanceof Chapter) {
$countsToLoad = ['pages' => static::withTrashedQuery(...)]; $countsToLoad = ['pages' => $withTrashedQuery];
if ($deletable instanceof Book) { if ($deletable instanceof Book) {
$countsToLoad['chapters'] = static::withTrashedQuery(...); $countsToLoad['chapters'] = $withTrashedQuery;
} }
$deletable->loadCount($countsToLoad); $deletable->loadCount($countsToLoad);
} }
} }
/**
* @param Builder<Chapter|Page> $query
*/
protected static function withTrashedQuery(Builder $query): void
{
$query->withTrashed();
}
} }

View File

@@ -8,7 +8,6 @@ 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
{ {
@@ -21,8 +20,8 @@ class RecycleBinController extends Controller
public function __construct() public function __construct()
{ {
$this->middleware(function ($request, $next) { $this->middleware(function ($request, $next) {
$this->checkPermission(Permission::SettingsManage); $this->checkPermission('settings-manage');
$this->checkPermission(Permission::RestrictionsManageAll); $this->checkPermission('restrictions-manage-all');
return $next($request); return $next($request);
}); });

View File

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

View File

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

View File

@@ -2,6 +2,8 @@
namespace BookStack\Entities\Models; namespace BookStack\Entities\Models;
use BookStack\References\ReferenceUpdater;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
@@ -16,10 +18,34 @@ abstract class BookChild extends Entity
{ {
/** /**
* Get the book this page sits in. * Get the book this page sits in.
* @return BelongsTo<Book, $this>
*/ */
public function book(): BelongsTo public function book(): BelongsTo
{ {
return $this->belongsTo(Book::class)->withTrashed(); return $this->belongsTo(Book::class)->withTrashed();
} }
/**
* Change the book that this entity belongs to.
*/
public function changeBook(int $newBookId): Entity
{
$oldUrl = $this->getUrl();
$this->book_id = $newBookId;
$this->refreshSlug();
$this->save();
$this->refresh();
if ($oldUrl !== $this->getUrl()) {
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
}
// Update all child pages if a chapter
if ($this instanceof Chapter) {
foreach ($this->pages()->withTrashed()->get() as $page) {
$page->changeBook($newBookId);
}
}
return $this;
}
} }

View File

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

View File

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

View File

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

View File

@@ -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 DeletableInterface interface Deletable
{ {
public function deletions(): MorphMany; public function deletions(): MorphMany;
} }

View File

@@ -4,7 +4,6 @@ namespace BookStack\Entities\Models;
use BookStack\Activity\Models\Loggable; use BookStack\Activity\Models\Loggable;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -14,12 +13,10 @@ 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 DeletableInterface $deletable * @property Deletable $deletable
*/ */
class Deletion extends Model implements Loggable class Deletion extends Model implements Loggable
{ {
use HasFactory;
protected $hidden = []; protected $hidden = [];
/** /**

View File

@@ -12,7 +12,8 @@ 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\SluggableInterface; use BookStack\App\Sluggable;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Permissions\JointPermissionBuilder; use BookStack\Permissions\JointPermissionBuilder;
use BookStack\Permissions\Models\EntityPermission; use BookStack\Permissions\Models\EntityPermission;
use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\Models\JointPermission;
@@ -21,47 +22,37 @@ 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\OwnableInterface; use BookStack\Users\Models\HasOwner;
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\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
/** /**
* Class Entity * Class Entity
* The base class for book-like items such as pages, chapters and books. * The base class for book-like items such as pages, chapters & books.
* This is not a database model in itself but extended. * This is not a database model in itself but extended.
* *
* @property int $id * @property int $id
* @property string $type
* @property string $name * @property string $name
* @property string $slug * @property string $slug
* @property Carbon $created_at * @property Carbon $created_at
* @property Carbon $updated_at * @property Carbon $updated_at
* @property Carbon $deleted_at * @property Carbon $deleted_at
* @property int|null $created_by * @property int $created_by
* @property int|null $updated_by * @property int $updated_by
* @property int|null $owned_by
* @property 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 abstract class Entity extends Model implements Sluggable, Favouritable, Viewable, Deletable, Loggable
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
@@ -78,72 +69,6 @@ abstract class Entity extends Model implements
*/ */
public float $searchFactor = 1.0; public float $searchFactor = 1.0;
/**
* Set the table to be that used by all entities.
*/
protected $table = 'entities';
/**
* Set a custom query builder for entities.
*/
protected static string $builder = EntityQueryBuilder::class;
public static array $commonFields = [
'id',
'type',
'name',
'slug',
'book_id',
'chapter_id',
'priority',
'created_at',
'updated_at',
'deleted_at',
'created_by',
'updated_by',
'owned_by',
];
/**
* Override the save method to also save the contents for convenience.
*/
public function save(array $options = []): bool
{
/** @var EntityPageData|EntityContainerData $contents */
$contents = $this->relatedData()->firstOrNew();
$contentFields = $this->getContentsAttributes();
foreach ($contentFields as $key => $value) {
$contents->setAttribute($key, $value);
unset($this->attributes[$key]);
}
$this->setAttribute('type', $this->getMorphClass());
$result = parent::save($options);
$contentsResult = true;
if ($result && $contents->isDirty()) {
$contentsFillData = $contents instanceof EntityPageData ? ['page_id' => $this->id] : ['entity_id' => $this->id, 'entity_type' => $this->getMorphClass()];
$contents->forceFill($contentsFillData);
$contentsResult = $contents->save();
$this->touch();
}
$this->forceFill($contentFields);
return $result && $contentsResult;
}
/**
* Check if this item is a container item.
*/
public function isContainer(): bool
{
return $this instanceof Bookshelf ||
$this instanceof Book ||
$this instanceof Chapter;
}
/** /**
* Get the entities that are visible to the current user. * Get the entities that are visible to the current user.
*/ */
@@ -158,8 +83,8 @@ abstract class Entity extends Model implements
public function scopeWithLastView(Builder $query) public function scopeWithLastView(Builder $query)
{ {
$viewedAtQuery = View::query()->select('updated_at') $viewedAtQuery = View::query()->select('updated_at')
->whereColumn('viewable_id', '=', 'entities.id') ->whereColumn('viewable_id', '=', $this->getTable() . '.id')
->whereColumn('viewable_type', '=', 'entities.type') ->where('viewable_type', '=', $this->getMorphClass())
->where('user_id', '=', user()->id) ->where('user_id', '=', user()->id)
->take(1); ->take(1);
@@ -169,12 +94,11 @@ abstract class Entity extends Model implements
/** /**
* Query scope to get the total view count of the entities. * Query scope to get the total view count of the entities.
*/ */
public function scopeWithViewCount(Builder $query): void public function scopeWithViewCount(Builder $query)
{ {
$viewCountQuery = View::query()->selectRaw('SUM(views) as view_count') $viewCountQuery = View::query()->selectRaw('SUM(views) as view_count')
->whereColumn('viewable_id', '=', 'entities.id') ->whereColumn('viewable_id', '=', $this->getTable() . '.id')
->whereColumn('viewable_type', '=', 'entities.type') ->where('viewable_type', '=', $this->getMorphClass())->take(1);
->take(1);
$query->addSelect(['view_count' => $viewCountQuery]); $query->addSelect(['view_count' => $viewCountQuery]);
} }
@@ -230,17 +154,15 @@ abstract class Entity extends Model implements
*/ */
public function tags(): MorphMany public function tags(): MorphMany
{ {
return $this->morphMany(Tag::class, 'entity') return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc');
->orderBy('order', 'asc');
} }
/** /**
* Get the comments for an entity. * Get the comments for an entity.
* @return MorphMany<Comment, $this>
*/ */
public function comments(bool $orderByCreated = true): MorphMany public function comments(bool $orderByCreated = true): MorphMany
{ {
$query = $this->morphMany(Comment::class, 'commentable'); $query = $this->morphMany(Comment::class, 'entity');
return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query; return $orderByCreated ? $query->orderBy('created_at', 'asc') : $query;
} }
@@ -254,7 +176,7 @@ abstract class Entity extends Model implements
} }
/** /**
* Get this entities assigned permissions. * Get this entities restrictions.
*/ */
public function permissions(): MorphMany public function permissions(): MorphMany
{ {
@@ -277,20 +199,6 @@ abstract class Entity extends Model implements
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.
*/ */
@@ -337,7 +245,7 @@ abstract class Entity extends Model implements
} }
/** /**
* Gets a limited-length version of the entity name. * Gets a limited-length version of the entities name.
*/ */
public function getShortName(int $length = 25): string public function getShortName(int $length = 25): string
{ {
@@ -375,14 +283,10 @@ abstract class Entity extends Model implements
public function getParent(): ?self public function getParent(): ?self
{ {
if ($this instanceof Page) { if ($this instanceof Page) {
/** @var BelongsTo<Chapter|Book, Page> $builder */ return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book()->withTrashed()->first();
$builder = $this->chapter_id ? $this->chapter() : $this->book();
return $builder->withTrashed()->first();
} }
if ($this instanceof Chapter) { if ($this instanceof Chapter) {
/** @var BelongsTo<Book, Page> $builder */ return $this->book()->withTrashed()->first();
$builder = $this->book();
return $builder->withTrashed()->first();
} }
return null; return null;
@@ -391,7 +295,7 @@ abstract class Entity extends Model implements
/** /**
* Rebuild the permissions for this entity. * Rebuild the permissions for this entity.
*/ */
public function rebuildPermissions(): void public function rebuildPermissions()
{ {
app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this); app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this);
} }
@@ -399,11 +303,21 @@ abstract class Entity extends Model implements
/** /**
* Index the current entity for search. * Index the current entity for search.
*/ */
public function indexForSearch(): void public function indexForSearch()
{ {
app()->make(SearchIndex::class)->indexEntity(clone $this); app()->make(SearchIndex::class)->indexEntity(clone $this);
} }
/**
* {@inheritdoc}
*/
public function refreshSlug(): string
{
$this->slug = app()->make(SlugGenerator::class)->generate($this);
return $this->slug;
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
@@ -430,14 +344,6 @@ abstract class Entity extends Model implements
return $this->morphMany(Watch::class, 'watchable'); return $this->morphMany(Watch::class, 'watchable');
} }
/**
* Get the related slug history for this entity.
*/
public function slugHistory(): MorphMany
{
return $this->morphMany(SlugHistory::class, 'sluggable');
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
@@ -445,40 +351,4 @@ abstract class Entity extends Model implements
{ {
return "({$this->id}) {$this->name}"; return "({$this->id}) {$this->name}";
} }
/**
* @return HasOne<covariant (EntityContainerData|EntityPageData), $this>
*/
abstract public function relatedData(): HasOne;
/**
* Get the attributes that are intended for the related contents model.
* @return array<string, mixed>
*/
protected function getContentsAttributes(): array
{
$contentFields = [];
$contentModel = $this instanceof Page ? EntityPageData::class : EntityContainerData::class;
foreach ($this->attributes as $key => $value) {
if (in_array($key, $contentModel::$fields)) {
$contentFields[$key] = $value;
}
}
return $contentFields;
}
/**
* Create a new instance for the given entity type.
*/
public static function instanceFromType(string $type): self
{
return match ($type) {
'page' => new Page(),
'chapter' => new Chapter(),
'book' => new Book(),
'bookshelf' => new Bookshelf(),
};
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ use BookStack\Activity\Models\Loggable;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
@@ -31,8 +30,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
*/ */
class PageRevision extends Model implements Loggable class PageRevision extends Model implements Loggable
{ {
use HasFactory;
protected $fillable = ['name', 'text', 'summary']; protected $fillable = ['name', 'text', 'summary'];
protected $hidden = ['html', 'markdown', 'text']; protected $hidden = ['html', 'markdown', 'text'];
@@ -63,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 getPreviousRevision(): ?PageRevision public function getPrevious(): ?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

@@ -1,28 +0,0 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\App\Model;
use BookStack\Permissions\Models\JointPermission;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property int $sluggable_id
* @property string $sluggable_type
* @property string $slug
* @property ?string $parent_slug
*/
class SlugHistory extends Model
{
use HasFactory;
protected $table = 'slug_history';
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'sluggable_id')
->whereColumn('joint_permissions.entity_type', '=', 'slug_history.sluggable_type');
}
}

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