Merge branch 'development' into release

This commit is contained in:
Dan Brown 2023-12-29 12:15:34 +00:00
commit e86a90967e
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
459 changed files with 7342 additions and 4499 deletions

View File

@ -273,6 +273,7 @@ OIDC_USER_TO_GROUPS=false
OIDC_GROUPS_CLAIM=groups
OIDC_REMOVE_FROM_GROUPS=false
OIDC_EXTERNAL_ID_CLAIM=sub
OIDC_END_SESSION_ENDPOINT=false
# Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option

View File

@ -177,7 +177,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish
arniom :: French
REMOVED_USER :: French; Dutch; Turkish;
REMOVED_USER :: French; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@ -371,3 +371,18 @@ LameeQS :: Latvian
Sorin T. (trimbitassorin) :: Romanian
poesty :: Chinese Simplified
balmag :: Hungarian
Antti-Jussi Nygård (ajnyga) :: Finnish
Eduard Ereza Martínez (Ereza) :: Catalan
Jabir Lang (amar.almrad) :: Arabic
Jaroslav Koblizek (foretix) :: Czech; French
Wiktor Adamczyk (adamczyk.wiktor) :: Polish
Abdulmajeed Alshuaibi (4Majeed) :: Arabic
NotSmartZakk :: Czech
HyoungMin Lee (ddokkaebi) :: Korean
Dasferco :: Chinese Simplified
Marcus Teräs (mteras) :: Finnish
Serkan Yardim (serkanzz) :: Turkish
Y (cnsr) :: Ukrainian
ZY ZV (vy0b0x) :: Chinese Simplified
diegobenitez :: Spanish
Marc Hagen (MarcHagen) :: Dutch

1
.gitignore vendored
View File

@ -30,3 +30,4 @@ webpack-stats.json
.DS_Store
phpstan.neon
esbuild-meta.json
.phpactor.json

View File

@ -9,11 +9,6 @@ use Illuminate\Support\Facades\Password;
class ForgotPasswordController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
@ -30,10 +25,6 @@ class ForgotPasswordController extends Controller
/**
* Send a reset link to the given user.
*
* @param \Illuminate\Http\Request $request
*
* @return \Illuminate\Http\RedirectResponse
*/
public function sendResetLinkEmail(Request $request)
{
@ -56,13 +47,13 @@ class ForgotPasswordController extends Controller
$message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
$this->showSuccessNotification($message);
return back()->with('status', trans($response));
return redirect('/password/email')->with('status', trans($response));
}
// If an error was returned by the password broker, we will get this message
// translated so we can notify a user of the problem. We'll redirect back
// to where the users came from so they can attempt this process again.
return back()->withErrors(
return redirect('/password/email')->withErrors(
['email' => trans($response)]
);
}

View File

