mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-10-01 01:36:00 -04:00
Merge branch 'development' into release
This commit is contained in:
commit
e86a90967e
@ -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
|
||||
|
17
.github/translators.txt
vendored
17
.github/translators.txt
vendored
@ -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
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -29,4 +29,5 @@ webpack-stats.json
|
||||
.phpunit.result.cache
|
||||
.DS_Store
|
||||
phpstan.neon
|
||||
esbuild-meta.json
|
||||
esbuild-meta.json
|
||||
.phpactor.json
|
||||
|
@ -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)]
|
||||
);
|
||||
}
|
||||
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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)]);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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 = [
|
||||
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
147
app/Access/SocialDriverManager.php
Normal file
147
app/Access/SocialDriverManager.php
Normal 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);
|
||||
}
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -24,6 +24,6 @@ class WatchController extends Controller
|
||||
|
||||
$this->showSuccessNotification(trans('activities.watch_update_level_notification'));
|
||||
|
||||
return redirect()->back();
|
||||
return redirect($watchable->getUrl());
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
/**
|
||||
|
@ -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()})";
|
||||
}
|
||||
}
|
@ -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(' > ', $entityHtmls);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return implode(' > ', $this->entityLinks);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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));
|
||||
|
@ -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,
|
||||
];
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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),
|
||||
];
|
||||
|
@ -34,7 +34,7 @@ class RegenerateReferencesCommand extends Command
|
||||
DB::setDefaultConnection($this->option('database'));
|
||||
}
|
||||
|
||||
$references->updateForAllPages();
|
||||
$references->updateForAll();
|
||||
|
||||
DB::setDefaultConnection($connection);
|
||||
|
||||
|
@ -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'],
|
||||
|
@ -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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
@ -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')) {
|
||||
|
@ -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()),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
@ -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')) {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
21
app/Entities/Models/HasHtmlDescription.php
Normal file
21
app/Entities/Models/HasHtmlDescription.php
Normal 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);
|
||||
}
|
||||
}
|
@ -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'];
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
103
app/Entities/Tools/MixedEntityListLoader.php
Normal file
103
app/Entities/Tools/MixedEntityListLoader.php
Normal 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;
|
||||
}
|
||||
}
|
@ -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(' ', ' ', $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;
|
||||
}
|
||||
}
|
||||
|
85
app/Entities/Tools/PageIncludeContent.php
Normal file
85
app/Entities/Tools/PageIncludeContent.php
Normal 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;
|
||||
}
|
||||
}
|
220
app/Entities/Tools/PageIncludeParser.php
Normal file
220
app/Entities/Tools/PageIncludeParser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
30
app/Entities/Tools/PageIncludeTag.php
Normal file
30
app/Entities/Tools/PageIncludeTag.php
Normal 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] ?? '');
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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[]
|
||||
*/
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
@ -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())
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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());
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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 = [];
|
||||
|
||||
|
@ -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.
|
||||
*/
|
||||
|
@ -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);
|
||||
|
@ -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());
|
||||
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
79
app/Util/HtmlDescriptionFilter.php
Normal file
79
app/Util/HtmlDescriptionFilter.php
Normal 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
152
app/Util/HtmlDocument.php
Normal 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);
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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
790
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -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>'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -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>'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -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>'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
};
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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"}
|
||||
]
|
||||
}
|
@ -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"}
|
||||
]
|
||||
}
|
@ -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"},
|
||||
|
@ -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"},
|
||||
|
@ -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"}
|
||||
]
|
||||
}
|
@ -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]
|
||||
}
|
@ -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
|
||||
}
|
@ -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,
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user