@ -3,34 +3,26 @@
namespace BookStack\Access\Controllers;
use BookStack\Access\LoginService;
use BookStack\Access\SocialAuthService;
use BookStack\Access\SocialDriverManager;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Facades\Activity;
use BookStack\Http\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
use ThrottlesLogins;
protected SocialAuthService $socialAuthService;
protected LoginService $loginService;
/**
* Create a new controller instance.
*/
public function __construct(SocialAuthService $socialAuthService, LoginService $loginService)
{
public function __construct(
protected SocialDriverManager $socialDriverManager,
protected LoginService $loginService,
) {
$this->middleware('guest', ['only' => ['getLogin', 'login']]);
$this->middleware('guard:standard,ldap', ['only' => ['login']]);
$this->middleware('guard:standard,ldap,oidc', ['only' => ['logout']]);
$this->socialAuthService = $socialAuthService;
$this->loginService = $loginService;
}
/**
@ -38,7 +30,7 @@ class LoginController extends Controller
*/
public function getLogin(Request $request)
{
$socialDrivers = $this->socialAuthService->getActiveDrivers();
$socialDrivers = $this->socialDriverManager->getActive();
$authMethod = config('auth.method');
$preventInitiation = $request->get('prevent_auto_init') === 'true';
@ -52,7 +44,7 @@ class LoginController extends Controller
// Store the previous location for redirect after login
$this->updateIntendedFromPrevious();
if (!$preventInitiation && $this->shouldAutoInitiate()) {
if (!$preventInitiation && $this->loginService->shouldAutoInitiate()) {
return view('auth.login-initiate', [
'authMethod' => $authMethod,
]);
@ -101,15 +93,9 @@ class LoginController extends Controller
/**
* Logout user and perform subsequent redirect.
*/
public function logout(Request $request)
public function logout()
{
Auth::guard()->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
return redirect($redirectUri);
return redirect($this->loginService->logout());
}
/**
@ -200,7 +186,7 @@ class LoginController extends Controller
{
// Store the previous location for redirect after login
$previous = url()->previous('');
$isPreviousFromInstance = (strpos($previous, url('/')) === 0);
$isPreviousFromInstance = str_starts_with($previous, url('/'));
if (!$previous || !setting('app-public') || !$isPreviousFromInstance) {
return;
}
@ -211,23 +197,11 @@ class LoginController extends Controller
];
foreach ($ignorePrefixList as $ignorePrefix) {
if (strpos($previous, url($ignorePrefix)) === 0) {
if (str_starts_with($previous, url($ignorePrefix))) {
return;
}
}
redirect()->setIntendedUrl($previous);
}
/**
* Check if login auto-initiate should be valid based upon authentication config.
*/
protected function shouldAutoInitiate(): bool
{
$socialDrivers = $this->socialAuthService->getActiveDrivers();
$authMethod = config('auth.method');
$autoRedirect = config('auth.auto_initiate');
return $autoRedirect && count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
}
}

View File

@ -11,9 +11,6 @@ class OidcController extends Controller
{
protected OidcService $oidcService;
/**
* OpenIdController constructor.
*/
public function __construct(OidcService $oidcService)
{
$this->oidcService = $oidcService;
@ -63,4 +60,12 @@ class OidcController extends Controller
return redirect()->intended();
}
/**
* Log the user out then start the OIDC RP-initiated logout process.
*/
public function logout()
{
return redirect($this->oidcService->logout());
}
}

View File

@ -4,7 +4,7 @@ namespace BookStack\Access\Controllers;
use BookStack\Access\LoginService;
use BookStack\Access\RegistrationService;
use BookStack\Access\SocialAuthService;
use BookStack\Access\SocialDriverManager;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Http\Controller;
@ -15,7 +15,7 @@ use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller
{
protected SocialAuthService $socialAuthService;
protected SocialDriverManager $socialDriverManager;
protected RegistrationService $registrationService;
protected LoginService $loginService;
@ -23,14 +23,14 @@ class RegisterController extends Controller
* Create a new controller instance.
*/
public function __construct(
SocialAuthService $socialAuthService,
SocialDriverManager $socialDriverManager,
RegistrationService $registrationService,
LoginService $loginService
) {
$this->middleware('guest');
$this->middleware('guard:standard');
$this->socialAuthService = $socialAuthService;
$this->socialDriverManager = $socialDriverManager;
$this->registrationService = $registrationService;
$this->loginService = $loginService;
}
@ -43,7 +43,7 @@ class RegisterController extends Controller
public function getRegister()
{
$this->registrationService->ensureRegistrationAllowed();
$socialDrivers = $this->socialAuthService->getActiveDrivers();
$socialDrivers = $this->socialDriverManager->getActive();
return view('auth.register', [
'socialDrivers' => $socialDrivers,

View File

@ -66,7 +66,7 @@ class ResetPasswordController extends Controller
// redirect them back to where they came from with their error message.
return $response === Password::PASSWORD_RESET
? $this->sendResetResponse()
: $this->sendResetFailedResponse($request, $response);
: $this->sendResetFailedResponse($request, $response, $request->get('token'));
}
/**
@ -83,7 +83,7 @@ class ResetPasswordController extends Controller
/**
* Get the response for a failed password reset.
*/
protected function sendResetFailedResponse(Request $request, string $response): RedirectResponse
protected function sendResetFailedResponse(Request $request, string $response, string $token): RedirectResponse
{
// We show invalid users as invalid tokens as to not leak what
// users may exist in the system.
@ -91,7 +91,7 @@ class ResetPasswordController extends Controller
$response = Password::INVALID_TOKEN;
}
return redirect()->back()
return redirect("/password/reset/{$token}")
->withInput($request->only('email'))
->withErrors(['email' => trans($response)]);
}

View File

@ -9,14 +9,9 @@ use Illuminate\Support\Str;
class Saml2Controller extends Controller
{
protected Saml2Service $samlService;
/**
* Saml2Controller constructor.
*/
public function __construct(Saml2Service $samlService)
{
$this->samlService = $samlService;
public function __construct(
protected Saml2Service $samlService
) {
$this->middleware('guard:saml2');
}
@ -36,7 +31,12 @@ class Saml2Controller extends Controller
*/
public function logout()
{
$logoutDetails = $this->samlService->logout(auth()->user());
$user = user();
if ($user->isGuest()) {
return redirect('/login');
}
$logoutDetails = $this->samlService->logout($user);
if ($logoutDetails['id']) {
session()->flash('saml2_logout_request_id', $logoutDetails['id']);
@ -64,7 +64,7 @@ class Saml2Controller extends Controller
public function sls()
{
$requestId = session()->pull('saml2_logout_request_id', null);
$redirect = $this->samlService->processSlsResponse($requestId) ?? '/';
$redirect = $this->samlService->processSlsResponse($requestId);
return redirect($redirect);
}

View File

@ -79,7 +79,7 @@ class SocialController extends Controller
try {
return $this->socialAuthService->handleLoginCallback($socialDriver, $socialUser);
} catch (SocialSignInAccountNotUsed $exception) {
if ($this->socialAuthService->driverAutoRegisterEnabled($socialDriver)) {
if ($this->socialAuthService->drivers()->isAutoRegisterEnabled($socialDriver)) {
return $this->socialRegisterCallback($socialDriver, $socialUser);
}
@ -91,7 +91,7 @@ class SocialController extends Controller
return $this->socialRegisterCallback($socialDriver, $socialUser);
}
return redirect()->back();
return redirect('/');
}
/**
@ -114,7 +114,7 @@ class SocialController extends Controller
{
$socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser);
$socialAccount = $this->socialAuthService->newSocialAccount($socialDriver, $socialUser);
$emailVerified = $this->socialAuthService->driverAutoConfirmEmailEnabled($socialDriver);
$emailVerified = $this->socialAuthService->drivers()->isAutoConfirmEmailEnabled($socialDriver);
// Create an array of the user data to create a new user instance
$userData = [

View File

@ -16,13 +16,11 @@ class LoginService
{
protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted';
protected $mfaSession;
protected $emailConfirmationService;
public function __construct(MfaSession $mfaSession, EmailConfirmationService $emailConfirmationService)
{
$this->mfaSession = $mfaSession;
$this->emailConfirmationService = $emailConfirmationService;
public function __construct(
protected MfaSession $mfaSession,
protected EmailConfirmationService $emailConfirmationService,
protected SocialDriverManager $socialDriverManager,
) {
}
/**
@ -163,4 +161,33 @@ class LoginService
return $result;
}
/**
* Logs the current user out of the application.
* Returns an app post-redirect path.
*/
public function logout(): string
{
auth()->logout();
session()->invalidate();
session()->regenerateToken();
return $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
}
/**
* Check if login auto-initiate should be active based upon authentication config.
*/
public function shouldAutoInitiate(): bool
{
$autoRedirect = config('auth.auto_initiate');
if (!$autoRedirect) {
return false;
}
$socialDrivers = $this->socialDriverManager->getActive();
$authMethod = config('auth.method');
return count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
}
}

View File

@ -21,6 +21,7 @@ class OidcProviderSettings
public ?string $redirectUri;
public ?string $authorizationEndpoint;
public ?string $tokenEndpoint;
public ?string $endSessionEndpoint;
/**
* @var string[]|array[]
@ -132,6 +133,10 @@ class OidcProviderSettings
$discoveredSettings['keys'] = $this->filterKeys($keys);
}
if (!empty($result['end_session_endpoint'])) {
$discoveredSettings['endSessionEndpoint'] = $result['end_session_endpoint'];
}
return $discoveredSettings;
}

View File

@ -84,6 +84,7 @@ class OidcService
'redirectUri' => url('/oidc/callback'),
'authorizationEndpoint' => $config['authorization_endpoint'],
'tokenEndpoint' => $config['token_endpoint'],
'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null,
]);
// Use keys if configured
@ -100,6 +101,14 @@ class OidcService
}
}
// Prevent use of RP-initiated logout if specifically disabled
// Or force use of a URL if specifically set.
if ($config['end_session_endpoint'] === false) {
$settings->endSessionEndpoint = null;
} else if (is_string($config['end_session_endpoint'])) {
$settings->endSessionEndpoint = $config['end_session_endpoint'];
}
$settings->validate();
return $settings;
@ -217,6 +226,8 @@ class OidcService
$settings->keys,
);
session()->put("oidc_id_token", $idTokenText);
$returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [
'access_token' => $accessToken->getToken(),
'expires_in' => $accessToken->getExpires(),
@ -284,4 +295,30 @@ class OidcService
{
return $this->config()['user_to_groups'] !== false;
}
/**
* Start the RP-initiated logout flow if active, otherwise start a standard logout flow.
* Returns a post-app-logout redirect URL.
* Reference: https://openid.net/specs/openid-connect-rpinitiated-1_0.html
* @throws OidcException
*/
public function logout(): string
{
$oidcToken = session()->pull("oidc_id_token");
$defaultLogoutUrl = url($this->loginService->logout());
$oidcSettings = $this->getProviderSettings();
if (!$oidcSettings->endSessionEndpoint) {
return $defaultLogoutUrl;
}
$endpointParams = [
'id_token_hint' => $oidcToken,
'post_logout_redirect_uri' => $defaultLogoutUrl,
];
$joiner = str_contains($oidcSettings->endSessionEndpoint, '?') ? '&' : '?';
return $oidcSettings->endSessionEndpoint . $joiner . http_build_query($endpointParams);
}
}

View File

@ -21,19 +21,13 @@ use OneLogin\Saml2\ValidationError;
class Saml2Service
{
protected array $config;
protected RegistrationService $registrationService;
protected LoginService $loginService;
protected GroupSyncService $groupSyncService;
public function __construct(
RegistrationService $registrationService,
LoginService $loginService,
GroupSyncService $groupSyncService
protected RegistrationService $registrationService,
protected LoginService $loginService,
protected GroupSyncService $groupSyncService
) {
$this->config = config('saml2');
$this->registrationService = $registrationService;
$this->loginService = $loginService;
$this->groupSyncService = $groupSyncService;
}
/**
@ -54,20 +48,23 @@ class Saml2Service
/**
* Initiate a logout flow.
* Returns the SAML2 request ID, and the URL to redirect the user to.
*
* @throws Error
* @returns array{url: string, id: ?string}
*/
public function logout(User $user): array
{
$toolKit = $this->getToolkit();
$returnRoute = url('/');
$sessionIndex = session()->get('saml2_session_index');
$returnUrl = url($this->loginService->logout());
try {
$url = $toolKit->logout(
$returnRoute,
$returnUrl,
[],
$user->email,
session()->get('saml2_session_index'),
$sessionIndex,
true,
Constants::NAMEID_EMAIL_ADDRESS
);
@ -77,8 +74,7 @@ class Saml2Service
throw $error;
}
$this->actionLogout();
$url = '/';
$url = $returnUrl;
$id = null;
}
@ -128,7 +124,7 @@ class Saml2Service
*
* @throws Error
*/
public function processSlsResponse(?string $requestId): ?string
public function processSlsResponse(?string $requestId): string
{
$toolkit = $this->getToolkit();
@ -137,7 +133,7 @@ class Saml2Service
// value so that the exact encoding format is matched when checking the signature.
// This is primarily due to ADFS encoding query params with lowercase percent encoding while
// PHP (And most other sensible providers) standardise on uppercase.
$redirect = $toolkit->processSLO(true, $requestId, true, null, true);
$samlRedirect = $toolkit->processSLO(true, $requestId, true, null, true);
$errors = $toolkit->getErrors();
if (!empty($errors)) {
@ -146,18 +142,9 @@ class Saml2Service
);
}
$this->actionLogout();
$defaultBookStackRedirect = $this->loginService->logout();
return $redirect;
}
/**
* Do the required actions to log a user out.
*/
protected function actionLogout()
{
auth()->logout();
session()->invalidate();
return $samlRedirect ?? $defaultBookStackRedirect;
}
/**
@ -357,6 +344,10 @@ class Saml2Service
$userDetails = $this->getUserDetails($samlID, $samlAttributes);
$isLoggedIn = auth()->check();
if ($this->shouldSyncGroups()) {
$userDetails['groups'] = $this->getUserGroups($samlAttributes);
}
if ($this->config['dump_user_details']) {
throw new JsonDebugException([
'id_from_idp' => $samlID,
@ -379,13 +370,8 @@ class Saml2Service
$userDetails['external_id']
);
if ($user === null) {
throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
}
if ($this->shouldSyncGroups()) {
$groups = $this->getUserGroups($samlAttributes);
$this->groupSyncService->syncUserWithFoundGroups($user, $groups, $this->config['remove_from_groups']);
$this->groupSyncService->syncUserWithFoundGroups($user, $userDetails['groups'], $this->config['remove_from_groups']);
}
$this->loginService->login($user, 'saml2');

View File

@ -2,69 +2,24 @@
namespace BookStack\Access;
use BookStack\Auth\Access\handler;
use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\Factory as Socialite;
use Laravel\Socialite\Contracts\Provider;
use Laravel\Socialite\Contracts\User as SocialUser;
use Laravel\Socialite\Two\GoogleProvider;
use SocialiteProviders\Manager\SocialiteWasCalled;
use Symfony\Component\HttpFoundation\RedirectResponse;
class SocialAuthService
{
/**
* The core socialite library used.
*
* @var Socialite
*/
protected $socialite;
/**
* @var LoginService
*/
protected $loginService;
/**
* The default built-in social drivers we support.
*
* @var string[]
*/
protected $validSocialDrivers = [
'google',
'github',
'facebook',
'slack',
'twitter',
'azure',
'okta',
'gitlab',
'twitch',
'discord',
];
/**
* Callbacks to run when configuring a social driver
* for an initial redirect action.
* Array is keyed by social driver name.
* Callbacks are passed an instance of the driver.
*
* @var array<string, callable>
*/
protected $configureForRedirectCallbacks = [];
/**
* SocialAuthService constructor.
*/
public function __construct(Socialite $socialite, LoginService $loginService)
{
$this->socialite = $socialite;
$this->loginService = $loginService;
public function __construct(
protected Socialite $socialite,
protected LoginService $loginService,
protected SocialDriverManager $driverManager,
) {
}
/**
@ -74,9 +29,10 @@ class SocialAuthService
*/
public function startLogIn(string $socialDriver): RedirectResponse
{
$driver = $this->validateDriver($socialDriver);
$socialDriver = trim(strtolower($socialDriver));
$this->driverManager->ensureDriverActive($socialDriver);
return $this->getDriverForRedirect($driver)->redirect();
return $this->getDriverForRedirect($socialDriver)->redirect();
}
/**
@ -86,9 +42,10 @@ class SocialAuthService
*/
public function startRegister(string $socialDriver): RedirectResponse
{
$driver = $this->validateDriver($socialDriver);
$socialDriver = trim(strtolower($socialDriver));
$this->driverManager->ensureDriverActive($socialDriver);
return $this->getDriverForRedirect($driver)->redirect();
return $this->getDriverForRedirect($socialDriver)->redirect();
}
/**
@ -119,9 +76,10 @@ class SocialAuthService
*/
public function getSocialUser(string $socialDriver): SocialUser
{
$driver = $this->validateDriver($socialDriver);
$socialDriver = trim(strtolower($socialDriver));
$this->driverManager->ensureDriverActive($socialDriver);
return $this->socialite->driver($driver)->user();
return $this->socialite->driver($socialDriver)->user();
}
/**
@ -131,6 +89,7 @@ class SocialAuthService
*/
public function handleLoginCallback(string $socialDriver, SocialUser $socialUser)
{
$socialDriver = trim(strtolower($socialDriver));
$socialId = $socialUser->getId();
// Get any attached social accounts or users
@ -181,76 +140,11 @@ class SocialAuthService
}
/**
* Ensure the social driver is correct and supported.
*
* @throws SocialDriverNotConfigured
* Get the social driver manager used by this service.
*/
protected function validateDriver(string $socialDriver): string
public function drivers(): SocialDriverManager
{
$driver = trim(strtolower($socialDriver));
if (!in_array($driver, $this->validSocialDrivers)) {
abort(404, trans('errors.social_driver_not_found'));
}
if (!$this->checkDriverConfigured($driver)) {
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($socialDriver)]));
}
return $driver;
}
/**
* Check a social driver has been configured correctly.
*/
protected function checkDriverConfigured(string $driver): bool
{
$lowerName = strtolower($driver);
$configPrefix = 'services.' . $lowerName . '.';
$config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
return !in_array(false, $config) && !in_array(null, $config);
}
/**
* Gets the names of the active social drivers.
* @returns array<string, string>
*/
public function getActiveDrivers(): array
{
$activeDrivers = [];
foreach ($this->validSocialDrivers as $driverKey) {
if ($this->checkDriverConfigured($driverKey)) {
$activeDrivers[$driverKey] = $this->getDriverName($driverKey);
}
}
return $activeDrivers;
}
/**
* Get the presentational name for a driver.
*/
public function getDriverName(string $driver): string
{
return config('services.' . strtolower($driver) . '.name');
}
/**
* Check if the current config for the given driver allows auto-registration.
*/
public function driverAutoRegisterEnabled(string $driver): bool
{
return config('services.' . strtolower($driver) . '.auto_register') === true;
}
/**
* Check if the current config for the given driver allow email address auto-confirmation.
*/
public function driverAutoConfirmEmailEnabled(string $driver): bool
{
return config('services.' . strtolower($driver) . '.auto_confirm') === true;
return $this->driverManager;
}
/**
@ -284,33 +178,8 @@ class SocialAuthService
$driver->with(['prompt' => 'select_account']);
}
if (isset($this->configureForRedirectCallbacks[$driverName])) {
$this->configureForRedirectCallbacks[$driverName]($driver);
}
$this->driverManager->getConfigureForRedirectCallback($driverName)($driver);
return $driver;
}
/**
* Add a custom socialite driver to be used.
* Driver name should be lower_snake_case.
* Config array should mirror the structure of a service
* within the `Config/services.php` file.
* Handler should be a Class@method handler to the SocialiteWasCalled event.
*/
public function addSocialDriver(
string $driverName,
array $config,
string $socialiteHandler,
callable $configureForRedirect = null
) {
$this->validSocialDrivers[] = $driverName;
config()->set('services.' . $driverName, $config);
config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
Event::listen(SocialiteWasCalled::class, $socialiteHandler);
if (!is_null($configureForRedirect)) {
$this->configureForRedirectCallbacks[$driverName] = $configureForRedirect;
}
}
}

View File

@ -0,0 +1,147 @@
<?php
namespace BookStack\Access;
use BookStack\Exceptions\SocialDriverNotConfigured;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use SocialiteProviders\Manager\SocialiteWasCalled;
class SocialDriverManager
{
/**
* The default built-in social drivers we support.
*
* @var string[]
*/
protected array $validDrivers = [
'google',
'github',
'facebook',
'slack',
'twitter',
'azure',
'okta',
'gitlab',
'twitch',
'discord',
];
/**
* Callbacks to run when configuring a social driver
* for an initial redirect action.
* Array is keyed by social driver name.
* Callbacks are passed an instance of the driver.
*
* @var array<string, callable>
*/
protected array $configureForRedirectCallbacks = [];
/**
* Check if the current config for the given driver allows auto-registration.
*/
public function isAutoRegisterEnabled(string $driver): bool
{
return $this->getDriverConfigProperty($driver, 'auto_register') === true;
}
/**
* Check if the current config for the given driver allow email address auto-confirmation.
*/
public function isAutoConfirmEmailEnabled(string $driver): bool
{
return $this->getDriverConfigProperty($driver, 'auto_confirm') === true;
}
/**
* Gets the names of the active social drivers, keyed by driver id.
* @returns array<string, string>
*/
public function getActive(): array
{
$activeDrivers = [];
foreach ($this->validDrivers as $driverKey) {
if ($this->checkDriverConfigured($driverKey)) {
$activeDrivers[$driverKey] = $this->getName($driverKey);
}
}
return $activeDrivers;
}
/**
* Get the configure-for-redirect callback for the given driver.
* This is a callable that allows modification of the driver at redirect time.
* Commonly used to perform custom dynamic configuration where required.
* The callback is passed a \Laravel\Socialite\Contracts\Provider instance.
*/
public function getConfigureForRedirectCallback(string $driver): callable
{
return $this->configureForRedirectCallbacks[$driver] ?? (fn() => true);
}
/**
* Add a custom socialite driver to be used.
* Driver name should be lower_snake_case.
* Config array should mirror the structure of a service
* within the `Config/services.php` file.
* Handler should be a Class@method handler to the SocialiteWasCalled event.
*/
public function addSocialDriver(
string $driverName,
array $config,
string $socialiteHandler,
callable $configureForRedirect = null
) {
$this->validDrivers[] = $driverName;
config()->set('services.' . $driverName, $config);
config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
Event::listen(SocialiteWasCalled::class, $socialiteHandler);
if (!is_null($configureForRedirect)) {
$this->configureForRedirectCallbacks[$driverName] = $configureForRedirect;
}
}
/**
* Get the presentational name for a driver.
*/
protected function getName(string $driver): string
{
return $this->getDriverConfigProperty($driver, 'name') ?? '';
}
protected function getDriverConfigProperty(string $driver, string $property): mixed
{
return config("services.{$driver}.{$property}");
}
/**
* Ensure the social driver is correct and supported.
*
* @throws SocialDriverNotConfigured
*/
public function ensureDriverActive(string $driverName): void
{
if (!in_array($driverName, $this->validDrivers)) {
abort(404, trans('errors.social_driver_not_found'));
}
if (!$this->checkDriverConfigured($driverName)) {
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($driverName)]));
}
}
/**
* Check a social driver has been configured correctly.
*/
protected function checkDriverConfigured(string $driver): bool
{
$lowerName = strtolower($driver);
$configPrefix = 'services.' . $lowerName . '.';
$config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
return !in_array(false, $config) && !in_array(null, $config);
}
}

View File

@ -2,9 +2,6 @@
namespace BookStack\Activity\Controllers;
use BookStack\Activity\Models\Favouritable;
use BookStack\App\Model;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Queries\TopFavourites;
use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Http\Controller;
@ -52,7 +49,7 @@ class FavouriteController extends Controller
'name' => $entity->name,
]));
return redirect()->back();
return redirect($entity->getUrl());
}
/**
@ -70,6 +67,6 @@ class FavouriteController extends Controller
'name' => $entity->name,
]));
return redirect()->back();
return redirect($entity->getUrl());
}
}

View File

@ -24,6 +24,6 @@ class WatchController extends Controller
$this->showSuccessNotification(trans('activities.watch_update_level_notification'));
return redirect()->back();
return redirect($watchable->getUrl());
}
}

View File

@ -9,6 +9,7 @@ use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**

View File

@ -0,0 +1,29 @@
<?php
namespace BookStack\Activity\Notifications\MessageParts;
use BookStack\Entities\Models\Entity;
use Illuminate\Contracts\Support\Htmlable;
use Stringable;
/**
* A link to a specific entity in the system, with the text showing its name.
*/
class EntityLinkMessageLine implements Htmlable, Stringable
{
public function __construct(
protected Entity $entity,
protected int $nameLength = 120,
) {
}
public function toHtml(): string
{
return '<a href="' . e($this->entity->getUrl()) . '">' . e($this->entity->getShortName($this->nameLength)) . '</a>';
}
public function __toString(): string
{
return "{$this->entity->getShortName($this->nameLength)} ({$this->entity->getUrl()})";
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace BookStack\Activity\Notifications\MessageParts;
use BookStack\Entities\Models\Entity;
use Illuminate\Contracts\Support\Htmlable;
use Stringable;
/**
* A link to a specific entity in the system, with the text showing its name.
*/
class EntityPathMessageLine implements Htmlable, Stringable
{
/**
* @var EntityLinkMessageLine[]
*/
protected array $entityLinks;
public function __construct(
protected array $entities
) {
$this->entityLinks = array_map(fn (Entity $entity) => new EntityLinkMessageLine($entity, 24), $this->entities);
}
public function toHtml(): string
{
$entityHtmls = array_map(fn (EntityLinkMessageLine $line) => $line->toHtml(), $this->entityLinks);
return implode(' &gt; ', $entityHtmls);
}
public function __toString(): string
{
return implode(' > ', $this->entityLinks);
}
}

View File

@ -3,8 +3,12 @@
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\MessageParts\EntityPathMessageLine;
use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine;
use BookStack\App\MailNotification;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Translation\LocaleDefinition;
use BookStack\Users\Models\User;
use Illuminate\Bus\Queueable;
@ -44,4 +48,20 @@ abstract class BaseActivityNotification extends MailNotification
$locale->trans('notifications.footer_reason_link'),
);
}
/**
* Build a line which provides the book > chapter path to a page.
* Takes into account visibility of these parent items.
* Returns null if no path items can be used.
*/
protected function buildPagePathLine(Page $page, User $notifiable): ?EntityPathMessageLine
{
$permissions = new PermissionApplicator($notifiable);
$path = array_filter([$page->book, $page->chapter], function (?Entity $entity) use ($permissions) {
return !is_null($entity) && $permissions->checkOwnableUserAccess($entity, 'view');
});
return empty($path) ? null : new EntityPathMessageLine($path);
}
}

View File

@ -3,6 +3,7 @@
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;
@ -19,14 +20,17 @@ class CommentCreationNotification extends BaseActivityNotification
$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.new_comment_subject', ['pageName' => $page->getShortName()]))
->line($locale->trans('notifications.new_comment_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine([
$locale->trans('notifications.detail_page_name') => $page->name,
$locale->trans('notifications.detail_commenter') => $this->user->name,
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
]))
->line(new ListMessageLine($listLines))
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
->line($this->buildReasonFooterLine($locale));
}

View File

@ -2,6 +2,7 @@
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
@ -16,13 +17,16 @@ class PageCreationNotification extends BaseActivityNotification
$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_created_by') => $this->user->name,
]);
return $this->newMailMessage($locale)
->subject($locale->trans('notifications.new_page_subject', ['pageName' => $page->getShortName()]))
->line($locale->trans('notifications.new_page_intro', ['appName' => setting('app-name')], $locale))
->line(new ListMessageLine([
$locale->trans('notifications.detail_page_name') => $page->name,
$locale->trans('notifications.detail_created_by') => $this->user->name,
]))
->line($locale->trans('notifications.new_page_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine($listLines))
->action($locale->trans('notifications.action_view_page'), $page->getUrl())
->line($this->buildReasonFooterLine($locale));
}

View File

@ -2,6 +2,7 @@
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
@ -16,13 +17,16 @@ class PageUpdateNotification extends BaseActivityNotification
$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_updated_by') => $this->user->name,
]);
return $this->newMailMessage($locale)
->subject($locale->trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()]))
->line($locale->trans('notifications.updated_page_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine([
$locale->trans('notifications.detail_page_name') => $page->name,
$locale->trans('notifications.detail_updated_by') => $this->user->name,
]))
->line(new ListMessageLine($listLines))
->line($locale->trans('notifications.updated_page_debounce'))
->action($locale->trans('notifications.action_view_page'), $page->getUrl())
->line($this->buildReasonFooterLine($locale));

View File

@ -2,7 +2,7 @@
namespace BookStack\App\Providers;
use BookStack\Access\SocialAuthService;
use BookStack\Access\SocialDriverManager;
use BookStack\Activity\Tools\ActivityLogger;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
@ -36,7 +36,7 @@ class AppServiceProvider extends ServiceProvider
public $singletons = [
'activity' => ActivityLogger::class,
SettingService::class => SettingService::class,
SocialAuthService::class => SocialAuthService::class,
SocialDriverManager::class => SocialDriverManager::class,
CspService::class => CspService::class,
HttpRequestService::class => HttpRequestService::class,
];

View File

@ -2,9 +2,12 @@
namespace BookStack\App\Providers;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Http\Request;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
@ -46,8 +49,15 @@ class RouteServiceProvider extends ServiceProvider
Route::group([
'middleware' => 'web',
'namespace' => $this->namespace,
], function ($router) {
], function (Router $router) {
require base_path('routes/web.php');
Theme::dispatch(ThemeEvents::ROUTES_REGISTER_WEB, $router);
});
Route::group([
'middleware' => ['web', 'auth'],
], function (Router $router) {
Theme::dispatch(ThemeEvents::ROUTES_REGISTER_WEB_AUTH, $router);
});
}

View File

@ -4,6 +4,7 @@ namespace BookStack\App\Providers;
use BookStack\Theming\ThemeEvents;
use BookStack\Theming\ThemeService;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
class ThemeServiceProvider extends ServiceProvider

View File

@ -36,6 +36,12 @@ return [
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
// OIDC RP-Initiated Logout endpoint URL.
// A false value force-disables RP-Initiated Logout.
// A true value gets the URL from discovery, if active.
// A string value is used as the URL.
'end_session_endpoint' => env('OIDC_END_SESSION_ENDPOINT', false),
// Add extra scopes, upon those required, to the OIDC authentication request
// Multiple values can be provided comma seperated.
'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
@ -45,6 +51,6 @@ return [
'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
// Attribute, within a OIDC ID token, to find group names within
'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'),
// When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups.
// When syncing groups, remove any groups that no longer match. Otherwise, sync only adds new groups.
'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false),
];

View File

@ -34,7 +34,7 @@ class RegenerateReferencesCommand extends Command
DB::setDefaultConnection($this->option('database'));
}
$references->updateForAllPages();
$references->updateForAll();
DB::setDefaultConnection($connection);

View File

@ -46,6 +46,9 @@ class UpdateUrlCommand extends Command
$columnsToUpdateByTable = [
'attachments' => ['path'],
'pages' => ['html', 'text', 'markdown'],
'chapters' => ['description_html'],
'books' => ['description_html'],
'bookshelves' => ['description_html'],
'images' => ['url'],
'settings' => ['value'],
'comments' => ['html', 'text'],

View File

@ -14,11 +14,9 @@ use Illuminate\Validation\ValidationException;
class BookApiController extends ApiController
{
protected BookRepo $bookRepo;
public function __construct(BookRepo $bookRepo)
{
$this->bookRepo = $bookRepo;
public function __construct(
protected BookRepo $bookRepo
) {
}
/**
@ -47,7 +45,7 @@ class BookApiController extends ApiController
$book = $this->bookRepo->create($requestData);
return response()->json($book);
return response()->json($this->forJsonDisplay($book));
}
/**
@ -58,7 +56,9 @@ class BookApiController extends ApiController
*/
public function read(string $id)
{
$book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])->findOrFail($id);
$book = Book::visible()->findOrFail($id);
$book = $this->forJsonDisplay($book);
$book->load(['createdBy', 'updatedBy', 'ownedBy']);
$contents = (new BookContents($book))->getTree(true, false)->all();
$contentsApiData = (new ApiEntityListFormatter($contents))
@ -89,7 +89,7 @@ class BookApiController extends ApiController
$requestData = $this->validate($request, $this->rules()['update']);
$book = $this->bookRepo->update($book, $requestData);
return response()->json($book);
return response()->json($this->forJsonDisplay($book));
}
/**
@ -108,20 +108,36 @@ class BookApiController extends ApiController
return response('', 204);
}
protected function forJsonDisplay(Book $book): Book
{
$book = clone $book;
$book->unsetRelations()->refresh();
$book->load(['tags', 'cover']);
$book->makeVisible('description_html')
->setAttribute('description_html', $book->descriptionHtml());
return $book;
}
protected function rules(): array
{
return [
'create' => [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'default_template_id' => ['nullable', 'integer'],
],
'update' => [
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'default_template_id' => ['nullable', 'integer'],
],
];
}

View File

@ -24,15 +24,11 @@ use Throwable;
class BookController extends Controller
{
protected BookRepo $bookRepo;
protected ShelfContext $shelfContext;
protected ReferenceFetcher $referenceFetcher;
public function __construct(ShelfContext $entityContextManager, BookRepo $bookRepo, ReferenceFetcher $referenceFetcher)
{
$this->bookRepo = $bookRepo;
$this->shelfContext = $entityContextManager;
$this->referenceFetcher = $referenceFetcher;
public function __construct(
protected ShelfContext $shelfContext,
protected BookRepo $bookRepo,
protected ReferenceFetcher $referenceFetcher
) {
}
/**
@ -96,10 +92,11 @@ class BookController extends Controller
{
$this->checkPermission('book-create-all');
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
'default_template_id' => ['nullable', 'integer'],
]);
$bookshelf = null;
@ -141,7 +138,7 @@ class BookController extends Controller
'bookParentShelves' => $bookParentShelves,
'watchOptions' => new UserEntityWatchOptions(user(), $book),
'activity' => $activities->entityActivity($book, 20, 1),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($book),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($book),
]);
}
@ -170,10 +167,11 @@ class BookController extends Controller
$this->checkOwnablePermission('book-update', $book);
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
'default_template_id' => ['nullable', 'integer'],
]);
if ($request->has('image_reset')) {

View File

@ -12,11 +12,9 @@ use Illuminate\Validation\ValidationException;
class BookshelfApiController extends ApiController
{
protected BookshelfRepo $bookshelfRepo;
public function __construct(BookshelfRepo $bookshelfRepo)
{
$this->bookshelfRepo = $bookshelfRepo;
public function __construct(
protected BookshelfRepo $bookshelfRepo
) {
}
/**
@ -48,7 +46,7 @@ class BookshelfApiController extends ApiController
$bookIds = $request->get('books', []);
$shelf = $this->bookshelfRepo->create($requestData, $bookIds);
return response()->json($shelf);
return response()->json($this->forJsonDisplay($shelf));
}
/**
@ -56,12 +54,14 @@ class BookshelfApiController extends ApiController
*/
public function read(string $id)
{
$shelf = Bookshelf::visible()->with([
'tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy',
$shelf = Bookshelf::visible()->findOrFail($id);
$shelf = $this->forJsonDisplay($shelf);
$shelf->load([
'createdBy', 'updatedBy', 'ownedBy',
'books' => function (BelongsToMany $query) {
$query->scopes('visible')->get(['id', 'name', 'slug']);
},
])->findOrFail($id);
]);
return response()->json($shelf);
}
@ -86,7 +86,7 @@ class BookshelfApiController extends ApiController
$shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
return response()->json($shelf);
return response()->json($this->forJsonDisplay($shelf));
}
/**
@ -105,22 +105,36 @@ class BookshelfApiController extends ApiController
return response('', 204);
}
protected function forJsonDisplay(Bookshelf $shelf): Bookshelf
{
$shelf = clone $shelf;
$shelf->unsetRelations()->refresh();
$shelf->load(['tags', 'cover']);
$shelf->makeVisible('description_html')
->setAttribute('description_html', $shelf->descriptionHtml());
return $shelf;
}
protected function rules(): array
{
return [
'create' => [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'books' => ['array'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'books' => ['array'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
],
'update' => [
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'books' => ['array'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'books' => ['array'],
'tags' => ['array'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
],
];
}

View File

@ -18,15 +18,11 @@ use Illuminate\Validation\ValidationException;
class BookshelfController extends Controller
{
protected BookshelfRepo $shelfRepo;
protected ShelfContext $shelfContext;
protected ReferenceFetcher $referenceFetcher;
public function __construct(BookshelfRepo $shelfRepo, ShelfContext $shelfContext, ReferenceFetcher $referenceFetcher)
{
$this->shelfRepo = $shelfRepo;
$this->shelfContext = $shelfContext;
$this->referenceFetcher = $referenceFetcher;
public function __construct(
protected BookshelfRepo $shelfRepo,
protected ShelfContext $shelfContext,
protected ReferenceFetcher $referenceFetcher
) {
}
/**
@ -81,10 +77,10 @@ class BookshelfController extends Controller
{
$this->checkPermission('bookshelf-create-all');
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
]);
$bookIds = explode(',', $request->get('books', ''));
@ -129,7 +125,7 @@ class BookshelfController extends Controller
'view' => $view,
'activity' => $activities->entityActivity($shelf, 20, 1),
'listOptions' => $listOptions,
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($shelf),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($shelf),
]);
}
@ -164,10 +160,10 @@ class BookshelfController extends Controller
$shelf = $this->shelfRepo->getBySlug($slug);
$this->checkOwnablePermission('bookshelf-update', $shelf);
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
'tags' => ['array'],
]);
if ($request->has('image_reset')) {

View File

@ -15,18 +15,20 @@ class ChapterApiController extends ApiController
{
protected $rules = [
'create' => [
'book_id' => ['required', 'integer'],
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'priority' => ['integer'],
'book_id' => ['required', 'integer'],
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'priority' => ['integer'],
],
'update' => [
'book_id' => ['integer'],
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'tags' => ['array'],
'priority' => ['integer'],
'book_id' => ['integer'],
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1900'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
'priority' => ['integer'],
],
];
@ -61,7 +63,7 @@ class ChapterApiController extends ApiController
$chapter = $this->chapterRepo->create($requestData, $book);
return response()->json($chapter->load(['tags']));
return response()->json($this->forJsonDisplay($chapter));
}
/**
@ -69,9 +71,15 @@ class ChapterApiController extends ApiController
*/
public function read(string $id)
{
$chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'ownedBy', 'pages' => function (HasMany $query) {
$query->scopes('visible')->get(['id', 'name', 'slug']);
}])->findOrFail($id);
$chapter = Chapter::visible()->findOrFail($id);
$chapter = $this->forJsonDisplay($chapter);
$chapter->load([
'createdBy', 'updatedBy', 'ownedBy',
'pages' => function (HasMany $query) {
$query->scopes('visible')->get(['id', 'name', 'slug']);
}
]);
return response()->json($chapter);
}
@ -93,7 +101,7 @@ class ChapterApiController extends ApiController
try {
$this->chapterRepo->move($chapter, "book:{$requestData['book_id']}");
} catch (Exception $exception) {
if ($exception instanceof PermissionsException) {
if ($exception instanceof PermissionsException) {
$this->showPermissionError();
}
@ -103,7 +111,7 @@ class ChapterApiController extends ApiController
$updatedChapter = $this->chapterRepo->update($chapter, $requestData);
return response()->json($updatedChapter->load(['tags']));
return response()->json($this->forJsonDisplay($updatedChapter));
}
/**
@ -119,4 +127,16 @@ class ChapterApiController extends ApiController
return response('', 204);
}
protected function forJsonDisplay(Chapter $chapter): Chapter
{
$chapter = clone $chapter;
$chapter->unsetRelations()->refresh();
$chapter->load(['tags']);
$chapter->makeVisible('description_html')
->setAttribute('description_html', $chapter->descriptionHtml());
return $chapter;
}
}

View File

@ -12,6 +12,7 @@ use BookStack\Entities\Tools\HierarchyTransformer;
use BookStack\Entities\Tools\NextPreviousContentLocator;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\Controller;
use BookStack\References\ReferenceFetcher;
@ -21,13 +22,10 @@ use Throwable;
class ChapterController extends Controller
{
protected ChapterRepo $chapterRepo;
protected ReferenceFetcher $referenceFetcher;
public function __construct(ChapterRepo $chapterRepo, ReferenceFetcher $referenceFetcher)
{
$this->chapterRepo = $chapterRepo;
$this->referenceFetcher = $referenceFetcher;
public function __construct(
protected ChapterRepo $chapterRepo,
protected ReferenceFetcher $referenceFetcher
) {
}
/**
@ -50,14 +48,16 @@ class ChapterController extends Controller
*/
public function store(Request $request, string $bookSlug)
{
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
]);
$book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
$this->checkOwnablePermission('chapter-create', $book);
$chapter = $this->chapterRepo->create($request->all(), $book);
$chapter = $this->chapterRepo->create($validated, $book);
return redirect($chapter->getUrl());
}
@ -86,7 +86,7 @@ class ChapterController extends Controller
'pages' => $pages,
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($chapter),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($chapter),
]);
}
@ -110,10 +110,16 @@ class ChapterController extends Controller
*/
public function update(Request $request, string $bookSlug, string $chapterSlug)
{
$validated = $this->validate($request, [
'name' => ['required', 'string', 'max:255'],
'description_html' => ['string', 'max:2000'],
'tags' => ['array'],
]);
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
$this->checkOwnablePermission('chapter-update', $chapter);
$this->chapterRepo->update($chapter, $request->all());
$this->chapterRepo->update($chapter, $validated);
return redirect($chapter->getUrl());
}
@ -170,7 +176,7 @@ class ChapterController extends Controller
/**
* Perform the move action for a chapter.
*
* @throws NotFoundException
* @throws NotFoundException|NotifyException
*/
public function move(Request $request, string $bookSlug, string $chapterSlug)
{
@ -184,13 +190,13 @@ class ChapterController extends Controller
}
try {
$newBook = $this->chapterRepo->move($chapter, $entitySelection);
$this->chapterRepo->move($chapter, $entitySelection);
} catch (PermissionsException $exception) {
$this->showPermissionError();
} catch (MoveOperationException $exception) {
$this->showErrorNotification(trans('errors.selected_book_not_found'));
return redirect()->back();
return redirect($chapter->getUrl('/move'));
}
return redirect($chapter->getUrl());
@ -231,7 +237,7 @@ class ChapterController extends Controller
if (is_null($newParentBook)) {
$this->showErrorNotification(trans('errors.selected_book_not_found'));
return redirect()->back();
return redirect($chapter->getUrl('/copy'));
}
$this->checkOwnablePermission('chapter-create', $newParentBook);

View File

@ -5,6 +5,7 @@ namespace BookStack\Entities\Controllers;
use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\CommentTree;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Tools\BookContents;
@ -71,7 +72,6 @@ class PageController extends Controller
$page = $this->pageRepo->getNewDraftPage($parent);
$this->pageRepo->publishDraft($page, [
'name' => $request->get('name'),
'html' => '',
]);
return redirect($page->getUrl('/edit'));
@ -155,7 +155,7 @@ class PageController extends Controller
'watchOptions' => new UserEntityWatchOptions(user(), $page),
'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($page),
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($page),
]);
}
@ -259,11 +259,13 @@ class PageController extends Controller
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-delete', $page);
$this->setPageTitle(trans('entities.pages_delete_named', ['pageName' => $page->getShortName()]));
$usedAsTemplate = Book::query()->where('default_template_id', '=', $page->id)->count() > 0;
return view('pages.delete', [
'book' => $page->book,
'page' => $page,
'current' => $page,
'usedAsTemplate' => $usedAsTemplate,
]);
}
@ -277,11 +279,13 @@ class PageController extends Controller
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-update', $page);
$this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName' => $page->getShortName()]));
$usedAsTemplate = Book::query()->where('default_template_id', '=', $page->id)->count() > 0;
return view('pages.delete', [
'book' => $page->book,
'page' => $page,
'current' => $page,
'usedAsTemplate' => $usedAsTemplate,
]);
}
@ -391,7 +395,7 @@ class PageController extends Controller
} catch (Exception $exception) {
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
return redirect()->back();
return redirect($page->getUrl('/move'));
}
return redirect($page->getUrl());
@ -431,7 +435,7 @@ class PageController extends Controller
if (is_null($newParent)) {
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
return redirect()->back();
return redirect($page->getUrl('/copy'));
}
$this->checkOwnablePermission('page-create', $newParent);

View File

@ -15,20 +15,23 @@ use Illuminate\Support\Collection;
*
* @property string $description
* @property int $image_id
* @property ?int $default_template_id
* @property Image|null $cover
* @property \Illuminate\Database\Eloquent\Collection $chapters
* @property \Illuminate\Database\Eloquent\Collection $pages
* @property \Illuminate\Database\Eloquent\Collection $directPages
* @property \Illuminate\Database\Eloquent\Collection $shelves
* @property ?Page $defaultTemplate
*/
class Book extends Entity implements HasCoverImage
{
use HasFactory;
use HasHtmlDescription;
public $searchFactor = 1.2;
public float $searchFactor = 1.2;
protected $fillable = ['name', 'description'];
protected $hidden = ['pivot', 'image_id', 'deleted_at'];
protected $fillable = ['name'];
protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html'];
/**
* Get the url for this book.
@ -71,6 +74,14 @@ class Book extends Entity implements HasCoverImage
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 all pages within this book.
*/

View File

@ -65,7 +65,7 @@ abstract class BookChild extends Entity
$this->refresh();
if ($oldUrl !== $this->getUrl()) {
app()->make(ReferenceUpdater::class)->updateEntityPageReferences($this, $oldUrl);
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
}
// Update all child pages if a chapter

View File

@ -11,14 +11,15 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Bookshelf extends Entity implements HasCoverImage
{
use HasFactory;
use HasHtmlDescription;
protected $table = 'bookshelves';
public $searchFactor = 1.2;
public float $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'image_id'];
protected $hidden = ['image_id', 'deleted_at'];
protected $hidden = ['image_id', 'deleted_at', 'description_html'];
/**
* Get the books in this shelf.

View File

@ -15,11 +15,12 @@ use Illuminate\Support\Collection;
class Chapter extends BookChild
{
use HasFactory;
use HasHtmlDescription;
public $searchFactor = 1.2;
public float $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'priority'];
protected $hidden = ['pivot', 'deleted_at'];
protected $hidden = ['pivot', 'deleted_at', 'description_html'];
/**
* Get the pages that this chapter contains.

View File

@ -57,12 +57,17 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
/**
* @var string - Name of property where the main text content is found
*/
public $textField = 'description';
public string $textField = 'description';
/**
* @var string - Name of the property where the main HTML content is found
*/
public string $htmlField = 'description_html';
/**
* @var float - Multiplier for search indexing.
*/
public $searchFactor = 1.0;
public float $searchFactor = 1.0;
/**
* Get the entities that are visible to the current user.

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

@ -37,7 +37,8 @@ class Page extends BookChild
protected $fillable = ['name', 'priority'];
public $textField = 'text';
public string $textField = 'text';
public string $htmlField = 'html';
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];

View File

@ -5,22 +5,22 @@ namespace BookStack\Entities\Repos;
use BookStack\Activity\TagRepo;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage;
use BookStack\Entities\Models\HasHtmlDescription;
use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater;
use BookStack\Uploads\ImageRepo;
use BookStack\Util\HtmlDescriptionFilter;
use Illuminate\Http\UploadedFile;
class BaseRepo
{
protected TagRepo $tagRepo;
protected ImageRepo $imageRepo;
protected ReferenceUpdater $referenceUpdater;
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo, ReferenceUpdater $referenceUpdater)
{
$this->tagRepo = $tagRepo;
$this->imageRepo = $imageRepo;
$this->referenceUpdater = $referenceUpdater;
public function __construct(
protected TagRepo $tagRepo,
protected ImageRepo $imageRepo,
protected ReferenceUpdater $referenceUpdater,
protected ReferenceStore $referenceStore,
) {
}
/**
@ -29,6 +29,7 @@ class BaseRepo
public function create(Entity $entity, array $input)
{
$entity->fill($input);
$this->updateDescription($entity, $input);
$entity->forceFill([
'created_by' => user()->id,
'updated_by' => user()->id,
@ -44,6 +45,7 @@ class BaseRepo
$entity->refresh();
$entity->rebuildPermissions();
$entity->indexForSearch();
$this->referenceStore->updateForEntity($entity);
}
/**
@ -54,6 +56,7 @@ class BaseRepo
$oldUrl = $entity->getUrl();
$entity->fill($input);
$this->updateDescription($entity, $input);
$entity->updated_by = user()->id;
if ($entity->isDirty('name') || empty($entity->slug)) {
@ -69,9 +72,10 @@ class BaseRepo
$entity->rebuildPermissions();
$entity->indexForSearch();
$this->referenceStore->updateForEntity($entity);
if ($oldUrl !== $entity->getUrl()) {
$this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl);
$this->referenceUpdater->updateEntityReferences($entity, $oldUrl);
}
}
@ -99,4 +103,21 @@ class BaseRepo
$entity->save();
}
}
protected function updateDescription(Entity $entity, array $input): void
{
if (!in_array(HasHtmlDescription::class, class_uses($entity))) {
return;
}
/** @var HasHtmlDescription $entity */
if (isset($input['description_html'])) {
$entity->description_html = HtmlDescriptionFilter::filterFromString($input['description_html']);
$entity->description = html_entity_decode(strip_tags($input['description_html']));
} else if (isset($input['description'])) {
$entity->description = $input['description'];
$entity->description_html = '';
$entity->description_html = $entity->descriptionHtml();
}
}
}

View File

@ -5,6 +5,7 @@ namespace BookStack\Entities\Repos;
use BookStack\Activity\ActivityType;
use BookStack\Activity\TagRepo;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
@ -17,18 +18,11 @@ use Illuminate\Support\Collection;
class BookRepo
{
protected $baseRepo;
protected $tagRepo;
protected $imageRepo;
/**
* BookRepo constructor.
*/
public function __construct(BaseRepo $baseRepo, TagRepo $tagRepo, ImageRepo $imageRepo)
{
$this->baseRepo = $baseRepo;
$this->tagRepo = $tagRepo;
$this->imageRepo = $imageRepo;
public function __construct(
protected BaseRepo $baseRepo,
protected TagRepo $tagRepo,
protected ImageRepo $imageRepo
) {
}
/**
@ -92,6 +86,7 @@ class BookRepo
$book = new Book();
$this->baseRepo->create($book, $input);
$this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
$this->updateBookDefaultTemplate($book, intval($input['default_template_id'] ?? null));
Activity::add(ActivityType::BOOK_CREATE, $book);
return $book;
@ -104,6 +99,10 @@ class BookRepo
{
$this->baseRepo->update($book, $input);
if (array_key_exists('default_template_id', $input)) {
$this->updateBookDefaultTemplate($book, intval($input['default_template_id']));
}
if (array_key_exists('image', $input)) {
$this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null);
}
@ -113,6 +112,33 @@ class BookRepo
return $book;
}
/**
* Update the default page template used for this book.
* Checks that, if changing, the provided value is a valid template and the user
* has visibility of the provided page template id.
*/
protected function updateBookDefaultTemplate(Book $book, int $templateId): void
{
$changing = $templateId !== intval($book->default_template_id);
if (!$changing) {
return;
}
if ($templateId === 0) {
$book->default_template_id = null;
$book->save();
return;
}
$templateExists = Page::query()->visible()
->where('template', '=', true)
->where('id', '=', $templateId)
->exists();
$book->default_template_id = $templateExists ? $templateId : null;
$book->save();
}
/**
* Update the given book's cover image, or clear it.
*

View File

@ -136,6 +136,14 @@ class PageRepo
$page->book_id = $parent->id;
}
$defaultTemplate = $page->book->defaultTemplate;
if ($defaultTemplate && userCan('view', $defaultTemplate)) {
$page->forceFill([
'html' => $defaultTemplate->html,
'markdown' => $defaultTemplate->markdown,
]);
}
$page->save();
$page->refresh()->rebuildPermissions();
@ -154,7 +162,6 @@ class PageRepo
$this->baseRepo->update($draft, $input);
$this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision'));
$this->referenceStore->updateForPage($draft);
$draft->refresh();
Activity::add(ActivityType::PAGE_CREATE, $draft);
@ -174,7 +181,6 @@ class PageRepo
$this->updateTemplateStatusAndContentFromInput($page, $input);
$this->baseRepo->update($page, $input);
$this->referenceStore->updateForPage($page);
// Update with new details
$page->revision_count++;
@ -293,13 +299,13 @@ class PageRepo
$page->refreshSlug();
$page->save();
$page->indexForSearch();
$this->referenceStore->updateForPage($page);
$this->referenceStore->updateForEntity($page);
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
$this->revisionRepo->storeNewForPage($page, $summary);
if ($oldUrl !== $page->getUrl()) {
$this->referenceUpdater->updateEntityPageReferences($page, $oldUrl);
$this->referenceUpdater->updateEntityReferences($page, $oldUrl);
}
Activity::add(ActivityType::PAGE_RESTORE, $page);

View File

@ -8,9 +8,8 @@ use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
use BookStack\Uploads\ImageService;
use BookStack\Util\CspService;
use DOMDocument;
use BookStack\Util\HtmlDocument;
use DOMElement;
use DOMXPath;
use Exception;
use Throwable;
@ -151,45 +150,36 @@ class ExportFormatter
protected function htmlToPdf(string $html): string
{
$html = $this->containHtml($html);
$html = $this->replaceIframesWithLinks($html);
$html = $this->openDetailElements($html);
$doc = new HtmlDocument();
$doc->loadCompleteHtml($html);
return $this->pdfGenerator->fromHtml($html);
$this->replaceIframesWithLinks($doc);
$this->openDetailElements($doc);
$cleanedHtml = $doc->getHtml();
return $this->pdfGenerator->fromHtml($cleanedHtml);
}
/**
* Within the given HTML content, Open any detail blocks.
*/
protected function openDetailElements(string $html): string
protected function openDetailElements(HtmlDocument $doc): void
{
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$details = $xPath->query('//details');
$details = $doc->queryXPath('//details');
/** @var DOMElement $detail */
foreach ($details as $detail) {
$detail->setAttribute('open', 'open');
}
return $doc->saveHTML();
}
/**
* Within the given HTML content, replace any iframe elements
* Within the given HTML document, replace any iframe elements
* with anchor links within paragraph blocks.
*/
protected function replaceIframesWithLinks(string $html): string
protected function replaceIframesWithLinks(HtmlDocument $doc): void
{
libxml_use_internal_errors(true);
$iframes = $doc->queryXPath('//iframe');
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$iframes = $xPath->query('//iframe');
/** @var DOMElement $iframe */
foreach ($iframes as $iframe) {
$link = $iframe->getAttribute('src');
@ -203,8 +193,6 @@ class ExportFormatter
$paragraph->appendChild($anchor);
$iframe->parentNode->replaceChild($paragraph, $iframe);
}
return $doc->saveHTML();
}
/**

View File

@ -0,0 +1,103 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\App\Model;
use BookStack\Entities\EntityProvider;
use Illuminate\Database\Eloquent\Relations\Relation;
class MixedEntityListLoader
{
protected array $listAttributes = [
'page' => ['id', 'name', 'slug', 'book_id', 'chapter_id', 'text', 'draft'],
'chapter' => ['id', 'name', 'slug', 'book_id', 'description'],
'book' => ['id', 'name', 'slug', 'description'],
'bookshelf' => ['id', 'name', 'slug', 'description'],
];
public function __construct(
protected EntityProvider $entityProvider
) {
}
/**
* Efficiently load in entities for listing onto the given list
* where entities are set as a relation via the given name.
* This will look for a model id and type via 'name_id' and 'name_type'.
* @param Model[] $relations
*/
public function loadIntoRelations(array $relations, string $relationName): void
{
$idsByType = [];
foreach ($relations as $relation) {
$type = $relation->getAttribute($relationName . '_type');
$id = $relation->getAttribute($relationName . '_id');
if (!isset($idsByType[$type])) {
$idsByType[$type] = [];
}
$idsByType[$type][] = $id;
}
$modelMap = $this->idsByTypeToModelMap($idsByType);
foreach ($relations as $relation) {
$type = $relation->getAttribute($relationName . '_type');
$id = $relation->getAttribute($relationName . '_id');
$related = $modelMap[$type][strval($id)] ?? null;
if ($related) {
$relation->setRelation($relationName, $related);
}
}
}
/**
* @param array<string, int[]> $idsByType
* @return array<string, array<int, Model>>
*/
protected function idsByTypeToModelMap(array $idsByType): array
{
$modelMap = [];
foreach ($idsByType as $type => $ids) {
if (!isset($this->listAttributes[$type])) {
continue;
}
$instance = $this->entityProvider->get($type);
$models = $instance->newQuery()
->select($this->listAttributes[$type])
->scopes('visible')
->whereIn('id', $ids)
->with($this->getRelationsToEagerLoad($type))
->get();
if (count($models) > 0) {
$modelMap[$type] = [];
}
foreach ($models as $model) {
$modelMap[$type][strval($model->id)] = $model;
}
}
return $modelMap;
}
protected function getRelationsToEagerLoad(string $type): array
{
$toLoad = [];
$loadVisible = fn (Relation $query) => $query->scopes('visible');
if ($type === 'chapter' || $type === 'page') {
$toLoad['book'] = $loadVisible;
}
if ($type === 'page') {
$toLoad['chapter'] = $loadVisible;
}
return $toLoad;
}
}

View File

@ -11,12 +11,12 @@ use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageService;
use BookStack\Users\Models\User;
use BookStack\Util\HtmlContentFilter;
use BookStack\Util\HtmlDocument;
use BookStack\Util\WebSafeMimeSniffer;
use DOMDocument;
use Closure;
use DOMElement;
use DOMNode;
use DOMNodeList;
use DOMXPath;
use Illuminate\Support\Str;
class PageContent
@ -58,27 +58,18 @@ class PageContent
return $htmlText;
}
$doc = $this->loadDocumentFromHtml($htmlText);
$container = $doc->documentElement;
$body = $container->childNodes->item(0);
$childNodes = $body->childNodes;
$xPath = new DOMXPath($doc);
$doc = new HtmlDocument($htmlText);
// Get all img elements with image data blobs
$imageNodes = $xPath->query('//img[contains(@src, \'data:image\')]');
$imageNodes = $doc->queryXPath('//img[contains(@src, \'data:image\')]');
/** @var DOMElement $imageNode */
foreach ($imageNodes as $imageNode) {
$imageSrc = $imageNode->getAttribute('src');
$newUrl = $this->base64ImageUriToUploadedImageUrl($imageSrc, $updater);
$imageNode->setAttribute('src', $newUrl);
}
// Generate inner html as a string
$html = '';
foreach ($childNodes as $childNode) {
$html .= $doc->saveHTML($childNode);
}
return $html;
return $doc->getBodyInnerHtml();
}
/**
@ -186,27 +177,18 @@ class PageContent
return $htmlText;
}
$doc = $this->loadDocumentFromHtml($htmlText);
$container = $doc->documentElement;
$body = $container->childNodes->item(0);
$childNodes = $body->childNodes;
$xPath = new DOMXPath($doc);
$doc = new HtmlDocument($htmlText);
// Map to hold used ID references
$idMap = [];
// Map to hold changing ID references
$changeMap = [];
$this->updateIdsRecursively($body, 0, $idMap, $changeMap);
$this->updateLinks($xPath, $changeMap);
$this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
$this->updateLinks($doc, $changeMap);
// Generate inner html as a string
$html = '';
foreach ($childNodes as $childNode) {
$html .= $doc->saveHTML($childNode);
}
// Perform required string-level tweaks
// Generate inner html as a string & perform required string-level tweaks
$html = $doc->getBodyInnerHtml();
$html = str_replace(' ', '&nbsp;', $html);
return $html;
@ -239,13 +221,13 @@ class PageContent
* Update the all links in the given xpath to apply requires changes within the
* given $changeMap array.
*/
protected function updateLinks(DOMXPath $xpath, array $changeMap): void
protected function updateLinks(HtmlDocument $doc, array $changeMap): void
{
if (empty($changeMap)) {
return;
}
$links = $xpath->query('//body//*//*[@href]');
$links = $doc->queryXPath('//body//*//*[@href]');
/** @var DOMElement $domElem */
foreach ($links as $domElem) {
$href = ltrim($domElem->getAttribute('href'), '#');
@ -309,21 +291,65 @@ class PageContent
*/
public function render(bool $blankIncludes = false): string
{
$content = $this->page->html ?? '';
$html = $this->page->html ?? '';
if (empty($html)) {
return $html;
}
$doc = new HtmlDocument($html);
$contentProvider = $this->getContentProviderClosure($blankIncludes);
$parser = new PageIncludeParser($doc, $contentProvider);
$nodesAdded = 1;
for ($includeDepth = 0; $includeDepth < 3 && $nodesAdded !== 0; $includeDepth++) {
$nodesAdded = $parser->parse();
}
if ($includeDepth > 1) {
$idMap = [];
$changeMap = [];
$this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
}
if (!config('app.allow_content_scripts')) {
$content = HtmlContentFilter::removeScripts($content);
HtmlContentFilter::removeScriptsFromDocument($doc);
}
if ($blankIncludes) {
$content = $this->blankPageIncludes($content);
} else {
for ($includeDepth = 0; $includeDepth < 3; $includeDepth++) {
$content = $this->parsePageIncludes($content);
return $doc->getBodyInnerHtml();
}
/**
* Get the closure used to fetch content for page includes.
*/
protected function getContentProviderClosure(bool $blankIncludes): Closure
{
$contextPage = $this->page;
return function (PageIncludeTag $tag) use ($blankIncludes, $contextPage): PageIncludeContent {
if ($blankIncludes) {
return PageIncludeContent::fromHtmlAndTag('', $tag);
}
}
return $content;
$matchedPage = Page::visible()->find($tag->getPageId());
$content = PageIncludeContent::fromHtmlAndTag($matchedPage->html ?? '', $tag);
if (Theme::hasListeners(ThemeEvents::PAGE_INCLUDE_PARSE)) {
$themeReplacement = Theme::dispatch(
ThemeEvents::PAGE_INCLUDE_PARSE,
$tag->tagContent,
$content->toHtml(),
clone $contextPage,
$matchedPage ? (clone $matchedPage) : null,
);
if ($themeReplacement !== null) {
$content = PageIncludeContent::fromInlineHtml(strval($themeReplacement));
}
}
return $content;
};
}
/**
@ -335,11 +361,10 @@ class PageContent
return [];
}
$doc = $this->loadDocumentFromHtml($htmlContent);
$xPath = new DOMXPath($doc);
$headers = $xPath->query('//h1|//h2|//h3|//h4|//h5|//h6');
$doc = new HtmlDocument($htmlContent);
$headers = $doc->queryXPath('//h1|//h2|//h3|//h4|//h5|//h6');
return $headers ? $this->headerNodesToLevelList($headers) : [];
return $headers->count() === 0 ? [] : $this->headerNodesToLevelList($headers);
}
/**
@ -372,102 +397,4 @@ class PageContent
return $tree->toArray();
}
/**
* Remove any page include tags within the given HTML.
*/
protected function blankPageIncludes(string $html): string
{
return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html);
}
/**
* Parse any include tags "{{@<page_id>#section}}" to be part of the page.
*/
protected function parsePageIncludes(string $html): string
{
$matches = [];
preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
foreach ($matches[1] as $index => $includeId) {
$fullMatch = $matches[0][$index];
$splitInclude = explode('#', $includeId, 2);
// Get page id from reference
$pageId = intval($splitInclude[0]);
if (is_nan($pageId)) {
continue;
}
// Find page to use, and default replacement to empty string for non-matches.
/** @var ?Page $matchedPage */
$matchedPage = Page::visible()->find($pageId);
$replacement = '';
if ($matchedPage && count($splitInclude) === 1) {
// If we only have page id, just insert all page html and continue.
$replacement = $matchedPage->html;
} elseif ($matchedPage && count($splitInclude) > 1) {
// Otherwise, if our include tag defines a section, load that specific content
$innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
$replacement = trim($innerContent);
}
$themeReplacement = Theme::dispatch(
ThemeEvents::PAGE_INCLUDE_PARSE,
$includeId,
$replacement,
clone $this->page,
$matchedPage ? (clone $matchedPage) : null,
);
// Perform the content replacement
$html = str_replace($fullMatch, $themeReplacement ?? $replacement, $html);
}
return $html;
}
/**
* Fetch the content from a specific section of the given page.
*/
protected function fetchSectionOfPage(Page $page, string $sectionId): string
{
$topLevelTags = ['table', 'ul', 'ol', 'pre'];
$doc = $this->loadDocumentFromHtml($page->html);
// Search included content for the id given and blank out if not exists.
$matchingElem = $doc->getElementById($sectionId);
if ($matchingElem === null) {
return '';
}
// Otherwise replace the content with the found content
// Checks if the top-level wrapper should be included by matching on tag types
$innerContent = '';
$isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
if ($isTopLevel) {
$innerContent .= $doc->saveHTML($matchingElem);
} else {
foreach ($matchingElem->childNodes as $childNode) {
$innerContent .= $doc->saveHTML($childNode);
}
}
libxml_clear_errors();
return $innerContent;
}
/**
* Create and load a DOMDocument from the given html content.
*/
protected function loadDocumentFromHtml(string $html): DOMDocument
{
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
$doc->loadHTML($html);
return $doc;
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Util\HtmlDocument;
use DOMNode;
class PageIncludeContent
{
protected static array $topLevelTags = ['table', 'ul', 'ol', 'pre'];
/**
* @param DOMNode[] $contents
* @param bool $isInline
*/
public function __construct(
protected array $contents,
protected bool $isInline,
) {
}
public static function fromHtmlAndTag(string $html, PageIncludeTag $tag): self
{
if (empty($html)) {
return new self([], true);
}
$doc = new HtmlDocument($html);
$sectionId = $tag->getSectionId();
if (!$sectionId) {
$contents = [...$doc->getBodyChildren()];
return new self($contents, false);
}
$section = $doc->getElementById($sectionId);
if (!$section) {
return new self([], true);
}
$isTopLevel = in_array(strtolower($section->nodeName), static::$topLevelTags);
$contents = $isTopLevel ? [$section] : [...$section->childNodes];
return new self($contents, !$isTopLevel);
}
public static function fromInlineHtml(string $html): self
{
if (empty($html)) {
return new self([], true);
}
$doc = new HtmlDocument($html);
return new self([...$doc->getBodyChildren()], true);
}
public function isInline(): bool
{
return $this->isInline;
}
public function isEmpty(): bool
{
return empty($this->contents);
}
/**
* @return DOMNode[]
*/
public function toDomNodes(): array
{
return $this->contents;
}
public function toHtml(): string
{
$html = '';
foreach ($this->contents as $content) {
$html .= $content->ownerDocument->saveHTML($content);
}
return $html;
}
}

View File

@ -0,0 +1,220 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Util\HtmlDocument;
use Closure;
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMText;
class PageIncludeParser
{
protected static string $includeTagRegex = "/{{@\s?([0-9].*?)}}/";
/**
* Elements to clean up and remove if left empty after a parsing operation.
* @var DOMElement[]
*/
protected array $toCleanup = [];
/**
* @param Closure(PageIncludeTag $tag): PageContent $pageContentForId
*/
public function __construct(
protected HtmlDocument $doc,
protected Closure $pageContentForId,
) {
}
/**
* Parse out the include tags.
* Returns the count of new content DOM nodes added to the document.
*/
public function parse(): int
{
$nodesAdded = 0;
$tags = $this->locateAndIsolateIncludeTags();
foreach ($tags as $tag) {
/** @var PageIncludeContent $content */
$content = $this->pageContentForId->call($this, $tag);
if (!$content->isInline()) {
$parentP = $this->getParentParagraph($tag->domNode);
$isWithinParentP = $parentP === $tag->domNode->parentNode;
if ($parentP && $isWithinParentP) {
$this->splitNodeAtChildNode($tag->domNode->parentNode, $tag->domNode);
} else if ($parentP) {
$this->moveTagNodeToBesideParent($tag, $parentP);
}
}
$replacementNodes = $content->toDomNodes();
$nodesAdded += count($replacementNodes);
$this->replaceNodeWithNodes($tag->domNode, $replacementNodes);
}
$this->cleanup();
return $nodesAdded;
}
/**
* Locate include tags within the given document, isolating them to their
* own nodes in the DOM for future targeted manipulation.
* @return PageIncludeTag[]
*/
protected function locateAndIsolateIncludeTags(): array
{
$includeHosts = $this->doc->queryXPath("//*[text()[contains(., '{{@')]]");
$includeTags = [];
/** @var DOMNode $node */
foreach ($includeHosts as $node) {
/** @var DOMNode $childNode */
foreach ($node->childNodes as $childNode) {
if ($childNode->nodeName === '#text') {
array_push($includeTags, ...$this->splitTextNodesAtTags($childNode));
}
}
}
return $includeTags;
}
/**
* Takes a text DOMNode and splits its text content at include tags
* into multiple text nodes within the original parent.
* Returns found PageIncludeTag references.
* @return PageIncludeTag[]
*/
protected function splitTextNodesAtTags(DOMNode $textNode): array
{
$includeTags = [];
$text = $textNode->textContent;
preg_match_all(static::$includeTagRegex, $text, $matches, PREG_OFFSET_CAPTURE);
$currentOffset = 0;
foreach ($matches[0] as $index => $fullTagMatch) {
$tagOuterContent = $fullTagMatch[0];
$tagInnerContent = $matches[1][$index][0];
$tagStartOffset = $fullTagMatch[1];
if ($currentOffset < $tagStartOffset) {
$previousText = substr($text, $currentOffset, $tagStartOffset - $currentOffset);
$textNode->parentNode->insertBefore(new DOMText($previousText), $textNode);
}
$node = $textNode->parentNode->insertBefore(new DOMText($tagOuterContent), $textNode);
$includeTags[] = new PageIncludeTag($tagInnerContent, $node);
$currentOffset = $tagStartOffset + strlen($tagOuterContent);
}
if ($currentOffset > 0) {
$textNode->textContent = substr($text, $currentOffset);
}
return $includeTags;
}
/**
* Replace the given node with all those in $replacements
* @param DOMNode[] $replacements
*/
protected function replaceNodeWithNodes(DOMNode $toReplace, array $replacements): void
{
/** @var DOMDocument $targetDoc */
$targetDoc = $toReplace->ownerDocument;
foreach ($replacements as $replacement) {
if ($replacement->ownerDocument !== $targetDoc) {
$replacement = $targetDoc->importNode($replacement, true);
}
$toReplace->parentNode->insertBefore($replacement, $toReplace);
}
$toReplace->parentNode->removeChild($toReplace);
}
/**
* Move a tag node to become a sibling of the given parent.
* Will attempt to guess a position based upon the tag content within the parent.
*/
protected function moveTagNodeToBesideParent(PageIncludeTag $tag, DOMNode $parent): void
{
$parentText = $parent->textContent;
$tagPos = strpos($parentText, $tag->tagContent);
$before = $tagPos < (strlen($parentText) / 2);
$this->toCleanup[] = $tag->domNode->parentNode;
if ($before) {
$parent->parentNode->insertBefore($tag->domNode, $parent);
} else {
$parent->parentNode->insertBefore($tag->domNode, $parent->nextSibling);
}
}
/**
* Splits the given $parentNode at the location of the $domNode within it.
* Attempts replicate the original $parentNode, moving some of their parent
* children in where needed, before adding the $domNode between.
*/
protected function splitNodeAtChildNode(DOMElement $parentNode, DOMNode $domNode): void
{
$children = [...$parentNode->childNodes];
$splitPos = array_search($domNode, $children, true);
if ($splitPos === false) {
$splitPos = count($children) - 1;
}
$parentClone = $parentNode->cloneNode();
$parentNode->parentNode->insertBefore($parentClone, $parentNode);
$parentClone->removeAttribute('id');
for ($i = 0; $i < $splitPos; $i++) {
/** @var DOMNode $child */
$child = $children[$i];
$parentClone->appendChild($child);
}
$parentNode->parentNode->insertBefore($domNode, $parentNode);
$this->toCleanup[] = $parentNode;
$this->toCleanup[] = $parentClone;
}
/**
* Get the parent paragraph of the given node, if existing.
*/
protected function getParentParagraph(DOMNode $parent): ?DOMNode
{
do {
if (strtolower($parent->nodeName) === 'p') {
return $parent;
}
$parent = $parent->parentNode;
} while ($parent !== null);
return null;
}
/**
* Cleanup after a parse operation.
* Removes stranded elements we may have left during the parse.
*/
protected function cleanup(): void
{
foreach ($this->toCleanup as $element) {
$element->normalize();
while ($element->parentNode && !$element->hasChildNodes()) {
$parent = $element->parentNode;
$parent->removeChild($element);
$element = $parent;
}
}
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace BookStack\Entities\Tools;
use DOMNode;
class PageIncludeTag
{
public function __construct(
public string $tagContent,
public DOMNode $domNode,
) {
}
/**
* Get the page ID that this tag references.
*/
public function getPageId(): int
{
return intval(trim(explode('#', $this->tagContent, 2)[0]));
}
/**
* Get the section ID that this tag references (if any)
*/
public function getSectionId(): string
{
return trim(explode('#', $this->tagContent, 2)[1] ?? '');
}
}

View File

@ -202,6 +202,10 @@ class TrashCan
$attachmentService->deleteFile($attachment);
}
// Remove book template usages
Book::query()->where('default_template_id', '=', $page->id)
->update(['default_template_id' => null]);
$page->forceDelete();
return 1;

View File

@ -9,6 +9,7 @@ use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\Exceptions\PostTooLargeException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\ValidationException;
use Symfony\Component\ErrorHandler\Error\FatalError;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
@ -42,7 +43,7 @@ class Handler extends ExceptionHandler
* If it returns a response, that will be provided back to the request
* upon an out of memory event.
*
* @var ?callable<?\Illuminate\Http\Response>
* @var ?callable(): ?Response
*/
protected $onOutOfMemory = null;

View File

@ -9,6 +9,8 @@ use BookStack\Facades\Activity;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
abstract class Controller extends BaseController
@ -165,4 +167,20 @@ abstract class Controller extends BaseController
{
return ['image_extension', 'mimes:jpeg,png,gif,webp', 'max:' . (config('app.upload_limit') * 1000)];
}
/**
* Redirect to the URL provided in the request as a '_return' parameter.
* Will check that the parameter leads to a URL under the root path of the system.
*/
protected function redirectToRequest(Request $request): RedirectResponse
{
$basePath = url('/');
$returnUrl = $request->input('_return') ?? $basePath;
if (!str_starts_with($returnUrl, $basePath)) {
return redirect($basePath);
}
return redirect($returnUrl);
}
}

View File

@ -9,11 +9,9 @@ use Illuminate\Database\Eloquent\Builder;
class EntityPermissionEvaluator
{
protected string $action;
public function __construct(string $action)
{
$this->action = $action;
public function __construct(
protected string $action
) {
}
public function evaluateEntityForUser(Entity $entity, array $userRoleIds): ?bool
@ -82,23 +80,25 @@ class EntityPermissionEvaluator
*/
protected function getPermissionsMapByTypeId(array $typeIdChain, array $filterRoleIds): array
{
$query = EntityPermission::query()->where(function (Builder $query) use ($typeIdChain) {
foreach ($typeIdChain as $typeId) {
$query->orWhere(function (Builder $query) use ($typeId) {
[$type, $id] = explode(':', $typeId);
$query->where('entity_type', '=', $type)
->where('entity_id', '=', $id);
});
$idsByType = [];
foreach ($typeIdChain as $typeId) {
[$type, $id] = explode(':', $typeId);
if (!isset($idsByType[$type])) {
$idsByType[$type] = [];
}
});
if (!empty($filterRoleIds)) {
$query->where(function (Builder $query) use ($filterRoleIds) {
$query->whereIn('role_id', [...$filterRoleIds, 0]);
});
$idsByType[$type][] = $id;
}
$relevantPermissions = $query->get(['entity_id', 'entity_type', 'role_id', $this->action])->all();
$relevantPermissions = [];
foreach ($idsByType as $type => $ids) {
$idsChunked = array_chunk($ids, 10000);
foreach ($idsChunked as $idChunk) {
$permissions = $this->getPermissionsForEntityIdsOfType($type, $idChunk, $filterRoleIds);
array_push($relevantPermissions, ...$permissions);
}
}
$map = [];
foreach ($relevantPermissions as $permission) {
@ -113,6 +113,26 @@ class EntityPermissionEvaluator
return $map;
}
/**
* @param string[] $ids
* @param int[] $filterRoleIds
* @return EntityPermission[]
*/
protected function getPermissionsForEntityIdsOfType(string $type, array $ids, array $filterRoleIds): array
{
$query = EntityPermission::query()
->where('entity_type', '=', $type)
->whereIn('entity_id', $ids);
if (!empty($filterRoleIds)) {
$query->where(function (Builder $query) use ($filterRoleIds) {
$query->whereIn('role_id', [...$filterRoleIds, 0]);
});
}
return $query->get(['entity_id', 'entity_type', 'role_id', $this->action])->all();
}
/**
* @return string[]
*/

View File

@ -83,13 +83,13 @@ class JointPermissionBuilder
$role->load('permissions');
// Chunk through all books
$this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
$this->bookFetchQuery()->chunk(10, function ($books) use ($roles) {
$this->buildJointPermissionsForBooks($books, $roles);
});
// Chunk through all bookshelves
Bookshelf::query()->select(['id', 'owned_by'])
->chunk(50, function ($shelves) use ($roles) {
->chunk(100, function ($shelves) use ($roles) {
$this->createManyJointPermissions($shelves->all(), $roles);
});
}

View File

@ -25,7 +25,7 @@ class PermissionApplicator
/**
* Checks if an entity has a restriction set upon it.
*
* @param HasCreatorAndUpdater|HasOwner $ownable
* @param Model&(HasCreatorAndUpdater|HasOwner) $ownable
*/
public function checkOwnableUserAccess(Model $ownable, string $permission): bool
{
@ -160,10 +160,9 @@ class PermissionApplicator
$joinQuery = function ($query) use ($entityProvider) {
$first = true;
/** @var Builder $query */
foreach ($entityProvider->all() as $entity) {
/** @var Builder $query */
$entityQuery = function ($query) use ($entity) {
/** @var Builder $query */
$query->select(['id', 'deleted_at'])
->selectRaw("'{$entity->getMorphClass()}' as type")
->from($entity->getTable())

View File

@ -9,8 +9,7 @@ use BookStack\References\ModelResolvers\ChapterLinkModelResolver;
use BookStack\References\ModelResolvers\CrossLinkModelResolver;
use BookStack\References\ModelResolvers\PageLinkModelResolver;
use BookStack\References\ModelResolvers\PagePermalinkModelResolver;
use DOMDocument;
use DOMXPath;
use BookStack\Util\HtmlDocument;
class CrossLinkParser
{
@ -54,13 +53,8 @@ class CrossLinkParser
{
$links = [];
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML($html);
$xPath = new DOMXPath($doc);
$anchors = $xPath->query('//a[@href]');
$doc = new HtmlDocument($html);
$anchors = $doc->queryXPath('//a[@href]');
/** @var \DOMElement $anchor */
foreach ($anchors as $anchor) {

View File

@ -10,11 +10,9 @@ use BookStack\Http\Controller;
class ReferenceController extends Controller
{
protected ReferenceFetcher $referenceFetcher;
public function __construct(ReferenceFetcher $referenceFetcher)
{
$this->referenceFetcher = $referenceFetcher;
public function __construct(
protected ReferenceFetcher $referenceFetcher
) {
}
/**
@ -23,7 +21,7 @@ class ReferenceController extends Controller
public function page(string $bookSlug, string $pageSlug)
{
$page = Page::getBySlugs($bookSlug, $pageSlug);
$references = $this->referenceFetcher->getPageReferencesToEntity($page);
$references = $this->referenceFetcher->getReferencesToEntity($page);
return view('pages.references', [
'page' => $page,
@ -37,7 +35,7 @@ class ReferenceController extends Controller
public function chapter(string $bookSlug, string $chapterSlug)
{
$chapter = Chapter::getBySlugs($bookSlug, $chapterSlug);
$references = $this->referenceFetcher->getPageReferencesToEntity($chapter);
$references = $this->referenceFetcher->getReferencesToEntity($chapter);
return view('chapters.references', [
'chapter' => $chapter,
@ -51,7 +49,7 @@ class ReferenceController extends Controller
public function book(string $slug)
{
$book = Book::getBySlug($slug);
$references = $this->referenceFetcher->getPageReferencesToEntity($book);
$references = $this->referenceFetcher->getReferencesToEntity($book);
return view('books.references', [
'book' => $book,
@ -65,7 +63,7 @@ class ReferenceController extends Controller
public function shelf(string $slug)
{
$shelf = Bookshelf::getBySlug($slug);
$references = $this->referenceFetcher->getPageReferencesToEntity($shelf);
$references = $this->referenceFetcher->getReferencesToEntity($shelf);
return view('shelves.references', [
'shelf' => $shelf,

View File

@ -3,65 +3,51 @@
namespace BookStack\References;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\MixedEntityListLoader;
use BookStack\Permissions\PermissionApplicator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\Relation;
class ReferenceFetcher
{
protected PermissionApplicator $permissions;
public function __construct(PermissionApplicator $permissions)
{
$this->permissions = $permissions;
public function __construct(
protected PermissionApplicator $permissions,
protected MixedEntityListLoader $mixedEntityListLoader,
) {
}
/**
* Query and return the page references pointing to the given entity.
* Query and return the references pointing to the given entity.
* Loads the commonly required relations while taking permissions into account.
*/
public function getPageReferencesToEntity(Entity $entity): Collection
public function getReferencesToEntity(Entity $entity): Collection
{
$baseQuery = $this->queryPageReferencesToEntity($entity)
->with([
'from' => fn (Relation $query) => $query->select(Page::$listAttributes),
'from.book' => fn (Relation $query) => $query->scopes('visible'),
'from.chapter' => fn (Relation $query) => $query->scopes('visible'),
]);
$references = $this->permissions->restrictEntityRelationQuery(
$baseQuery,
'references',
'from_id',
'from_type'
)->get();
$references = $this->queryReferencesToEntity($entity)->get();
$this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from');
return $references;
}
/**
* Returns the count of page references pointing to the given entity.
* Returns the count of references pointing to the given entity.
* Takes permissions into account.
*/
public function getPageReferenceCountToEntity(Entity $entity): int
public function getReferenceCountToEntity(Entity $entity): int
{
$count = $this->permissions->restrictEntityRelationQuery(
$this->queryPageReferencesToEntity($entity),
return $this->queryReferencesToEntity($entity)->count();
}
protected function queryReferencesToEntity(Entity $entity): Builder
{
$baseQuery = Reference::query()
->where('to_type', '=', $entity->getMorphClass())
->where('to_id', '=', $entity->id);
return $this->permissions->restrictEntityRelationQuery(
$baseQuery,
'references',
'from_id',
'from_type'
)->count();
return $count;
}
protected function queryPageReferencesToEntity(Entity $entity): Builder
{
return Reference::query()
->where('to_type', '=', $entity->getMorphClass())
->where('to_id', '=', $entity->id)
->where('from_type', '=', (new Page())->getMorphClass());
);
}
}

View File

@ -2,60 +2,62 @@
namespace BookStack\References;
use BookStack\Entities\Models\Page;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Collection;
class ReferenceStore
{
/**
* Update the outgoing references for the given page.
*/
public function updateForPage(Page $page): void
{
$this->updateForPages([$page]);
public function __construct(
protected EntityProvider $entityProvider
) {
}
/**
* Update the outgoing references for all pages in the system.
* Update the outgoing references for the given entity.
*/
public function updateForAllPages(): void
public function updateForEntity(Entity $entity): void
{
Reference::query()
->where('from_type', '=', (new Page())->getMorphClass())
->delete();
Page::query()->select(['id', 'html'])->chunk(100, function (Collection $pages) {
$this->updateForPages($pages->all());
});
$this->updateForEntities([$entity]);
}
/**
* Update the outgoing references for the pages in the given array.
* Update the outgoing references for all entities in the system.
*/
public function updateForAll(): void
{
Reference::query()->delete();
foreach ($this->entityProvider->all() as $entity) {
$entity->newQuery()->select(['id', $entity->htmlField])->chunk(100, function (Collection $entities) {
$this->updateForEntities($entities->all());
});
}
}
/**
* Update the outgoing references for the entities in the given array.
*
* @param Page[] $pages
* @param Entity[] $entities
*/
protected function updateForPages(array $pages): void
protected function updateForEntities(array $entities): void
{
if (count($pages) === 0) {
if (count($entities) === 0) {
return;
}
$parser = CrossLinkParser::createWithEntityResolvers();
$references = [];
$pageIds = array_map(fn (Page $page) => $page->id, $pages);
Reference::query()
->where('from_type', '=', $pages[0]->getMorphClass())
->whereIn('from_id', $pageIds)
->delete();
$this->dropReferencesFromEntities($entities);
foreach ($pages as $page) {
$models = $parser->extractLinkedModels($page->html);
foreach ($entities as $entity) {
$models = $parser->extractLinkedModels($entity->getAttribute($entity->htmlField));
foreach ($models as $model) {
$references[] = [
'from_id' => $page->id,
'from_type' => $page->getMorphClass(),
'from_id' => $entity->id,
'from_type' => $entity->getMorphClass(),
'to_id' => $model->id,
'to_type' => $model->getMorphClass(),
];
@ -66,4 +68,29 @@ class ReferenceStore
Reference::query()->insert($referenceDataChunk);
}
}
/**
* Delete all the existing references originating from the given entities.
* @param Entity[] $entities
*/
protected function dropReferencesFromEntities(array $entities): void
{
$IdsByType = [];
foreach ($entities as $entity) {
$type = $entity->getMorphClass();
if (!isset($IdsByType[$type])) {
$IdsByType[$type] = [];
}
$IdsByType[$type][] = $entity->id;
}
foreach ($IdsByType as $type => $entityIds) {
Reference::query()
->where('from_type', '=', $type)
->whereIn('from_id', $entityIds)
->delete();
}
}
}

View File

@ -4,32 +4,28 @@ namespace BookStack\References;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasHtmlDescription;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\RevisionRepo;
use DOMDocument;
use DOMXPath;
use BookStack\Util\HtmlDocument;
class ReferenceUpdater
{
protected ReferenceFetcher $referenceFetcher;
protected RevisionRepo $revisionRepo;
public function __construct(ReferenceFetcher $referenceFetcher, RevisionRepo $revisionRepo)
{
$this->referenceFetcher = $referenceFetcher;
$this->revisionRepo = $revisionRepo;
public function __construct(
protected ReferenceFetcher $referenceFetcher,
protected RevisionRepo $revisionRepo,
) {
}
public function updateEntityPageReferences(Entity $entity, string $oldLink)
public function updateEntityReferences(Entity $entity, string $oldLink): void
{
$references = $this->getReferencesToUpdate($entity);
$newLink = $entity->getUrl();
/** @var Reference $reference */
foreach ($references as $reference) {
/** @var Page $page */
$page = $reference->from;
$this->updateReferencesWithinPage($page, $oldLink, $newLink);
/** @var Entity $entity */
$entity = $reference->from;
$this->updateReferencesWithinEntity($entity, $oldLink, $newLink);
}
}
@ -39,14 +35,15 @@ class ReferenceUpdater
protected function getReferencesToUpdate(Entity $entity): array
{
/** @var Reference[] $references */
$references = $this->referenceFetcher->getPageReferencesToEntity($entity)->values()->all();
$references = $this->referenceFetcher->getReferencesToEntity($entity)->values()->all();
if ($entity instanceof Book) {
$pages = $entity->pages()->get(['id']);
$chapters = $entity->chapters()->get(['id']);
$children = $pages->concat($chapters);
foreach ($children as $bookChild) {
$childRefs = $this->referenceFetcher->getPageReferencesToEntity($bookChild)->values()->all();
/** @var Reference[] $childRefs */
$childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild)->values()->all();
array_push($references, ...$childRefs);
}
}
@ -60,7 +57,28 @@ class ReferenceUpdater
return array_values($deduped);
}
protected function updateReferencesWithinPage(Page $page, string $oldLink, string $newLink)
protected function updateReferencesWithinEntity(Entity $entity, string $oldLink, string $newLink): void
{
if ($entity instanceof Page) {
$this->updateReferencesWithinPage($entity, $oldLink, $newLink);
return;
}
if (in_array(HasHtmlDescription::class, class_uses($entity))) {
$this->updateReferencesWithinDescription($entity, $oldLink, $newLink);
}
}
protected function updateReferencesWithinDescription(Entity $entity, string $oldLink, string $newLink): void
{
/** @var HasHtmlDescription&Entity $entity */
$entity = (clone $entity)->refresh();
$html = $this->updateLinksInHtml($entity->description_html ?: '', $oldLink, $newLink);
$entity->description_html = $html;
$entity->save();
}
protected function updateReferencesWithinPage(Page $page, string $oldLink, string $newLink): void
{
$page = (clone $page)->refresh();
$html = $this->updateLinksInHtml($page->html, $oldLink, $newLink);
@ -96,13 +114,8 @@ class ReferenceUpdater
return $html;
}
$html = '<body>' . $html . '</body>';
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$anchors = $xPath->query('//a[@href]');
$doc = new HtmlDocument($html);
$anchors = $doc->queryXPath('//a[@href]');
/** @var \DOMElement $anchor */
foreach ($anchors as $anchor) {
@ -111,12 +124,6 @@ class ReferenceUpdater
$anchor->setAttribute('href', $updated);
}
$html = '';
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
foreach ($topElems as $child) {
$html .= $doc->saveHTML($child);
}
return $html;
return $doc->getBodyInnerHtml();
}
}

View File

@ -2,6 +2,7 @@
namespace BookStack\Search;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\Popular;
use BookStack\Entities\Tools\SiblingFetcher;
use BookStack\Http\Controller;
@ -82,6 +83,32 @@ class SearchController extends Controller
return view('search.parts.entity-selector-list', ['entities' => $entities, 'permission' => $permission]);
}
/**
* Search for a list of templates to choose from.
*/
public function templatesForSelector(Request $request)
{
$searchTerm = $request->get('term', false);
if ($searchTerm !== false) {
$searchOptions = SearchOptions::fromString($searchTerm);
$searchOptions->setFilter('is_template');
$entities = $this->searchRunner->searchEntities($searchOptions, 'page', 1, 20)['results'];
} else {
$entities = Page::visible()
->where('template', '=', true)
->where('draft', '=', false)
->orderBy('updated_at', 'desc')
->take(20)
->get(Page::$listAttributes);
}
return view('search.parts.entity-selector-list', [
'entities' => $entities,
'permission' => 'view'
]);
}
/**
* Search for a list of entities and return a partial HTML response of matching entities
* to be used as a result preview suggestion list for global system searches.

View File

@ -6,7 +6,7 @@ use BookStack\Activity\Models\Tag;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use DOMDocument;
use BookStack\Util\HtmlDocument;
use DOMNode;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
@ -138,16 +138,11 @@ class SearchIndex
'h6' => 1.5,
];
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
$html = str_ireplace(['<br>', '<br />', '<br/>'], "\n", $html);
$doc = new HtmlDocument($html);
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML($html);
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
/** @var DOMNode $child */
foreach ($topElems as $child) {
foreach ($doc->getBodyChildren() as $child) {
$nodeName = $child->nodeName;
$termCounts = $this->textToTermCountMap(trim($child->textContent));
foreach ($termCounts as $term => $count) {
@ -168,7 +163,6 @@ class SearchIndex
*/
protected function generateTermScoreMapFromTags(array $tags): array
{
$scoreMap = [];
$names = [];
$values = [];

View File

@ -170,6 +170,14 @@ class SearchOptions
return $parsed;
}
/**
* Set the value of a specific filter in the search options.
*/
public function setFilter(string $filterName, string $filterValue = ''): void
{
$this->filters[$filterName] = $filterValue;
}
/**
* Encode this instance to a search string.
*/

View File

@ -58,7 +58,7 @@ class SearchRunner
$entityTypesToSearch = $entityTypes;
if ($entityType !== 'all') {
$entityTypesToSearch = $entityType;
$entityTypesToSearch = [$entityType];
} elseif (isset($searchOpts->filters['type'])) {
$entityTypesToSearch = explode('|', $searchOpts->filters['type']);
}
@ -469,6 +469,13 @@ class SearchRunner
});
}
protected function filterIsTemplate(EloquentBuilder $query, Entity $model, $input)
{
if ($model instanceof Page) {
$query->where('template', '=', true);
}
}
protected function filterSortBy(EloquentBuilder $query, Entity $model, $input)
{
$functionName = Str::camel('sort_by_' . $input);

View File

@ -87,7 +87,7 @@ class MaintenanceController extends Controller
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'regenerate-references');
try {
$referenceStore->updateForAllPages();
$referenceStore->updateForAll();
$this->showSuccessNotification(trans('settings.maint_regen_references_success'));
} catch (\Exception $exception) {
$this->showErrorNotification($exception->getMessage());

View File

@ -50,7 +50,7 @@ class CustomHtmlHeadContentProvider
$hash = md5($content);
return $this->cache->remember('custom-head-export:' . $hash, 86400, function () use ($content) {
return HtmlContentFilter::removeScripts($content);
return HtmlContentFilter::removeScriptsFromHtmlString($content);
});
}

View File

@ -2,8 +2,6 @@
namespace BookStack\Theming;
use BookStack\Entities\Models\Page;
/**
* The ThemeEvents used within BookStack.
*
@ -93,11 +91,30 @@ class ThemeEvents
*
* @param string $tagReference
* @param string $replacementHTML
* @param Page $currentPage
* @param ?Page $referencedPage
* @param \BookStack\Entities\Models\Page $currentPage
* @param ?\BookStack\Entities\Models\Page $referencedPage
*/
const PAGE_INCLUDE_PARSE = 'page_include_parse';
/**
* Routes register web event.
* Called when standard web (browser/non-api) app routes are registered.
* Provides an app router, so you can register your own web routes.
*
* @param \Illuminate\Routing\Router $router
*/
const ROUTES_REGISTER_WEB = 'routes_register_web';
/**
* Routes register web auth event.
* Called when auth-required web (browser/non-api) app routes can be registered.
* These are routes that typically require login to access (unless the instance is made public).
* Provides an app router, so you can register your own web routes.
*
* @param \Illuminate\Routing\Router $router
*/
const ROUTES_REGISTER_WEB_AUTH = 'routes_register_web_auth';
/**
* Web before middleware action.
* Runs before the request is handled but after all other middleware apart from those

View File

@ -2,7 +2,7 @@
namespace BookStack\Theming;
use BookStack\Access\SocialAuthService;
use BookStack\Access\SocialDriverManager;
use BookStack\Exceptions\ThemeException;
use Illuminate\Console\Application;
use Illuminate\Console\Application as Artisan;
@ -48,6 +48,14 @@ class ThemeService
return null;
}
/**
* Check if there are listeners registered for the given event name.
*/
public function hasListeners(string $event): bool
{
return count($this->listeners[$event] ?? []) > 0;
}
/**
* Register a new custom artisan command to be available.
*/
@ -74,11 +82,11 @@ class ThemeService
}
/**
* @see SocialAuthService::addSocialDriver
* @see SocialDriverManager::addSocialDriver
*/
public function addSocialDriver(string $driverName, array $config, string $socialiteHandler, callable $configureForRedirect = null): void
{
$socialAuthService = app()->make(SocialAuthService::class);
$socialAuthService->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect);
$driverManager = app()->make(SocialDriverManager::class);
$driverManager->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect);
}
}

View File

@ -154,7 +154,7 @@ class RoleController extends Controller
} catch (PermissionsException $e) {
$this->showErrorNotification($e->getMessage());
return redirect()->back();
return redirect("/settings/roles/delete/{$id}");
}
return redirect('/settings/roles');

View File

@ -2,7 +2,7 @@
namespace BookStack\Users\Controllers;
use BookStack\Access\SocialAuthService;
use BookStack\Access\SocialDriverManager;
use BookStack\Http\Controller;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\UserNotificationPreferences;
@ -161,7 +161,7 @@ class UserAccountController extends Controller
/**
* Show the view for the "Access & Security" account options.
*/
public function showAuth(SocialAuthService $socialAuthService)
public function showAuth(SocialDriverManager $socialDriverManager)
{
$mfaMethods = user()->mfaValues()->get()->groupBy('method');
@ -171,7 +171,7 @@ class UserAccountController extends Controller
'category' => 'auth',
'mfaMethods' => $mfaMethods,
'authMethod' => config('auth.method'),
'activeSocialDrivers' => $socialAuthService->getActiveDrivers(),
'activeSocialDrivers' => $socialDriverManager->getActive(),
]);
}

View File

@ -90,7 +90,7 @@ class UserApiController extends ApiController
public function create(Request $request)
{
$data = $this->validate($request, $this->rules()['create']);
$sendInvite = ($data['send_invite'] ?? false) === true;
$sendInvite = boolval($data['send_invite'] ?? false) === true;
$user = null;
DB::transaction(function () use ($data, $sendInvite, &$user) {

View File

@ -2,7 +2,7 @@
namespace BookStack\Users\Controllers;
use BookStack\Access\SocialAuthService;
use BookStack\Access\SocialDriverManager;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Http\Controller;
@ -101,7 +101,7 @@ class UserController extends Controller
/**
* Show the form for editing the specified user.
*/
public function edit(int $id, SocialAuthService $socialAuthService)
public function edit(int $id, SocialDriverManager $socialDriverManager)
{
$this->checkPermission('users-manage');
@ -109,7 +109,7 @@ class UserController extends Controller
$user->load(['apiTokens', 'mfaValues']);
$authMethod = ($user->system_name) ? 'system' : config('auth.method');
$activeSocialDrivers = $socialAuthService->getActiveDrivers();
$activeSocialDrivers = $socialDriverManager->getActive();
$mfaMethods = $user->mfaValues->groupBy('method');
$this->setPageTitle(trans('settings.user_profile'));
$roles = Role::query()->orderBy('display_name', 'asc')->get();

View File

@ -3,9 +3,6 @@
namespace BookStack\Users\Controllers;
use BookStack\Http\Controller;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Settings\UserShortcutMap;
use BookStack\Users\UserRepo;
use Illuminate\Http\Request;
@ -23,7 +20,7 @@ class UserPreferencesController extends Controller
{
$valueViewTypes = ['books', 'bookshelves', 'bookshelf'];
if (!in_array($type, $valueViewTypes)) {
return redirect()->back(500);
return $this->redirectToRequest($request);
}
$view = $request->get('view');
@ -34,7 +31,7 @@ class UserPreferencesController extends Controller
$key = $type . '_view_type';
setting()->putForCurrentUser($key, $view);
return redirect()->back(302, [], "/");
return $this->redirectToRequest($request);
}
/**
@ -44,7 +41,7 @@ class UserPreferencesController extends Controller
{
$validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles', 'webhooks', 'tags', 'page_revisions'];
if (!in_array($type, $validSortTypes)) {
return redirect()->back(500);
return $this->redirectToRequest($request);
}
$sort = substr($request->get('sort') ?: 'name', 0, 50);
@ -55,18 +52,18 @@ class UserPreferencesController extends Controller
setting()->putForCurrentUser($sortKey, $sort);
setting()->putForCurrentUser($orderKey, $order);
return redirect()->back(302, [], "/");
return $this->redirectToRequest($request);
}
/**
* Toggle dark mode for the current user.
*/
public function toggleDarkMode()
public function toggleDarkMode(Request $request)
{
$enabled = setting()->getForCurrentUser('dark-mode-enabled');
setting()->putForCurrentUser('dark-mode-enabled', $enabled ? 'false' : 'true');
return redirect()->back();
return $this->redirectToRequest($request);
}
/**

View File

@ -3,70 +3,65 @@
namespace BookStack\Util;
use DOMAttr;
use DOMDocument;
use DOMElement;
use DOMNodeList;
use DOMXPath;
class HtmlContentFilter
{
/**
* Remove all the script elements from the given HTML.
* Remove all the script elements from the given HTML document.
*/
public static function removeScripts(string $html): string
public static function removeScriptsFromDocument(HtmlDocument $doc)
{
if (empty($html)) {
return $html;
}
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML($html);
$xPath = new DOMXPath($doc);
// Remove standard script tags
$scriptElems = $xPath->query('//script');
$scriptElems = $doc->queryXPath('//script');
static::removeNodes($scriptElems);
// Remove clickable links to JavaScript URI
$badLinks = $xPath->query('//*[' . static::xpathContains('@href', 'javascript:') . ']');
$badLinks = $doc->queryXPath('//*[' . static::xpathContains('@href', 'javascript:') . ']');
static::removeNodes($badLinks);
// Remove forms with calls to JavaScript URI
$badForms = $xPath->query('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']');
$badForms = $doc->queryXPath('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']');
static::removeNodes($badForms);
// Remove meta tag to prevent external redirects
$metaTags = $xPath->query('//meta[' . static::xpathContains('@content', 'url') . ']');
$metaTags = $doc->queryXPath('//meta[' . static::xpathContains('@content', 'url') . ']');
static::removeNodes($metaTags);
// Remove data or JavaScript iFrames
$badIframes = $xPath->query('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]');
$badIframes = $doc->queryXPath('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]');
static::removeNodes($badIframes);
// Remove attributes, within svg children, hiding JavaScript or data uris.
// A bunch of svg element and attribute combinations expose xss possibilities.
// For example, SVG animate tag can exploit javascript in values.
$badValuesAttrs = $xPath->query('//svg//@*[' . static::xpathContains('.', 'data:') . '] | //svg//@*[' . static::xpathContains('.', 'javascript:') . ']');
$badValuesAttrs = $doc->queryXPath('//svg//@*[' . static::xpathContains('.', 'data:') . '] | //svg//@*[' . static::xpathContains('.', 'javascript:') . ']');
static::removeAttributes($badValuesAttrs);
// Remove elements with a xlink:href attribute
// Used in SVG but deprecated anyway, so we'll be a bit more heavy-handed here.
$xlinkHrefAttributes = $xPath->query('//@*[contains(name(), \'xlink:href\')]');
$xlinkHrefAttributes = $doc->queryXPath('//@*[contains(name(), \'xlink:href\')]');
static::removeAttributes($xlinkHrefAttributes);
// Remove 'on*' attributes
$onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
$onAttributes = $doc->queryXPath('//@*[starts-with(name(), \'on\')]');
static::removeAttributes($onAttributes);
}
$html = '';
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
foreach ($topElems as $child) {
$html .= $doc->saveHTML($child);
/**
* Remove scripts from the given HTML string.
*/
public static function removeScriptsFromHtmlString(string $html): string
{
if (empty($html)) {
return $html;
}
return $html;
$doc = new HtmlDocument($html);
static::removeScriptsFromDocument($doc);
return $doc->getBodyInnerHtml();
}
/**

View File

@ -0,0 +1,79 @@
<?php
namespace BookStack\Util;
use DOMAttr;
use DOMElement;
use DOMNamedNodeMap;
use DOMNode;
/**
* Filter to ensure HTML input for description content remains simple and
* to a limited allow-list of elements and attributes.
* More for consistency and to prevent nuisance rather than for security
* (which would be done via a separate content filter and CSP).
*/
class HtmlDescriptionFilter
{
/**
* @var array<string, string[]>
*/
protected static array $allowedAttrsByElements = [
'p' => [],
'a' => ['href', 'title'],
'ol' => [],
'ul' => [],
'li' => [],
'strong' => [],
'em' => [],
'br' => [],
];
public static function filterFromString(string $html): string
{
if (empty(trim($html))) {
return '';
}
$doc = new HtmlDocument($html);
$topLevel = [...$doc->getBodyChildren()];
foreach ($topLevel as $child) {
/** @var DOMNode $child */
if ($child instanceof DOMElement) {
static::filterElement($child);
} else {
$child->parentNode->removeChild($child);
}
}
return $doc->getBodyInnerHtml();
}
protected static function filterElement(DOMElement $element): void
{
$elType = strtolower($element->tagName);
$allowedAttrs = static::$allowedAttrsByElements[$elType] ?? null;
if (is_null($allowedAttrs)) {
$element->remove();
return;
}
/** @var DOMNamedNodeMap $attrs */
$attrs = $element->attributes;
for ($i = $attrs->length - 1; $i >= 0; $i--) {
/** @var DOMAttr $attr */
$attr = $attrs->item($i);
$name = strtolower($attr->name);
if (!in_array($name, $allowedAttrs)) {
$element->removeAttribute($attr->name);
}
}
foreach ($element->childNodes as $child) {
if ($child instanceof DOMElement) {
static::filterElement($child);
}
}
}
}

152
app/Util/HtmlDocument.php Normal file
View File

@ -0,0 +1,152 @@
<?php
namespace BookStack\Util;
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMNodeList;
use DOMXPath;
/**
* HtmlDocument is a thin wrapper around DOMDocument built
* specifically for loading, querying and generating HTML content.
*/
class HtmlDocument
{
protected DOMDocument $document;
protected ?DOMXPath $xpath = null;
protected int $loadOptions;
public function __construct(string $partialHtml = '', int $loadOptions = 0)
{
libxml_use_internal_errors(true);
$this->document = new DOMDocument();
$this->loadOptions = $loadOptions;
if ($partialHtml) {
$this->loadPartialHtml($partialHtml);
}
}
/**
* Load some HTML content that's part of a document (e.g. body content)
* into the current document.
*/
public function loadPartialHtml(string $html): void
{
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
$this->document->loadHTML($html, $this->loadOptions);
$this->xpath = null;
}
/**
* Load a complete page of HTML content into the document.
*/
public function loadCompleteHtml(string $html): void
{
$html = '<?xml encoding="utf-8" ?>' . $html;
$this->document->loadHTML($html, $this->loadOptions);
$this->xpath = null;
}
/**
* Start an XPath query on the current document.
*/
public function queryXPath(string $expression): DOMNodeList
{
if (is_null($this->xpath)) {
$this->xpath = new DOMXPath($this->document);
}
$result = $this->xpath->query($expression);
if ($result === false) {
throw new \InvalidArgumentException("XPath query for expression [$expression] failed to execute");
}
return $result;
}
/**
* Create a new DOMElement instance within the document.
*/
public function createElement(string $localName, string $value = ''): DOMElement
{
$element = $this->document->createElement($localName, $value);
if ($element === false) {
throw new \InvalidArgumentException("Failed to create element of name [$localName] and value [$value]");
}
return $element;
}
/**
* Get an element within the document of the given ID.
*/
public function getElementById(string $elementId): ?DOMElement
{
return $this->document->getElementById($elementId);
}
/**
* Get the DOMNode that represents the HTML body.
*/
public function getBody(): DOMNode
{
return $this->document->getElementsByTagName('body')[0];
}
/**
* Get the nodes that are a direct child of the body.
* This is usually all the content nodes if loaded partially.
*/
public function getBodyChildren(): DOMNodeList
{
return $this->getBody()->childNodes;
}
/**
* Get the inner HTML content of the body.
* This is usually all the content if loaded partially.
*/
public function getBodyInnerHtml(): string
{
$html = '';
foreach ($this->getBodyChildren() as $child) {
$html .= $this->document->saveHTML($child);
}
return $html;
}
/**
* Get the HTML content of the whole document.
*/
public function getHtml(): string
{
return $this->document->saveHTML($this->document->documentElement);
}
/**
* Get the inner HTML for the given node.
*/
public function getNodeInnerHtml(DOMNode $node): string
{
$html = '';
foreach ($node->childNodes as $childNode) {
$html .= $this->document->saveHTML($childNode);
}
return $html;
}
/**
* Get the outer HTML for the given node.
*/
public function getNodeOuterHtml(DOMNode $node): string
{
return $this->document->saveHTML($node);
}
}

View File

@ -2,14 +2,12 @@
namespace BookStack\Util;
use DOMDocument;
use DOMElement;
use DOMNodeList;
use DOMXPath;
class HtmlNonceApplicator
{
protected static $placeholder = '[CSP_NONCE_VALUE]';
protected static string $placeholder = '[CSP_NONCE_VALUE]';
/**
* Prepare the given HTML content with nonce attributes including a placeholder
@ -21,28 +19,20 @@ class HtmlNonceApplicator
return $html;
}
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML($html, LIBXML_SCHEMA_CREATE);
$xPath = new DOMXPath($doc);
// LIBXML_SCHEMA_CREATE was found to be required here otherwise
// the PHP DOMDocument handling will attempt to format/close
// HTML tags within scripts and therefore change JS content.
$doc = new HtmlDocument($html, LIBXML_SCHEMA_CREATE);
// Apply to scripts
$scriptElems = $xPath->query('//script');
$scriptElems = $doc->queryXPath('//script');
static::addNonceAttributes($scriptElems, static::$placeholder);
// Apply to styles
$styleElems = $xPath->query('//style');
$styleElems = $doc->queryXPath('//style');
static::addNonceAttributes($styleElems, static::$placeholder);
$returnHtml = '';
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
foreach ($topElems as $child) {
$content = $doc->saveHTML($child);
$returnHtml .= $content;
}
return $returnHtml;
return $doc->getBodyInnerHtml();
}
/**

View File

@ -23,7 +23,7 @@
"guzzlehttp/guzzle": "^7.4",
"intervention/image": "^2.7",
"laravel/framework": "^9.0",
"laravel/socialite": "^5.8",
"laravel/socialite": "^5.10",
"laravel/tinker": "^2.6",
"league/commonmark": "^2.3",
"league/flysystem-aws-s3-v3": "^3.0",
@ -38,18 +38,18 @@
"socialiteproviders/microsoft-azure": "^5.1",
"socialiteproviders/okta": "^4.2",
"socialiteproviders/twitch": "^5.3",
"ssddanbrown/htmldiff": "^1.0.2"
"ssddanbrown/htmldiff": "^1.0.2",
"ssddanbrown/symfony-mailer": "6.0.x-dev"
},
"require-dev": {
"fakerphp/faker": "^1.21",
"itsgoingd/clockwork": "^5.1",
"mockery/mockery": "^1.5",
"nunomaduro/collision": "^6.4",
"nunomaduro/larastan": "^2.4",
"larastan/larastan": "^2.7",
"phpunit/phpunit": "^9.5",
"squizlabs/php_codesniffer": "^3.7",
"ssddanbrown/asserthtml": "^2.0",
"ssddanbrown/symfony-mailer": "6.0.x-dev"
"ssddanbrown/asserthtml": "^2.0"
},
"autoload": {
"psr-4": {

790
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -21,10 +21,12 @@ class BookFactory extends Factory
*/
public function definition()
{
$description = $this->faker->paragraph();
return [
'name' => $this->faker->sentence(),
'slug' => Str::random(10),
'description' => $this->faker->paragraph(),
'description' => $description,
'description_html' => '<p>' . e($description) . '</p>'
];
}
}

View File

@ -21,10 +21,12 @@ class BookshelfFactory extends Factory
*/
public function definition()
{
$description = $this->faker->paragraph();
return [
'name' => $this->faker->sentence,
'slug' => Str::random(10),
'description' => $this->faker->paragraph,
'description' => $description,
'description_html' => '<p>' . e($description) . '</p>'
];
}
}

View File

@ -21,10 +21,12 @@ class ChapterFactory extends Factory
*/
public function definition()
{
$description = $this->faker->paragraph();
return [
'name' => $this->faker->sentence(),
'slug' => Str::random(10),
'description' => $this->faker->paragraph(),
'description' => $description,
'description_html' => '<p>' . e($description) . '</p>'
];
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddDefaultTemplateToBooks extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('books', function (Blueprint $table) {
$table->integer('default_template_id')->nullable()->default(null);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('books', function (Blueprint $table) {
$table->dropColumn('default_template_id');
});
}
}

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
$addColumn = fn(Blueprint $table) => $table->text('description_html');
Schema::table('books', $addColumn);
Schema::table('chapters', $addColumn);
Schema::table('bookshelves', $addColumn);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
$removeColumn = fn(Blueprint $table) => $table->removeColumn('description_html');
Schema::table('books', $removeColumn);
Schema::table('chapters', $removeColumn);
Schema::table('bookshelves', $removeColumn);
}
};

View File

@ -3,6 +3,7 @@
namespace Database\Seeders;
use BookStack\Api\ApiToken;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
@ -38,7 +39,7 @@ class DummyContentSeeder extends Seeder
$byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'owned_by' => $editorUser->id];
\BookStack\Entities\Models\Book::factory()->count(5)->create($byData)
Book::factory()->count(5)->create($byData)
->each(function ($book) use ($byData) {
$chapters = Chapter::factory()->count(3)->create($byData)
->each(function ($chapter) use ($book, $byData) {
@ -50,7 +51,7 @@ class DummyContentSeeder extends Seeder
$book->pages()->saveMany($pages);
});
$largeBook = \BookStack\Entities\Models\Book::factory()->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
$largeBook = Book::factory()->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)]));
$pages = Page::factory()->count(200)->make($byData);
$chapters = Chapter::factory()->count(50)->make($byData);
$largeBook->pages()->saveMany($pages);

View File

@ -28,12 +28,18 @@ class LargeContentSeeder extends Seeder
/** @var Book $largeBook */
$largeBook = Book::factory()->create(['name' => 'Large book' . Str::random(10), 'created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
$pages = Page::factory()->count(200)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
$chapters = Chapter::factory()->count(50)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
$largeBook->pages()->saveMany($pages);
$largeBook->chapters()->saveMany($chapters);
$all = array_merge([$largeBook], array_values($pages->all()), array_values($chapters->all()));
$allPages = [];
foreach ($chapters as $chapter) {
$pages = Page::factory()->count(100)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'chapter_id' => $chapter->id]);
$largeBook->pages()->saveMany($pages);
array_push($allPages, ...$pages->all());
}
$all = array_merge([$largeBook], $allPages, array_values($chapters->all()));
app()->make(JointPermissionBuilder::class)->rebuildForEntity($largeBook);
app()->make(SearchIndex::class)->indexEntities($all);

View File

@ -1,4 +1,9 @@
{
"name": "My own book",
"description": "This is my own little book"
"description_html": "<p>This is <strong>my</strong> own little book created via the API</p>",
"default_template_id": 2427,
"tags": [
{"name": "Category", "value": "Top Content"},
{"name": "Rating", "value": "Highest"}
]
}

View File

@ -1,4 +1,8 @@
{
"name": "My updated book",
"description": "This is my book with updated details"
"description_html": "<p>This is my book with <em>updated</em> details</p>",
"default_template_id": 2427,
"tags": [
{"name": "Subject", "value": "Updates"}
]
}

View File

@ -1,7 +1,7 @@
{
"book_id": 1,
"name": "My fantastic new chapter",
"description": "This is a great new chapter that I've created via the API",
"description_html": "<p>This is a <strong>great new chapter</strong> that I've created via the API</p>",
"priority": 15,
"tags": [
{"name": "Category", "value": "Top Content"},

View File

@ -1,7 +1,7 @@
{
"book_id": 1,
"name": "My fantastic updated chapter",
"description": "This is an updated chapter that I've altered via the API",
"description_html": "<p>This is an <strong>updated chapter</strong> that I've altered via the API</p>",
"priority": 16,
"tags": [
{"name": "Category", "value": "Kinda Good Content"},

View File

@ -1,5 +1,8 @@
{
"name": "My shelf",
"description": "This is my shelf with some books",
"books": [5,1,3]
"description_html": "<p>This is <strong>my shelf</strong> with some books</p>",
"books": [5,1,3],
"tags": [
{"name": "Category", "value": "Learning"}
]
}

View File

@ -1,5 +1,5 @@
{
"name": "My updated shelf",
"description": "This is my update shelf with some books",
"description_html": "<p>This is my <em>updated shelf</em> with some books</p>",
"books": [5,1,3]
}

View File

@ -1,11 +1,26 @@
{
"id": 15,
"name": "My new book",
"slug": "my-new-book",
"description": "This is a book created via the API",
"id": 226,
"name": "My own book",
"slug": "my-own-book",
"description": "This is my own little book created via the API",
"created_at": "2023-12-22T14:22:28.000000Z",
"updated_at": "2023-12-22T14:22:28.000000Z",
"created_by": 1,
"updated_by": 1,
"owned_by": 1,
"updated_at": "2020-01-12T14:05:11.000000Z",
"created_at": "2020-01-12T14:05:11.000000Z"
"default_template_id": 2427,
"description_html": "<p>This is <strong>my<\/strong> own little book created via the API<\/p>",
"tags": [
{
"name": "Category",
"value": "Top Content",
"order": 0
},
{
"name": "Rating",
"value": "Highest",
"order": 0
}
],
"cover": null
}

View File

@ -3,6 +3,7 @@
"name": "My own book",
"slug": "my-own-book",
"description": "This is my own little book",
"description_html": "<p>This is my own <em>little</em> book</p>",
"created_at": "2020-01-12T14:09:59.000000Z",
"updated_at": "2020-01-12T14:11:51.000000Z",
"created_by": {
@ -20,6 +21,7 @@
"name": "Admin",
"slug": "admin"
},
"default_template_id": null,
"contents": [
{
"id": 50,

View File

@ -1,11 +1,21 @@
{
"id": 16,
"name": "My own book",
"slug": "my-own-book",
"description": "This is my own little book - updated",
"created_at": "2020-01-12T14:09:59.000000Z",
"updated_at": "2020-01-12T14:16:10.000000Z",
"id": 226,
"name": "My updated book",
"slug": "my-updated-book",
"description": "This is my book with updated details",
"created_at": "2023-12-22T14:22:28.000000Z",
"updated_at": "2023-12-22T14:24:07.000000Z",
"created_by": 1,
"updated_by": 1,
"owned_by": 1
"owned_by": 1,
"default_template_id": 2427,
"description_html": "<p>This is my book with <em>updated<\/em> details<\/p>",
"tags": [
{
"name": "Subject",
"value": "Updates",
"order": 0
}
],
"cover": null
}

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