mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-06 09:09:38 +03:00
Compare commits
162 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
825c369ad9 | ||
|
|
10bab70438 | ||
|
|
8e01345f14 | ||
|
|
f5f96f84e7 | ||
|
|
2009d4d6a8 | ||
|
|
4ccfde6d02 | ||
|
|
c4279c9697 | ||
|
|
350e0b281b | ||
|
|
08805ea3c8 | ||
|
|
48ea0bc291 | ||
|
|
a75d5b8bc1 | ||
|
|
055bbf17de | ||
|
|
be3423a16e | ||
|
|
bbb41e8b5c | ||
|
|
c290d01adb | ||
|
|
16327cf40c | ||
|
|
999d41a7f5 | ||
|
|
9ff9b9c805 | ||
|
|
8f1d8cef9e | ||
|
|
8688ad99b6 | ||
|
|
ed0718d3f7 | ||
|
|
3fdee6a93b | ||
|
|
cafea1c02d | ||
|
|
32e20e5059 | ||
|
|
c66b8ad842 | ||
|
|
c9a5c29abf | ||
|
|
12daa1c2b9 | ||
|
|
ff8daad22b | ||
|
|
1ea2ac864a | ||
|
|
ed9c013f6e | ||
|
|
ed21a6d798 | ||
|
|
b77ab6f3af | ||
|
|
546cfb0dcc | ||
|
|
483410749b | ||
|
|
c95f4ca40f | ||
|
|
222c665018 | ||
|
|
8e78b4c43e | ||
|
|
05ac0fcd1d | ||
|
|
9fa68fd8ab | ||
|
|
3886aedf54 | ||
|
|
1559b0acd1 | ||
|
|
a70ed81908 | ||
|
|
2460e7c56e | ||
|
|
779f09bff6 | ||
|
|
43a72fb9a5 | ||
|
|
4137cf9c8f | ||
|
|
16af833124 | ||
|
|
47f082c085 | ||
|
|
fee9045dac | ||
|
|
06901b878f | ||
|
|
e9a19d5878 | ||
|
|
adf0baebb9 | ||
|
|
5c92b72fdd | ||
|
|
24e6dc4b37 | ||
|
|
4a8f70240f | ||
|
|
64c783c6f8 | ||
|
|
2a849894be | ||
|
|
415663a9bc | ||
|
|
1dc094ffaf | ||
|
|
3e9e196cda | ||
|
|
5903823eed | ||
|
|
9441e32c69 | ||
|
|
530fc37067 | ||
|
|
8fb9d9d4c2 | ||
|
|
eff7aa0f73 | ||
|
|
14ecb19b05 | ||
|
|
0fc02a2532 | ||
|
|
8c6b116472 | ||
|
|
69c8ff5c2d | ||
|
|
788327fffb | ||
|
|
655ae5ecae | ||
|
|
d5a91d0d35 | ||
|
|
369e499dce | ||
|
|
655815de6d | ||
|
|
a4fd825fe2 | ||
|
|
496b4264d9 | ||
|
|
57284bb869 | ||
|
|
adf1806fea | ||
|
|
2dc454d206 | ||
|
|
c1552fb799 | ||
|
|
91d8d6eaaa | ||
|
|
afbbcafd44 | ||
|
|
d94762549a | ||
|
|
b4d9029dc3 | ||
|
|
70bfebcd7c | ||
|
|
457adc1fee | ||
|
|
e86a90967e | ||
|
|
b191d8f99f | ||
|
|
c017f5bed1 | ||
|
|
5b1929a39a | ||
|
|
02d94c8798 | ||
|
|
88ee33ee49 | ||
|
|
529f7bd1bc | ||
|
|
3668949705 | ||
|
|
7cd0629a75 | ||
|
|
fb3cfaf7c7 | ||
|
|
2a7a81e749 | ||
|
|
00ae04e0bd | ||
|
|
ed5d67e609 | ||
|
|
a21ca44633 | ||
|
|
7fd6d5b2cc | ||
|
|
077b9709d4 | ||
|
|
2fbed3919b | ||
|
|
c07aa056c2 | ||
|
|
bc354e8b12 | ||
|
|
307fae39c4 | ||
|
|
c622b785a9 | ||
|
|
569542f0bb | ||
|
|
fc2e8ed315 | ||
|
|
0c4dd7874c | ||
|
|
7250671889 | ||
|
|
5395ca2f00 | ||
|
|
56d07f1909 | ||
|
|
4896c4047f | ||
|
|
3af07addf6 | ||
|
|
2f3806244c | ||
|
|
2081a783f3 | ||
|
|
d75eb06777 | ||
|
|
4017048555 | ||
|
|
7ebe7d4e58 | ||
|
|
d61f42a377 | ||
|
|
968bc8cdf3 | ||
|
|
c13fd2a9e6 | ||
|
|
45ce7a7126 | ||
|
|
11955e270c | ||
|
|
33374524bf | ||
|
|
8cbaa3e27c | ||
|
|
4c0b7f3123 | ||
|
|
7312300d53 | ||
|
|
81d256aebd | ||
|
|
a72e0fee70 | ||
|
|
f32cfb4292 | ||
|
|
bba7dcce49 | ||
|
|
cc10d1ddfc | ||
|
|
0254527bd9 | ||
|
|
11853361b0 | ||
|
|
596f7314cd | ||
|
|
1011d61713 | ||
|
|
652d5417bf | ||
|
|
b569827114 | ||
|
|
71c93c8878 | ||
|
|
4874dc1304 | ||
|
|
c88eb729a4 | ||
|
|
75936454cc | ||
|
|
04d21c8a97 | ||
|
|
5d08f7cf14 | ||
|
|
22a9cf1e48 | ||
|
|
37a17e858a | ||
|
|
eab9c1081e | ||
|
|
db7b11fe93 | ||
|
|
3a6f50e668 | ||
|
|
76417efd6f | ||
|
|
d41fd7a8dd | ||
|
|
65ac197be4 | ||
|
|
f8ebbb7553 | ||
|
|
a0942ef441 | ||
|
|
6b55104ecb | ||
|
|
ac519b3009 | ||
|
|
ec3b06d83f | ||
|
|
99ae759eff | ||
|
|
1dbc3588cf | ||
|
|
3599a962a3 |
@@ -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
|
||||
|
||||
39
.github/translators.txt
vendored
39
.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
|
||||
@@ -324,7 +324,7 @@ Robin Flikkema (RobinFlikkema) :: Dutch
|
||||
Michal Gurcik (mgurcik) :: Slovak
|
||||
Pooyan Arab (pooyanarab) :: Persian
|
||||
Ochi Darma Putra (troke12) :: Indonesian
|
||||
H.-H. Peng (Hsins) :: Chinese Traditional
|
||||
Hsin-Hsiang Peng (Hsins) :: Chinese Traditional
|
||||
Mosi Wang (mosiwang) :: Chinese Traditional
|
||||
骆言 (LawssssCat) :: Chinese Simplified
|
||||
Stickers Gaming Shøw (StickerSGSHOW) :: French
|
||||
@@ -371,3 +371,38 @@ 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 Kobližek (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
|
||||
Kasper Alsøe (zeonos) :: Danish
|
||||
sultani :: Persian
|
||||
renge :: Korean
|
||||
TheGatesDev (thegatesdev) :: Dutch
|
||||
Irdi (irdiOL) :: Albanian
|
||||
KateBarber :: Welsh
|
||||
Twister (theuncles75) :: Hebrew
|
||||
algernon19 :: Hungarian
|
||||
Ivan Krstic (ikrstic) :: Serbian (Cyrillic)
|
||||
Show :: Russian
|
||||
xBahamut :: Portuguese, Brazilian
|
||||
Pavle Knežević (pavleknezzevic) :: Serbian (Cyrillic)
|
||||
Vanja Cvelbar (b100w11) :: Slovenian
|
||||
simonpct :: French
|
||||
Honza Nagy (honza.nagy) :: Czech
|
||||
asd20752 :: Norwegian Bokmal
|
||||
Jan Picka (polipones) :: Czech
|
||||
diogoalex991 :: Portuguese
|
||||
Ehsan Sadeghi (ehsansadeghi) :: Persian
|
||||
ka_picit :: Danish
|
||||
|
||||
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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,15 +83,9 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
|
||||
/**
|
||||
* Checks a provider response for errors.
|
||||
*
|
||||
* @param ResponseInterface $response
|
||||
* @param array|string $data Parsed response data
|
||||
*
|
||||
* @throws IdentityProviderException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function checkResponse(ResponseInterface $response, $data)
|
||||
protected function checkResponse(ResponseInterface $response, $data): void
|
||||
{
|
||||
if ($response->getStatusCode() >= 400 || isset($data['error'])) {
|
||||
throw new IdentityProviderException(
|
||||
@@ -105,13 +99,8 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
/**
|
||||
* Generates a resource owner object from a successful resource owner
|
||||
* details request.
|
||||
*
|
||||
* @param array $response
|
||||
* @param AccessToken $token
|
||||
*
|
||||
* @return ResourceOwnerInterface
|
||||
*/
|
||||
protected function createResourceOwner(array $response, AccessToken $token)
|
||||
protected function createResourceOwner(array $response, AccessToken $token): ResourceOwnerInterface
|
||||
{
|
||||
return new GenericResourceOwner($response, '');
|
||||
}
|
||||
@@ -121,14 +110,18 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
*
|
||||
* The grant that was used to fetch the response can be used to provide
|
||||
* additional context.
|
||||
*
|
||||
* @param array $response
|
||||
* @param AbstractGrant $grant
|
||||
*
|
||||
* @return OidcAccessToken
|
||||
*/
|
||||
protected function createAccessToken(array $response, AbstractGrant $grant)
|
||||
protected function createAccessToken(array $response, AbstractGrant $grant): OidcAccessToken
|
||||
{
|
||||
return new OidcAccessToken($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the method used for PKCE code verifier hashing, which is passed
|
||||
* in the "code_challenge_method" parameter in the authorization request.
|
||||
*/
|
||||
protected function getPkceMethod(): string
|
||||
{
|
||||
return static::PKCE_METHOD_S256;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ class OidcService
|
||||
|
||||
/**
|
||||
* Initiate an authorization flow.
|
||||
* Provides back an authorize redirect URL, in addition to other
|
||||
* details which may be required for the auth flow.
|
||||
*
|
||||
* @throws OidcException
|
||||
*
|
||||
@@ -42,8 +44,12 @@ class OidcService
|
||||
{
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
|
||||
$url = $provider->getAuthorizationUrl();
|
||||
session()->put('oidc_pkce_code', $provider->getPkceCode() ?? '');
|
||||
|
||||
return [
|
||||
'url' => $provider->getAuthorizationUrl(),
|
||||
'url' => $url,
|
||||
'state' => $provider->getState(),
|
||||
];
|
||||
}
|
||||
@@ -63,6 +69,10 @@ class OidcService
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
|
||||
// Set PKCE code flashed at login
|
||||
$pkceCode = session()->pull('oidc_pkce_code', '');
|
||||
$provider->setPkceCode($pkceCode);
|
||||
|
||||
// Try to exchange authorization code for access token
|
||||
$accessToken = $provider->getAccessToken('authorization_code', [
|
||||
'code' => $authorizationCode,
|
||||
@@ -84,6 +94,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 +111,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 +236,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 +305,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,20 +14,14 @@ use Illuminate\Support\Str;
|
||||
|
||||
class RegistrationService
|
||||
{
|
||||
protected $userRepo;
|
||||
protected $emailConfirmationService;
|
||||
|
||||
/**
|
||||
* RegistrationService constructor.
|
||||
*/
|
||||
public function __construct(UserRepo $userRepo, EmailConfirmationService $emailConfirmationService)
|
||||
{
|
||||
$this->userRepo = $userRepo;
|
||||
$this->emailConfirmationService = $emailConfirmationService;
|
||||
public function __construct(
|
||||
protected UserRepo $userRepo,
|
||||
protected EmailConfirmationService $emailConfirmationService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not registrations are allowed in the app settings.
|
||||
* Check if registrations are allowed in the app settings.
|
||||
*
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
@@ -84,6 +78,7 @@ class RegistrationService
|
||||
public function registerUser(array $userData, ?SocialAccount $socialAccount = null, bool $emailConfirmed = false): User
|
||||
{
|
||||
$userEmail = $userData['email'];
|
||||
$authSystem = $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver();
|
||||
|
||||
// Email restriction
|
||||
$this->ensureEmailDomainAllowed($userEmail);
|
||||
@@ -94,6 +89,12 @@ class RegistrationService
|
||||
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login');
|
||||
}
|
||||
|
||||
/** @var ?bool $shouldRegister */
|
||||
$shouldRegister = Theme::dispatch(ThemeEvents::AUTH_PRE_REGISTER, $authSystem, $userData);
|
||||
if ($shouldRegister === false) {
|
||||
throw new UserRegistrationException(trans('errors.auth_pre_register_theme_prevention'), '/login');
|
||||
}
|
||||
|
||||
// Create the user
|
||||
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
|
||||
$newUser->attachDefaultRole();
|
||||
@@ -104,7 +105,7 @@ class RegistrationService
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
|
||||
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver(), $newUser);
|
||||
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $authSystem, $newUser);
|
||||
|
||||
// Start email confirmation flow if required
|
||||
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
|
||||
@@ -138,7 +139,7 @@ class RegistrationService
|
||||
}
|
||||
|
||||
$restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
|
||||
$userEmailDomain = $domain = mb_substr(mb_strrchr($userEmail, '@'), 1);
|
||||
$userEmailDomain = mb_substr(mb_strrchr($userEmail, '@'), 1);
|
||||
if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
|
||||
$redirect = $this->registrationAllowed() ? '/register' : '/login';
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\MixedEntityListLoader;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -14,11 +15,10 @@ use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class ActivityQueries
|
||||
{
|
||||
protected PermissionApplicator $permissions;
|
||||
|
||||
public function __construct(PermissionApplicator $permissions)
|
||||
{
|
||||
$this->permissions = $permissions;
|
||||
public function __construct(
|
||||
protected PermissionApplicator $permissions,
|
||||
protected MixedEntityListLoader $listLoader,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,11 +29,13 @@ class ActivityQueries
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->with(['user', 'entity'])
|
||||
->with(['user'])
|
||||
->skip($count * $page)
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
$this->listLoader->loadIntoRelations($activityList->all(), 'entity', false);
|
||||
|
||||
return $this->filterSimilar($activityList);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace BookStack\Activity;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Facades\Activity as ActivityService;
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
use BookStack\Util\HtmlDescriptionFilter;
|
||||
|
||||
class CommentRepo
|
||||
{
|
||||
@@ -20,13 +20,12 @@ class CommentRepo
|
||||
/**
|
||||
* Create a new comment on an entity.
|
||||
*/
|
||||
public function create(Entity $entity, string $text, ?int $parent_id): Comment
|
||||
public function create(Entity $entity, string $html, ?int $parent_id): Comment
|
||||
{
|
||||
$userId = user()->id;
|
||||
$comment = new Comment();
|
||||
|
||||
$comment->text = $text;
|
||||
$comment->html = $this->commentToHtml($text);
|
||||
$comment->html = HtmlDescriptionFilter::filterFromString($html);
|
||||
$comment->created_by = $userId;
|
||||
$comment->updated_by = $userId;
|
||||
$comment->local_id = $this->getNextLocalId($entity);
|
||||
@@ -42,11 +41,10 @@ class CommentRepo
|
||||
/**
|
||||
* Update an existing comment.
|
||||
*/
|
||||
public function update(Comment $comment, string $text): Comment
|
||||
public function update(Comment $comment, string $html): Comment
|
||||
{
|
||||
$comment->updated_by = user()->id;
|
||||
$comment->text = $text;
|
||||
$comment->html = $this->commentToHtml($text);
|
||||
$comment->html = HtmlDescriptionFilter::filterFromString($html);
|
||||
$comment->save();
|
||||
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
@@ -64,20 +62,6 @@ class CommentRepo
|
||||
ActivityService::add(ActivityType::COMMENT_DELETE, $comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given comment Markdown to HTML.
|
||||
*/
|
||||
public function commentToHtml(string $commentText): string
|
||||
{
|
||||
$converter = new CommonMarkConverter([
|
||||
'html_input' => 'strip',
|
||||
'max_nesting_level' => 10,
|
||||
'allow_unsafe_links' => false,
|
||||
]);
|
||||
|
||||
return $converter->convert($commentText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next local ID relative to the linked entity.
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\CommentRepo;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -11,7 +11,8 @@ use Illuminate\Validation\ValidationException;
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected CommentRepo $commentRepo
|
||||
protected CommentRepo $commentRepo,
|
||||
protected PageQueries $pageQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -22,12 +23,12 @@ class CommentController extends Controller
|
||||
*/
|
||||
public function savePageComment(Request $request, int $pageId)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'text' => ['required', 'string'],
|
||||
$input = $this->validate($request, [
|
||||
'html' => ['required', 'string'],
|
||||
'parent_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$page = Page::visible()->find($pageId);
|
||||
$page = $this->pageQueries->findVisibleById($pageId);
|
||||
if ($page === null) {
|
||||
return response('Not found', 404);
|
||||
}
|
||||
@@ -39,7 +40,7 @@ class CommentController extends Controller
|
||||
|
||||
// Create a new comment.
|
||||
$this->checkPermission('comment-create-all');
|
||||
$comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id'));
|
||||
$comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null);
|
||||
|
||||
return view('comments.comment-branch', [
|
||||
'readOnly' => false,
|
||||
@@ -57,17 +58,20 @@ class CommentController extends Controller
|
||||
*/
|
||||
public function update(Request $request, int $commentId)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'text' => ['required', 'string'],
|
||||
$input = $this->validate($request, [
|
||||
'html' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$comment = $this->commentRepo->getById($commentId);
|
||||
$this->checkOwnablePermission('page-view', $comment->entity);
|
||||
$this->checkOwnablePermission('comment-update', $comment);
|
||||
|
||||
$comment = $this->commentRepo->update($comment, $request->get('text'));
|
||||
$comment = $this->commentRepo->update($comment, $input['html']);
|
||||
|
||||
return view('comments.comment', ['comment' => $comment, 'readOnly' => false]);
|
||||
return view('comments.comment', [
|
||||
'comment' => $comment,
|
||||
'readOnly' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
|
||||
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\Queries\QueryTopFavourites;
|
||||
use BookStack\Entities\Tools\MixedEntityRequestHelper;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -20,11 +17,11 @@ class FavouriteController extends Controller
|
||||
/**
|
||||
* Show a listing of all favourite items for the current user.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
public function index(Request $request, QueryTopFavourites $topFavourites)
|
||||
{
|
||||
$viewCount = 20;
|
||||
$page = intval($request->get('page', 1));
|
||||
$favourites = (new TopFavourites())->run($viewCount + 1, (($page - 1) * $viewCount));
|
||||
$favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount));
|
||||
|
||||
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,13 +4,14 @@ namespace BookStack\Activity\Models;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $text
|
||||
* @property string $text - Deprecated & now unused (#4821)
|
||||
* @property string $html
|
||||
* @property int|null $parent_id - Relates to local_id, not id
|
||||
* @property int $local_id
|
||||
@@ -24,7 +25,7 @@ class Comment extends Model implements Loggable
|
||||
use HasFactory;
|
||||
use HasCreatorAndUpdater;
|
||||
|
||||
protected $fillable = ['text', 'parent_id'];
|
||||
protected $fillable = ['parent_id'];
|
||||
protected $appends = ['created', 'updated'];
|
||||
|
||||
/**
|
||||
@@ -73,4 +74,9 @@ class Comment extends Model implements Loggable
|
||||
{
|
||||
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
|
||||
}
|
||||
|
||||
public function safeHtml(): string
|
||||
{
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -41,6 +41,17 @@ class CommentTree
|
||||
return $this->tree;
|
||||
}
|
||||
|
||||
public function canUpdateAny(): bool
|
||||
{
|
||||
foreach ($this->comments as $comment) {
|
||||
if (userCan('comment-update', $comment)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Comment[] $comments
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,6 @@ use Exception;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
namespace BookStack\App;
|
||||
|
||||
use BookStack\Activity\ActivityQueries;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\RecentlyViewed;
|
||||
use BookStack\Entities\Queries\TopFavourites;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Queries\QueryRecentlyViewed;
|
||||
use BookStack\Entities\Queries\QueryTopFavourites;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Uploads\FaviconHandler;
|
||||
@@ -17,18 +15,25 @@ use Illuminate\Http\Request;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected EntityQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the homepage.
|
||||
*/
|
||||
public function index(Request $request, ActivityQueries $activities)
|
||||
{
|
||||
public function index(
|
||||
Request $request,
|
||||
ActivityQueries $activities,
|
||||
QueryRecentlyViewed $recentlyViewed,
|
||||
QueryTopFavourites $topFavourites,
|
||||
) {
|
||||
$activity = $activities->latest(10);
|
||||
$draftPages = [];
|
||||
|
||||
if ($this->isSignedIn()) {
|
||||
$draftPages = Page::visible()
|
||||
->where('draft', '=', true)
|
||||
->where('created_by', '=', user()->id)
|
||||
$draftPages = $this->queries->pages->currentUserDraftsForList()
|
||||
->orderBy('updated_at', 'desc')
|
||||
->with('book')
|
||||
->take(6)
|
||||
@@ -37,14 +42,13 @@ class HomeController extends Controller
|
||||
|
||||
$recentFactor = count($draftPages) > 0 ? 0.5 : 1;
|
||||
$recents = $this->isSignedIn() ?
|
||||
(new RecentlyViewed())->run(12 * $recentFactor, 1)
|
||||
: Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
|
||||
$favourites = (new TopFavourites())->run(6);
|
||||
$recentlyUpdatedPages = Page::visible()->with('book')
|
||||
$recentlyViewed->run(12 * $recentFactor, 1)
|
||||
: $this->queries->books->visibleForList()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
|
||||
$favourites = $topFavourites->run(6);
|
||||
$recentlyUpdatedPages = $this->queries->pages->visibleForList()
|
||||
->where('draft', false)
|
||||
->orderBy('updated_at', 'desc')
|
||||
->take($favourites->count() > 0 ? 5 : 10)
|
||||
->select(Page::$listAttributes)
|
||||
->get();
|
||||
|
||||
$homepageOptions = ['default', 'books', 'bookshelves', 'page'];
|
||||
@@ -78,14 +82,18 @@ class HomeController extends Controller
|
||||
}
|
||||
|
||||
if ($homepageOption === 'bookshelves') {
|
||||
$shelves = app()->make(BookshelfRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
|
||||
$shelves = $this->queries->shelves->visibleForListWithCover()
|
||||
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
||||
->paginate(18);
|
||||
$data = array_merge($commonData, ['shelves' => $shelves]);
|
||||
|
||||
return view('home.shelves', $data);
|
||||
}
|
||||
|
||||
if ($homepageOption === 'books') {
|
||||
$books = app()->make(BookRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
|
||||
$books = $this->queries->books->visibleForListWithCover()
|
||||
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
||||
->paginate(18);
|
||||
$data = array_merge($commonData, ['books' => $books]);
|
||||
|
||||
return view('home.books', $data);
|
||||
@@ -95,7 +103,7 @@ class HomeController extends Controller
|
||||
$homepageSetting = setting('app-homepage', '0:');
|
||||
$id = intval(explode(':', $homepageSetting)[0]);
|
||||
/** @var Page $customHomepage */
|
||||
$customHomepage = Page::query()->where('draft', '=', false)->findOrFail($id);
|
||||
$customHomepage = $this->queries->pages->start()->where('draft', '=', false)->findOrFail($id);
|
||||
$pageContent = new PageContent($customHomepage);
|
||||
$customHomepage->html = $pageContent->render(false);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class PwaManifestBuilder
|
||||
"launch_handler" => [
|
||||
"client_mode" => "focus-existing"
|
||||
],
|
||||
"orientation" => "portrait",
|
||||
"orientation" => "any",
|
||||
"icons" => [
|
||||
[
|
||||
"src" => setting('app-icon-32') ?: url('/icon-32.png'),
|
||||
|
||||
@@ -173,6 +173,8 @@ return [
|
||||
|
||||
// List of URIs that should not be collected
|
||||
'except' => [
|
||||
'/uploads/images/.*', // BookStack image requests
|
||||
|
||||
'/horizon/.*', // Laravel Horizon requests
|
||||
'/telescope/.*', // Laravel Telescope requests
|
||||
'/_debugbar/.*', // Laravel DebugBar requests
|
||||
|
||||
@@ -58,6 +58,7 @@ return [
|
||||
'endpoint' => env('STORAGE_S3_ENDPOINT', null),
|
||||
'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null,
|
||||
'throw' => true,
|
||||
'stream_reads' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@@ -28,7 +28,7 @@ class CopyShelfPermissionsCommand extends Command
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(PermissionsUpdater $permissionsUpdater): int
|
||||
public function handle(PermissionsUpdater $permissionsUpdater, BookshelfQueries $queries): int
|
||||
{
|
||||
$shelfSlug = $this->option('slug');
|
||||
$cascadeAll = $this->option('all');
|
||||
@@ -51,11 +51,11 @@ class CopyShelfPermissionsCommand extends Command
|
||||
return 0;
|
||||
}
|
||||
|
||||
$shelves = Bookshelf::query()->get(['id']);
|
||||
$shelves = $queries->start()->get(['id']);
|
||||
}
|
||||
|
||||
if ($shelfSlug) {
|
||||
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id']);
|
||||
$shelves = $queries->start()->where('slug', '=', $shelfSlug)->get(['id']);
|
||||
if ($shelves->count() === 0) {
|
||||
$this->info('No shelves found with the given slug.');
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Activity\CommentRepo;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegenerateCommentContentCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bookstack:regenerate-comment-content
|
||||
{--database= : The database connection to use}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Regenerate the stored HTML of all comments';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(CommentRepo $commentRepo): int
|
||||
{
|
||||
$connection = DB::getDefaultConnection();
|
||||
if ($this->option('database') !== null) {
|
||||
DB::setDefaultConnection($this->option('database'));
|
||||
}
|
||||
|
||||
Comment::query()->chunk(100, function ($comments) use ($commentRepo) {
|
||||
foreach ($comments as $comment) {
|
||||
$comment->html = $commentRepo->commentToHtml($comment->text);
|
||||
$comment->save();
|
||||
}
|
||||
});
|
||||
|
||||
DB::setDefaultConnection($connection);
|
||||
$this->comment('Comment HTML content has been regenerated');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\Api\ApiEntityListFormatter;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Http\ApiController;
|
||||
@@ -14,11 +15,10 @@ 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,
|
||||
protected BookQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,7 +26,9 @@ class BookApiController extends ApiController
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$books = Book::visible();
|
||||
$books = $this->queries
|
||||
->visibleForList()
|
||||
->addSelect(['created_by', 'updated_by']);
|
||||
|
||||
return $this->apiListingResponse($books, [
|
||||
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by',
|
||||
@@ -47,7 +49,7 @@ class BookApiController extends ApiController
|
||||
|
||||
$book = $this->bookRepo->create($requestData);
|
||||
|
||||
return response()->json($book);
|
||||
return response()->json($this->forJsonDisplay($book));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,7 +60,9 @@ class BookApiController extends ApiController
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$book = $this->forJsonDisplay($book);
|
||||
$book->load(['createdBy', 'updatedBy', 'ownedBy']);
|
||||
|
||||
$contents = (new BookContents($book))->getTree(true, false)->all();
|
||||
$contentsApiData = (new ApiEntityListFormatter($contents))
|
||||
@@ -83,13 +87,13 @@ class BookApiController extends ApiController
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
|
||||
$requestData = $this->validate($request, $this->rules()['update']);
|
||||
$book = $this->bookRepo->update($book, $requestData);
|
||||
|
||||
return response()->json($book);
|
||||
return response()->json($this->forJsonDisplay($book));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,7 +104,7 @@ class BookApiController extends ApiController
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission('book-delete', $book);
|
||||
|
||||
$this->bookRepo->destroy($book);
|
||||
@@ -108,20 +112,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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ use BookStack\Activity\ActivityQueries;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
@@ -24,15 +25,13 @@ 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 BookQueries $queries,
|
||||
protected BookshelfQueries $shelfQueries,
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,10 +46,12 @@ class BookController extends Controller
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
]);
|
||||
|
||||
$books = $this->bookRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
|
||||
$recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed(4) : false;
|
||||
$popular = $this->bookRepo->getPopular(4);
|
||||
$new = $this->bookRepo->getRecentlyCreated(4);
|
||||
$books = $this->queries->visibleForListWithCover()
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
||||
->paginate(18);
|
||||
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
|
||||
$popular = $this->queries->popularForList()->take(4)->get();
|
||||
$new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get();
|
||||
|
||||
$this->shelfContext->clearShelfContext();
|
||||
|
||||
@@ -75,7 +76,7 @@ class BookController extends Controller
|
||||
|
||||
$bookshelf = null;
|
||||
if ($shelfSlug !== null) {
|
||||
$bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
|
||||
$bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
|
||||
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
|
||||
}
|
||||
|
||||
@@ -96,15 +97,16 @@ 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;
|
||||
if ($shelfSlug !== null) {
|
||||
$bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
|
||||
$bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
|
||||
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
|
||||
}
|
||||
|
||||
@@ -123,7 +125,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($slug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$bookChildren = (new BookContents($book))->getTree(true);
|
||||
$bookParentShelves = $book->shelves()->scopes('visible')->get();
|
||||
|
||||
@@ -141,7 +143,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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -150,7 +152,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function edit(string $slug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($slug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
$this->setPageTitle(trans('entities.books_edit_named', ['bookName' => $book->getShortName()]));
|
||||
|
||||
@@ -166,14 +168,15 @@ class BookController extends Controller
|
||||
*/
|
||||
public function update(Request $request, string $slug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($slug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$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')) {
|
||||
@@ -192,7 +195,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function showDelete(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-delete', $book);
|
||||
$this->setPageTitle(trans('entities.books_delete_named', ['bookName' => $book->getShortName()]));
|
||||
|
||||
@@ -206,7 +209,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function destroy(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-delete', $book);
|
||||
|
||||
$this->bookRepo->destroy($book);
|
||||
@@ -221,7 +224,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function showCopy(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-view', $book);
|
||||
|
||||
session()->flashInput(['name' => $book->name]);
|
||||
@@ -238,7 +241,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function copy(Request $request, Cloner $cloner, string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-view', $book);
|
||||
$this->checkPermission('book-create-all');
|
||||
|
||||
@@ -254,7 +257,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function convertToShelf(HierarchyTransformer $transformer, string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
$this->checkOwnablePermission('book-delete', $book);
|
||||
$this->checkPermission('bookshelf-create-all');
|
||||
|
||||
@@ -2,18 +2,17 @@
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Http\ApiController;
|
||||
use Throwable;
|
||||
|
||||
class BookExportApiController extends ApiController
|
||||
{
|
||||
protected $exportFormatter;
|
||||
|
||||
public function __construct(ExportFormatter $exportFormatter)
|
||||
{
|
||||
$this->exportFormatter = $exportFormatter;
|
||||
public function __construct(
|
||||
protected ExportFormatter $exportFormatter,
|
||||
protected BookQueries $queries,
|
||||
) {
|
||||
$this->middleware('can:content-export');
|
||||
}
|
||||
|
||||
@@ -24,7 +23,7 @@ class BookExportApiController extends ApiController
|
||||
*/
|
||||
public function exportPdf(int $id)
|
||||
{
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail($id);
|
||||
$pdfContent = $this->exportFormatter->bookToPdf($book);
|
||||
|
||||
return $this->download()->directly($pdfContent, $book->slug . '.pdf');
|
||||
@@ -37,7 +36,7 @@ class BookExportApiController extends ApiController
|
||||
*/
|
||||
public function exportHtml(int $id)
|
||||
{
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail($id);
|
||||
$htmlContent = $this->exportFormatter->bookToContainedHtml($book);
|
||||
|
||||
return $this->download()->directly($htmlContent, $book->slug . '.html');
|
||||
@@ -48,7 +47,7 @@ class BookExportApiController extends ApiController
|
||||
*/
|
||||
public function exportPlainText(int $id)
|
||||
{
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail($id);
|
||||
$textContent = $this->exportFormatter->bookToPlainText($book);
|
||||
|
||||
return $this->download()->directly($textContent, $book->slug . '.txt');
|
||||
@@ -59,7 +58,7 @@ class BookExportApiController extends ApiController
|
||||
*/
|
||||
public function exportMarkdown(int $id)
|
||||
{
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail($id);
|
||||
$markdown = $this->exportFormatter->bookToMarkdown($book);
|
||||
|
||||
return $this->download()->directly($markdown, $book->slug . '.md');
|
||||
|
||||
@@ -2,23 +2,17 @@
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Http\Controller;
|
||||
use Throwable;
|
||||
|
||||
class BookExportController extends Controller
|
||||
{
|
||||
protected $bookRepo;
|
||||
protected $exportFormatter;
|
||||
|
||||
/**
|
||||
* BookExportController constructor.
|
||||
*/
|
||||
public function __construct(BookRepo $bookRepo, ExportFormatter $exportFormatter)
|
||||
{
|
||||
$this->bookRepo = $bookRepo;
|
||||
$this->exportFormatter = $exportFormatter;
|
||||
public function __construct(
|
||||
protected BookQueries $queries,
|
||||
protected ExportFormatter $exportFormatter,
|
||||
) {
|
||||
$this->middleware('can:content-export');
|
||||
}
|
||||
|
||||
@@ -29,7 +23,7 @@ class BookExportController extends Controller
|
||||
*/
|
||||
public function pdf(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$pdfContent = $this->exportFormatter->bookToPdf($book);
|
||||
|
||||
return $this->download()->directly($pdfContent, $bookSlug . '.pdf');
|
||||
@@ -42,7 +36,7 @@ class BookExportController extends Controller
|
||||
*/
|
||||
public function html(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$htmlContent = $this->exportFormatter->bookToContainedHtml($book);
|
||||
|
||||
return $this->download()->directly($htmlContent, $bookSlug . '.html');
|
||||
@@ -53,7 +47,7 @@ class BookExportController extends Controller
|
||||
*/
|
||||
public function plainText(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$textContent = $this->exportFormatter->bookToPlainText($book);
|
||||
|
||||
return $this->download()->directly($textContent, $bookSlug . '.txt');
|
||||
@@ -64,7 +58,7 @@ class BookExportController extends Controller
|
||||
*/
|
||||
public function markdown(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$textContent = $this->exportFormatter->bookToMarkdown($book);
|
||||
|
||||
return $this->download()->directly($textContent, $bookSlug . '.md');
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\BookSortMap;
|
||||
use BookStack\Facades\Activity;
|
||||
@@ -12,11 +12,9 @@ use Illuminate\Http\Request;
|
||||
|
||||
class BookSortController extends Controller
|
||||
{
|
||||
protected $bookRepo;
|
||||
|
||||
public function __construct(BookRepo $bookRepo)
|
||||
{
|
||||
$this->bookRepo = $bookRepo;
|
||||
public function __construct(
|
||||
protected BookQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,7 +22,7 @@ class BookSortController extends Controller
|
||||
*/
|
||||
public function show(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
|
||||
$bookChildren = (new BookContents($book))->getTree(false);
|
||||
@@ -40,7 +38,7 @@ class BookSortController extends Controller
|
||||
*/
|
||||
public function showItem(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$bookChildren = (new BookContents($book))->getTree();
|
||||
|
||||
return view('books.parts.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
|
||||
@@ -51,7 +49,7 @@ class BookSortController extends Controller
|
||||
*/
|
||||
public function update(Request $request, string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
|
||||
// Return if no map sent
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Http\ApiController;
|
||||
use Exception;
|
||||
@@ -12,11 +13,10 @@ 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,
|
||||
protected BookshelfQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,7 +24,9 @@ class BookshelfApiController extends ApiController
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$shelves = Bookshelf::visible();
|
||||
$shelves = $this->queries
|
||||
->visibleForList()
|
||||
->addSelect(['created_by', 'updated_by']);
|
||||
|
||||
return $this->apiListingResponse($shelves, [
|
||||
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by',
|
||||
@@ -48,7 +50,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 +58,14 @@ class BookshelfApiController extends ApiController
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$shelf = Bookshelf::visible()->with([
|
||||
'tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy',
|
||||
$shelf = $this->queries->findVisibleByIdOrFail(intval($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);
|
||||
}
|
||||
@@ -78,7 +82,7 @@ class BookshelfApiController extends ApiController
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$shelf = Bookshelf::visible()->findOrFail($id);
|
||||
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission('bookshelf-update', $shelf);
|
||||
|
||||
$requestData = $this->validate($request, $this->rules()['update']);
|
||||
@@ -86,7 +90,7 @@ class BookshelfApiController extends ApiController
|
||||
|
||||
$shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
|
||||
|
||||
return response()->json($shelf);
|
||||
return response()->json($this->forJsonDisplay($shelf));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +101,7 @@ class BookshelfApiController extends ApiController
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
$shelf = Bookshelf::visible()->findOrFail($id);
|
||||
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission('bookshelf-delete', $shelf);
|
||||
|
||||
$this->bookshelfRepo->destroy($shelf);
|
||||
@@ -105,22 +109,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()),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Activity\ActivityQueries;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Entities\Tools\ShelfContext;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
@@ -18,15 +19,13 @@ 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 BookshelfQueries $queries,
|
||||
protected BookQueries $bookQueries,
|
||||
protected ShelfContext $shelfContext,
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,10 +40,15 @@ class BookshelfController extends Controller
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
]);
|
||||
|
||||
$shelves = $this->shelfRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
|
||||
$recents = $this->isSignedIn() ? $this->shelfRepo->getRecentlyViewed(4) : false;
|
||||
$popular = $this->shelfRepo->getPopular(4);
|
||||
$new = $this->shelfRepo->getRecentlyCreated(4);
|
||||
$shelves = $this->queries->visibleForListWithCover()
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
||||
->paginate(18);
|
||||
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
|
||||
$popular = $this->queries->popularForList()->get();
|
||||
$new = $this->queries->visibleForList()
|
||||
->orderBy('created_at', 'desc')
|
||||
->take(4)
|
||||
->get();
|
||||
|
||||
$this->shelfContext->clearShelfContext();
|
||||
$this->setPageTitle(trans('entities.shelves'));
|
||||
@@ -65,7 +69,7 @@ class BookshelfController extends Controller
|
||||
public function create()
|
||||
{
|
||||
$this->checkPermission('bookshelf-create-all');
|
||||
$books = Book::visible()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
|
||||
$books = $this->bookQueries->visibleForList()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
|
||||
$this->setPageTitle(trans('entities.shelves_create'));
|
||||
|
||||
return view('shelves.create', ['books' => $books]);
|
||||
@@ -81,10 +85,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', ''));
|
||||
@@ -100,7 +104,7 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('bookshelf-view', $shelf);
|
||||
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
|
||||
@@ -129,7 +133,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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -138,11 +142,14 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function edit(string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('bookshelf-update', $shelf);
|
||||
|
||||
$shelfBookIds = $shelf->books()->get(['id'])->pluck('id');
|
||||
$books = Book::visible()->whereNotIn('id', $shelfBookIds)->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
|
||||
$books = $this->bookQueries->visibleForList()
|
||||
->whereNotIn('id', $shelfBookIds)
|
||||
->orderBy('name')
|
||||
->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
|
||||
|
||||
$this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()]));
|
||||
|
||||
@@ -161,13 +168,13 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function update(Request $request, string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($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')) {
|
||||
@@ -187,7 +194,7 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function showDelete(string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('bookshelf-delete', $shelf);
|
||||
|
||||
$this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()]));
|
||||
@@ -202,7 +209,7 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function destroy(string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('bookshelf-delete', $shelf);
|
||||
|
||||
$this->shelfRepo->destroy($shelf);
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Queries\ChapterQueries;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Http\ApiController;
|
||||
@@ -15,23 +16,29 @@ 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'],
|
||||
'default_template_id' => ['nullable', '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'],
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected ChapterRepo $chapterRepo
|
||||
protected ChapterRepo $chapterRepo,
|
||||
protected ChapterQueries $queries,
|
||||
protected EntityQueries $entityQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -40,7 +47,8 @@ class ChapterApiController extends ApiController
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$chapters = Chapter::visible();
|
||||
$chapters = $this->queries->visibleForList()
|
||||
->addSelect(['created_by', 'updated_by']);
|
||||
|
||||
return $this->apiListingResponse($chapters, [
|
||||
'id', 'book_id', 'name', 'slug', 'description', 'priority',
|
||||
@@ -56,12 +64,12 @@ class ChapterApiController extends ApiController
|
||||
$requestData = $this->validate($request, $this->rules['create']);
|
||||
|
||||
$bookId = $request->get('book_id');
|
||||
$book = Book::visible()->findOrFail($bookId);
|
||||
$book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId));
|
||||
$this->checkOwnablePermission('chapter-create', $book);
|
||||
|
||||
$chapter = $this->chapterRepo->create($requestData, $book);
|
||||
|
||||
return response()->json($chapter->load(['tags']));
|
||||
return response()->json($this->forJsonDisplay($chapter));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,9 +77,17 @@ 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 = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$chapter = $this->forJsonDisplay($chapter);
|
||||
|
||||
$chapter->load(['createdBy', 'updatedBy', 'ownedBy']);
|
||||
|
||||
// Note: More fields than usual here, for backwards compatibility,
|
||||
// due to previously accidentally including more fields that desired.
|
||||
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)
|
||||
->addSelect(['created_by', 'updated_by', 'revision_count', 'editor'])
|
||||
->get();
|
||||
$chapter->setRelation('pages', $pages);
|
||||
|
||||
return response()->json($chapter);
|
||||
}
|
||||
@@ -84,7 +100,7 @@ class ChapterApiController extends ApiController
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$requestData = $this->validate($request, $this->rules()['update']);
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission('chapter-update', $chapter);
|
||||
|
||||
if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) {
|
||||
@@ -93,7 +109,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 +119,7 @@ class ChapterApiController extends ApiController
|
||||
|
||||
$updatedChapter = $this->chapterRepo->update($chapter, $requestData);
|
||||
|
||||
return response()->json($updatedChapter->load(['tags']));
|
||||
return response()->json($this->forJsonDisplay($updatedChapter));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,11 +128,24 @@ class ChapterApiController extends ApiController
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission('chapter-delete', $chapter);
|
||||
|
||||
$this->chapterRepo->destroy($chapter);
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
protected function forJsonDisplay(Chapter $chapter): Chapter
|
||||
{
|
||||
$chapter = clone $chapter;
|
||||
$chapter->unsetRelations()->refresh();
|
||||
|
||||
$chapter->load(['tags']);
|
||||
$chapter->makeVisible('description_html');
|
||||
$chapter->setAttribute('description_html', $chapter->descriptionHtml());
|
||||
$chapter->setAttribute('book_slug', $chapter->book()->first()->slug);
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace BookStack\Entities\Controllers;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Queries\ChapterQueries;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
@@ -12,6 +14,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 +24,12 @@ 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 ChapterQueries $queries,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,12 +37,15 @@ class ChapterController extends Controller
|
||||
*/
|
||||
public function create(string $bookSlug)
|
||||
{
|
||||
$book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
|
||||
$book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('chapter-create', $book);
|
||||
|
||||
$this->setPageTitle(trans('entities.chapters_create'));
|
||||
|
||||
return view('chapters.create', ['book' => $book, 'current' => $book]);
|
||||
return view('chapters.create', [
|
||||
'book' => $book,
|
||||
'current' => $book,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,14 +55,17 @@ 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'],
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
|
||||
$book = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('chapter-create', $book);
|
||||
|
||||
$chapter = $this->chapterRepo->create($request->all(), $book);
|
||||
$chapter = $this->chapterRepo->create($validated, $book);
|
||||
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
@@ -67,11 +75,12 @@ class ChapterController extends Controller
|
||||
*/
|
||||
public function show(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('chapter-view', $chapter);
|
||||
|
||||
$sidebarTree = (new BookContents($chapter->book))->getTree();
|
||||
$pages = $chapter->getVisiblePages();
|
||||
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
|
||||
|
||||
$nextPreviousLocator = new NextPreviousContentLocator($chapter, $sidebarTree);
|
||||
View::incrementFor($chapter);
|
||||
|
||||
@@ -86,7 +95,7 @@ class ChapterController extends Controller
|
||||
'pages' => $pages,
|
||||
'next' => $nextPreviousLocator->getNext(),
|
||||
'previous' => $nextPreviousLocator->getPrevious(),
|
||||
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($chapter),
|
||||
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($chapter),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -95,7 +104,7 @@ class ChapterController extends Controller
|
||||
*/
|
||||
public function edit(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('chapter-update', $chapter);
|
||||
|
||||
$this->setPageTitle(trans('entities.chapters_edit_named', ['chapterName' => $chapter->getShortName()]));
|
||||
@@ -110,10 +119,17 @@ class ChapterController extends Controller
|
||||
*/
|
||||
public function update(Request $request, string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'tags' => ['array'],
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('chapter-update', $chapter);
|
||||
|
||||
$this->chapterRepo->update($chapter, $request->all());
|
||||
$this->chapterRepo->update($chapter, $validated);
|
||||
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
@@ -125,7 +141,7 @@ class ChapterController extends Controller
|
||||
*/
|
||||
public function showDelete(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('chapter-delete', $chapter);
|
||||
|
||||
$this->setPageTitle(trans('entities.chapters_delete_named', ['chapterName' => $chapter->getShortName()]));
|
||||
@@ -141,7 +157,7 @@ class ChapterController extends Controller
|
||||
*/
|
||||
public function destroy(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('chapter-delete', $chapter);
|
||||
|
||||
$this->chapterRepo->destroy($chapter);
|
||||
@@ -156,7 +172,7 @@ class ChapterController extends Controller
|
||||
*/
|
||||
public function showMove(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$this->setPageTitle(trans('entities.chapters_move_named', ['chapterName' => $chapter->getShortName()]));
|
||||
$this->checkOwnablePermission('chapter-update', $chapter);
|
||||
$this->checkOwnablePermission('chapter-delete', $chapter);
|
||||
@@ -170,11 +186,11 @@ 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)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('chapter-update', $chapter);
|
||||
$this->checkOwnablePermission('chapter-delete', $chapter);
|
||||
|
||||
@@ -184,13 +200,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());
|
||||
@@ -203,7 +219,7 @@ class ChapterController extends Controller
|
||||
*/
|
||||
public function showCopy(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('chapter-view', $chapter);
|
||||
|
||||
session()->flashInput(['name' => $chapter->name]);
|
||||
@@ -222,16 +238,16 @@ class ChapterController extends Controller
|
||||
*/
|
||||
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('chapter-view', $chapter);
|
||||
|
||||
$entitySelection = $request->get('entity_selection') ?: null;
|
||||
$newParentBook = $entitySelection ? $this->chapterRepo->findParentByIdentifier($entitySelection) : $chapter->getParent();
|
||||
$newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent();
|
||||
|
||||
if (is_null($newParentBook)) {
|
||||
if (!$newParentBook instanceof Book) {
|
||||
$this->showErrorNotification(trans('errors.selected_book_not_found'));
|
||||
|
||||
return redirect()->back();
|
||||
return redirect($chapter->getUrl('/copy'));
|
||||
}
|
||||
|
||||
$this->checkOwnablePermission('chapter-create', $newParentBook);
|
||||
@@ -248,7 +264,7 @@ class ChapterController extends Controller
|
||||
*/
|
||||
public function convertToBook(HierarchyTransformer $transformer, string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('chapter-update', $chapter);
|
||||
$this->checkOwnablePermission('chapter-delete', $chapter);
|
||||
$this->checkPermission('book-create-all');
|
||||
|
||||
@@ -2,21 +2,17 @@
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Queries\ChapterQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Http\ApiController;
|
||||
use Throwable;
|
||||
|
||||
class ChapterExportApiController extends ApiController
|
||||
{
|
||||
protected $exportFormatter;
|
||||
|
||||
/**
|
||||
* ChapterExportController constructor.
|
||||
*/
|
||||
public function __construct(ExportFormatter $exportFormatter)
|
||||
{
|
||||
$this->exportFormatter = $exportFormatter;
|
||||
public function __construct(
|
||||
protected ExportFormatter $exportFormatter,
|
||||
protected ChapterQueries $queries,
|
||||
) {
|
||||
$this->middleware('can:content-export');
|
||||
}
|
||||
|
||||
@@ -27,7 +23,7 @@ class ChapterExportApiController extends ApiController
|
||||
*/
|
||||
public function exportPdf(int $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$chapter = $this->queries->findVisibleByIdOrFail($id);
|
||||
$pdfContent = $this->exportFormatter->chapterToPdf($chapter);
|
||||
|
||||
return $this->download()->directly($pdfContent, $chapter->slug . '.pdf');
|
||||
@@ -40,7 +36,7 @@ class ChapterExportApiController extends ApiController
|
||||
*/
|
||||
public function exportHtml(int $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$chapter = $this->queries->findVisibleByIdOrFail($id);
|
||||
$htmlContent = $this->exportFormatter->chapterToContainedHtml($chapter);
|
||||
|
||||
return $this->download()->directly($htmlContent, $chapter->slug . '.html');
|
||||
@@ -51,7 +47,7 @@ class ChapterExportApiController extends ApiController
|
||||
*/
|
||||
public function exportPlainText(int $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$chapter = $this->queries->findVisibleByIdOrFail($id);
|
||||
$textContent = $this->exportFormatter->chapterToPlainText($chapter);
|
||||
|
||||
return $this->download()->directly($textContent, $chapter->slug . '.txt');
|
||||
@@ -62,7 +58,7 @@ class ChapterExportApiController extends ApiController
|
||||
*/
|
||||
public function exportMarkdown(int $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$chapter = $this->queries->findVisibleByIdOrFail($id);
|
||||
$markdown = $this->exportFormatter->chapterToMarkdown($chapter);
|
||||
|
||||
return $this->download()->directly($markdown, $chapter->slug . '.md');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
use BookStack\Entities\Queries\ChapterQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Http\Controller;
|
||||
@@ -10,16 +10,10 @@ use Throwable;
|
||||
|
||||
class ChapterExportController extends Controller
|
||||
{
|
||||
protected $chapterRepo;
|
||||
protected $exportFormatter;
|
||||
|
||||
/**
|
||||
* ChapterExportController constructor.
|
||||
*/
|
||||
public function __construct(ChapterRepo $chapterRepo, ExportFormatter $exportFormatter)
|
||||
{
|
||||
$this->chapterRepo = $chapterRepo;
|
||||
$this->exportFormatter = $exportFormatter;
|
||||
public function __construct(
|
||||
protected ChapterQueries $queries,
|
||||
protected ExportFormatter $exportFormatter,
|
||||
) {
|
||||
$this->middleware('can:content-export');
|
||||
}
|
||||
|
||||
@@ -31,7 +25,7 @@ class ChapterExportController extends Controller
|
||||
*/
|
||||
public function pdf(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$pdfContent = $this->exportFormatter->chapterToPdf($chapter);
|
||||
|
||||
return $this->download()->directly($pdfContent, $chapterSlug . '.pdf');
|
||||
@@ -45,7 +39,7 @@ class ChapterExportController extends Controller
|
||||
*/
|
||||
public function html(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$containedHtml = $this->exportFormatter->chapterToContainedHtml($chapter);
|
||||
|
||||
return $this->download()->directly($containedHtml, $chapterSlug . '.html');
|
||||
@@ -58,7 +52,7 @@ class ChapterExportController extends Controller
|
||||
*/
|
||||
public function plainText(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$chapterText = $this->exportFormatter->chapterToPlainText($chapter);
|
||||
|
||||
return $this->download()->directly($chapterText, $chapterSlug . '.txt');
|
||||
@@ -71,7 +65,7 @@ class ChapterExportController extends Controller
|
||||
*/
|
||||
public function markdown(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$chapterText = $this->exportFormatter->chapterToMarkdown($chapter);
|
||||
|
||||
return $this->download()->directly($chapterText, $chapterSlug . '.md');
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Http\ApiController;
|
||||
@@ -35,7 +34,9 @@ class PageApiController extends ApiController
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected PageRepo $pageRepo
|
||||
protected PageRepo $pageRepo,
|
||||
protected PageQueries $queries,
|
||||
protected EntityQueries $entityQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -44,7 +45,8 @@ class PageApiController extends ApiController
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$pages = Page::visible();
|
||||
$pages = $this->queries->visibleForList()
|
||||
->addSelect(['created_by', 'updated_by', 'revision_count', 'editor']);
|
||||
|
||||
return $this->apiListingResponse($pages, [
|
||||
'id', 'book_id', 'chapter_id', 'name', 'slug', 'priority',
|
||||
@@ -70,9 +72,9 @@ class PageApiController extends ApiController
|
||||
$this->validate($request, $this->rules['create']);
|
||||
|
||||
if ($request->has('chapter_id')) {
|
||||
$parent = Chapter::visible()->findOrFail($request->get('chapter_id'));
|
||||
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id')));
|
||||
} else {
|
||||
$parent = Book::visible()->findOrFail($request->get('book_id'));
|
||||
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
|
||||
}
|
||||
$this->checkOwnablePermission('page-create', $parent);
|
||||
|
||||
@@ -97,7 +99,7 @@ class PageApiController extends ApiController
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$page = $this->pageRepo->getById($id, []);
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
|
||||
return response()->json($page->forJsonDisplay());
|
||||
}
|
||||
@@ -113,14 +115,14 @@ class PageApiController extends ApiController
|
||||
{
|
||||
$requestData = $this->validate($request, $this->rules['update']);
|
||||
|
||||
$page = $this->pageRepo->getById($id, []);
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
|
||||
$parent = null;
|
||||
if ($request->has('chapter_id')) {
|
||||
$parent = Chapter::visible()->findOrFail($request->get('chapter_id'));
|
||||
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id')));
|
||||
} elseif ($request->has('book_id')) {
|
||||
$parent = Book::visible()->findOrFail($request->get('book_id'));
|
||||
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
|
||||
}
|
||||
|
||||
if ($parent && !$parent->matches($page->getParent())) {
|
||||
@@ -148,7 +150,7 @@ class PageApiController extends ApiController
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
$page = $this->pageRepo->getById($id, []);
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
$this->checkOwnablePermission('page-delete', $page);
|
||||
|
||||
$this->pageRepo->destroy($page);
|
||||
|
||||
@@ -5,7 +5,10 @@ namespace BookStack\Entities\Controllers;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Activity\Tools\CommentTree;
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
@@ -27,6 +30,8 @@ class PageController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PageRepo $pageRepo,
|
||||
protected PageQueries $queries,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected ReferenceFetcher $referenceFetcher
|
||||
) {
|
||||
}
|
||||
@@ -38,7 +43,12 @@ class PageController extends Controller
|
||||
*/
|
||||
public function create(string $bookSlug, string $chapterSlug = null)
|
||||
{
|
||||
$parent = $this->pageRepo->getParentFromSlugs($bookSlug, $chapterSlug);
|
||||
if ($chapterSlug) {
|
||||
$parent = $this->entityQueries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
} else {
|
||||
$parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
|
||||
}
|
||||
|
||||
$this->checkOwnablePermission('page-create', $parent);
|
||||
|
||||
// Redirect to draft edit screen if signed in
|
||||
@@ -65,13 +75,17 @@ class PageController extends Controller
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$parent = $this->pageRepo->getParentFromSlugs($bookSlug, $chapterSlug);
|
||||
if ($chapterSlug) {
|
||||
$parent = $this->entityQueries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
} else {
|
||||
$parent = $this->entityQueries->books->findVisibleBySlugOrFail($bookSlug);
|
||||
}
|
||||
|
||||
$this->checkOwnablePermission('page-create', $parent);
|
||||
|
||||
$page = $this->pageRepo->getNewDraftPage($parent);
|
||||
$this->pageRepo->publishDraft($page, [
|
||||
'name' => $request->get('name'),
|
||||
'html' => '',
|
||||
]);
|
||||
|
||||
return redirect($page->getUrl('/edit'));
|
||||
@@ -84,10 +98,10 @@ class PageController extends Controller
|
||||
*/
|
||||
public function editDraft(Request $request, string $bookSlug, int $pageId)
|
||||
{
|
||||
$draft = $this->pageRepo->getById($pageId);
|
||||
$draft = $this->queries->findVisibleByIdOrFail($pageId);
|
||||
$this->checkOwnablePermission('page-create', $draft->getParent());
|
||||
|
||||
$editorData = new PageEditorData($draft, $this->pageRepo, $request->query('editor', ''));
|
||||
$editorData = new PageEditorData($draft, $this->entityQueries, $request->query('editor', ''));
|
||||
$this->setPageTitle(trans('entities.pages_edit_draft'));
|
||||
|
||||
return view('pages.edit', $editorData->getViewData());
|
||||
@@ -104,7 +118,7 @@ class PageController extends Controller
|
||||
$this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
$draftPage = $this->pageRepo->getById($pageId);
|
||||
$draftPage = $this->queries->findVisibleByIdOrFail($pageId);
|
||||
$this->checkOwnablePermission('page-create', $draftPage->getParent());
|
||||
|
||||
$page = $this->pageRepo->publishDraft($draftPage, $request->all());
|
||||
@@ -121,11 +135,12 @@ class PageController extends Controller
|
||||
public function show(string $bookSlug, string $pageSlug)
|
||||
{
|
||||
try {
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
} catch (NotFoundException $e) {
|
||||
$page = $this->pageRepo->getByOldSlug($bookSlug, $pageSlug);
|
||||
$revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
|
||||
$page = $revision->page ?? null;
|
||||
|
||||
if ($page === null) {
|
||||
if (is_null($page)) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
@@ -155,7 +170,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),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -166,7 +181,7 @@ class PageController extends Controller
|
||||
*/
|
||||
public function getPageAjax(int $pageId)
|
||||
{
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$page = $this->queries->findVisibleByIdOrFail($pageId);
|
||||
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
|
||||
$page->makeHidden(['book']);
|
||||
|
||||
@@ -180,10 +195,10 @@ class PageController extends Controller
|
||||
*/
|
||||
public function edit(Request $request, string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
|
||||
$editorData = new PageEditorData($page, $this->pageRepo, $request->query('editor', ''));
|
||||
$editorData = new PageEditorData($page, $this->entityQueries, $request->query('editor', ''));
|
||||
if ($editorData->getWarnings()) {
|
||||
$this->showWarningNotification(implode("\n", $editorData->getWarnings()));
|
||||
}
|
||||
@@ -204,7 +219,7 @@ class PageController extends Controller
|
||||
$this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
|
||||
$this->pageRepo->update($page, $request->all());
|
||||
@@ -219,7 +234,7 @@ class PageController extends Controller
|
||||
*/
|
||||
public function saveDraft(Request $request, int $pageId)
|
||||
{
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$page = $this->queries->findVisibleByIdOrFail($pageId);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
|
||||
if (!$this->isSignedIn()) {
|
||||
@@ -244,7 +259,7 @@ class PageController extends Controller
|
||||
*/
|
||||
public function redirectFromLink(int $pageId)
|
||||
{
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$page = $this->queries->findVisibleByIdOrFail($pageId);
|
||||
|
||||
return redirect($page->getUrl());
|
||||
}
|
||||
@@ -256,14 +271,18 @@ class PageController extends Controller
|
||||
*/
|
||||
public function showDelete(string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('page-delete', $page);
|
||||
$this->setPageTitle(trans('entities.pages_delete_named', ['pageName' => $page->getShortName()]));
|
||||
$usedAsTemplate =
|
||||
$this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 ||
|
||||
$this->entityQueries->chapters->start()->where('default_template_id', '=', $page->id)->count() > 0;
|
||||
|
||||
return view('pages.delete', [
|
||||
'book' => $page->book,
|
||||
'page' => $page,
|
||||
'current' => $page,
|
||||
'usedAsTemplate' => $usedAsTemplate,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -274,14 +293,18 @@ class PageController extends Controller
|
||||
*/
|
||||
public function showDeleteDraft(string $bookSlug, int $pageId)
|
||||
{
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$page = $this->queries->findVisibleByIdOrFail($pageId);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
$this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName' => $page->getShortName()]));
|
||||
$usedAsTemplate =
|
||||
$this->entityQueries->books->start()->where('default_template_id', '=', $page->id)->count() > 0 ||
|
||||
$this->entityQueries->chapters->start()->where('default_template_id', '=', $page->id)->count() > 0;
|
||||
|
||||
return view('pages.delete', [
|
||||
'book' => $page->book,
|
||||
'page' => $page,
|
||||
'current' => $page,
|
||||
'usedAsTemplate' => $usedAsTemplate,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -293,7 +316,7 @@ class PageController extends Controller
|
||||
*/
|
||||
public function destroy(string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('page-delete', $page);
|
||||
$parent = $page->getParent();
|
||||
|
||||
@@ -310,7 +333,7 @@ class PageController extends Controller
|
||||
*/
|
||||
public function destroyDraft(string $bookSlug, int $pageId)
|
||||
{
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$page = $this->queries->findVisibleByIdOrFail($pageId);
|
||||
$book = $page->book;
|
||||
$chapter = $page->chapter;
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
@@ -335,7 +358,9 @@ class PageController extends Controller
|
||||
$query->scopes('visible');
|
||||
};
|
||||
|
||||
$pages = Page::visible()->with(['updatedBy', 'book' => $visibleBelongsScope, 'chapter' => $visibleBelongsScope])
|
||||
$pages = $this->queries->visibleForList()
|
||||
->addSelect('updated_by')
|
||||
->with(['updatedBy', 'book' => $visibleBelongsScope, 'chapter' => $visibleBelongsScope])
|
||||
->orderBy('updated_at', 'desc')
|
||||
->paginate(20)
|
||||
->setPath(url('/pages/recently-updated'));
|
||||
@@ -357,7 +382,7 @@ class PageController extends Controller
|
||||
*/
|
||||
public function showMove(string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
$this->checkOwnablePermission('page-delete', $page);
|
||||
|
||||
@@ -375,7 +400,7 @@ class PageController extends Controller
|
||||
*/
|
||||
public function move(Request $request, string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
$this->checkOwnablePermission('page-delete', $page);
|
||||
|
||||
@@ -391,7 +416,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());
|
||||
@@ -404,7 +429,7 @@ class PageController extends Controller
|
||||
*/
|
||||
public function showCopy(string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('page-view', $page);
|
||||
session()->flashInput(['name' => $page->name]);
|
||||
|
||||
@@ -422,16 +447,16 @@ class PageController extends Controller
|
||||
*/
|
||||
public function copy(Request $request, Cloner $cloner, string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('page-view', $page);
|
||||
|
||||
$entitySelection = $request->get('entity_selection') ?: null;
|
||||
$newParent = $entitySelection ? $this->pageRepo->findParentByIdentifier($entitySelection) : $page->getParent();
|
||||
$newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent();
|
||||
|
||||
if (is_null($newParent)) {
|
||||
if (!$newParent instanceof Book && !$newParent instanceof Chapter) {
|
||||
$this->showErrorNotification(trans('errors.selected_book_chapter_not_found'));
|
||||
|
||||
return redirect()->back();
|
||||
return redirect($page->getUrl('/copy'));
|
||||
}
|
||||
|
||||
$this->checkOwnablePermission('page-create', $newParent);
|
||||
|
||||
@@ -2,18 +2,17 @@
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Http\ApiController;
|
||||
use Throwable;
|
||||
|
||||
class PageExportApiController extends ApiController
|
||||
{
|
||||
protected $exportFormatter;
|
||||
|
||||
public function __construct(ExportFormatter $exportFormatter)
|
||||
{
|
||||
$this->exportFormatter = $exportFormatter;
|
||||
public function __construct(
|
||||
protected ExportFormatter $exportFormatter,
|
||||
protected PageQueries $queries,
|
||||
) {
|
||||
$this->middleware('can:content-export');
|
||||
}
|
||||
|
||||
@@ -24,7 +23,7 @@ class PageExportApiController extends ApiController
|
||||
*/
|
||||
public function exportPdf(int $id)
|
||||
{
|
||||
$page = Page::visible()->findOrFail($id);
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
$pdfContent = $this->exportFormatter->pageToPdf($page);
|
||||
|
||||
return $this->download()->directly($pdfContent, $page->slug . '.pdf');
|
||||
@@ -37,7 +36,7 @@ class PageExportApiController extends ApiController
|
||||
*/
|
||||
public function exportHtml(int $id)
|
||||
{
|
||||
$page = Page::visible()->findOrFail($id);
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
$htmlContent = $this->exportFormatter->pageToContainedHtml($page);
|
||||
|
||||
return $this->download()->directly($htmlContent, $page->slug . '.html');
|
||||
@@ -48,7 +47,7 @@ class PageExportApiController extends ApiController
|
||||
*/
|
||||
public function exportPlainText(int $id)
|
||||
{
|
||||
$page = Page::visible()->findOrFail($id);
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
$textContent = $this->exportFormatter->pageToPlainText($page);
|
||||
|
||||
return $this->download()->directly($textContent, $page->slug . '.txt');
|
||||
@@ -59,7 +58,7 @@ class PageExportApiController extends ApiController
|
||||
*/
|
||||
public function exportMarkdown(int $id)
|
||||
{
|
||||
$page = Page::visible()->findOrFail($id);
|
||||
$page = $this->queries->findVisibleByIdOrFail($id);
|
||||
$markdown = $this->exportFormatter->pageToMarkdown($page);
|
||||
|
||||
return $this->download()->directly($markdown, $page->slug . '.md');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
@@ -11,16 +11,10 @@ use Throwable;
|
||||
|
||||
class PageExportController extends Controller
|
||||
{
|
||||
protected $pageRepo;
|
||||
protected $exportFormatter;
|
||||
|
||||
/**
|
||||
* PageExportController constructor.
|
||||
*/
|
||||
public function __construct(PageRepo $pageRepo, ExportFormatter $exportFormatter)
|
||||
{
|
||||
$this->pageRepo = $pageRepo;
|
||||
$this->exportFormatter = $exportFormatter;
|
||||
public function __construct(
|
||||
protected PageQueries $queries,
|
||||
protected ExportFormatter $exportFormatter,
|
||||
) {
|
||||
$this->middleware('can:content-export');
|
||||
}
|
||||
|
||||
@@ -33,7 +27,7 @@ class PageExportController extends Controller
|
||||
*/
|
||||
public function pdf(string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$pdfContent = $this->exportFormatter->pageToPdf($page);
|
||||
|
||||
@@ -48,7 +42,7 @@ class PageExportController extends Controller
|
||||
*/
|
||||
public function html(string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$containedHtml = $this->exportFormatter->pageToContainedHtml($page);
|
||||
|
||||
@@ -62,7 +56,7 @@ class PageExportController extends Controller
|
||||
*/
|
||||
public function plainText(string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$pageText = $this->exportFormatter->pageToPlainText($page);
|
||||
|
||||
return $this->download()->directly($pageText, $pageSlug . '.txt');
|
||||
@@ -75,7 +69,7 @@ class PageExportController extends Controller
|
||||
*/
|
||||
public function markdown(string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$pageText = $this->exportFormatter->pageToMarkdown($page);
|
||||
|
||||
return $this->download()->directly($pageText, $pageSlug . '.md');
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Entities\Models\PageRevision;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Entities\Repos\RevisionRepo;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
@@ -18,6 +19,7 @@ class PageRevisionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PageRepo $pageRepo,
|
||||
protected PageQueries $pageQueries,
|
||||
protected RevisionRepo $revisionRepo,
|
||||
) {
|
||||
}
|
||||
@@ -29,7 +31,7 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function index(Request $request, string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([
|
||||
'id' => trans('entities.pages_revisions_sort_number')
|
||||
]);
|
||||
@@ -60,7 +62,7 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function show(string $bookSlug, string $pageSlug, int $revisionId)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||
if ($revision === null) {
|
||||
@@ -89,7 +91,7 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function changes(string $bookSlug, string $pageSlug, int $revisionId)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||
if ($revision === null) {
|
||||
@@ -121,7 +123,7 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function restore(string $bookSlug, string $pageSlug, int $revisionId)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('page-update', $page);
|
||||
|
||||
$page = $this->pageRepo->restoreRevision($page, $revisionId);
|
||||
@@ -136,7 +138,7 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function destroy(string $bookSlug, string $pageSlug, int $revId)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('page-delete', $page);
|
||||
|
||||
$revision = $page->revisions()->where('id', '=', $revId)->first();
|
||||
@@ -162,7 +164,7 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function destroyUserDraft(string $pageId)
|
||||
{
|
||||
$page = $this->pageRepo->getById($pageId);
|
||||
$page = $this->pageQueries->findVisibleByIdOrFail($pageId);
|
||||
$this->revisionRepo->deleteDraftsForCurrentUser($page);
|
||||
|
||||
return response('', 200);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Http\Controller;
|
||||
@@ -9,14 +10,10 @@ use Illuminate\Http\Request;
|
||||
|
||||
class PageTemplateController extends Controller
|
||||
{
|
||||
protected $pageRepo;
|
||||
|
||||
/**
|
||||
* PageTemplateController constructor.
|
||||
*/
|
||||
public function __construct(PageRepo $pageRepo)
|
||||
{
|
||||
$this->pageRepo = $pageRepo;
|
||||
public function __construct(
|
||||
protected PageRepo $pageRepo,
|
||||
protected PageQueries $pageQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,7 +23,19 @@ class PageTemplateController extends Controller
|
||||
{
|
||||
$page = $request->get('page', 1);
|
||||
$search = $request->get('search', '');
|
||||
$templates = $this->pageRepo->getTemplates(10, $page, $search);
|
||||
$count = 10;
|
||||
|
||||
$query = $this->pageQueries->visibleTemplates()
|
||||
->orderBy('name', 'asc')
|
||||
->skip(($page - 1) * $count)
|
||||
->take($count);
|
||||
|
||||
if ($search) {
|
||||
$query->where('name', 'like', '%' . $search . '%');
|
||||
}
|
||||
|
||||
$templates = $query->paginate($count, ['*'], 'page', $page);
|
||||
$templates->withPath('/templates');
|
||||
|
||||
if ($search) {
|
||||
$templates->appends(['search' => $search]);
|
||||
@@ -44,7 +53,7 @@ class PageTemplateController extends Controller
|
||||
*/
|
||||
public function get(int $templateId)
|
||||
{
|
||||
$page = $this->pageRepo->getById($templateId);
|
||||
$page = $this->pageQueries->findVisibleByIdOrFail($templateId);
|
||||
|
||||
if (!$page->template) {
|
||||
throw new NotFoundException();
|
||||
|
||||
@@ -116,9 +116,9 @@ class RecycleBinController extends Controller
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function empty()
|
||||
public function empty(TrashCan $trash)
|
||||
{
|
||||
$deleteCount = (new TrashCan())->empty();
|
||||
$deleteCount = $trash->empty();
|
||||
|
||||
$this->logActivity(ActivityType::RECYCLE_BIN_EMPTY);
|
||||
$this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -106,20 +117,11 @@ class Book extends Entity implements HasCoverImage
|
||||
/**
|
||||
* Get the direct child items within this book.
|
||||
*/
|
||||
public function getDirectChildren(): Collection
|
||||
public function getDirectVisibleChildren(): Collection
|
||||
{
|
||||
$pages = $this->directPages()->scopes('visible')->get();
|
||||
$chapters = $this->chapters()->scopes('visible')->get();
|
||||
|
||||
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible book by its slug.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlug(string $slug): self
|
||||
{
|
||||
return static::visible()->where('slug', '=', $slug)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,38 +13,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* @property int $priority
|
||||
* @property string $book_slug
|
||||
* @property Book $book
|
||||
*
|
||||
* @method Builder whereSlugs(string $bookSlug, string $childSlug)
|
||||
*/
|
||||
abstract class BookChild extends Entity
|
||||
{
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
// Load book slugs onto these models by default during query-time
|
||||
static::addGlobalScope('book_slug', function (Builder $builder) {
|
||||
$builder->addSelect(['book_slug' => function ($builder) {
|
||||
$builder->select('slug')
|
||||
->from('books')
|
||||
->whereColumn('books.id', '=', 'book_id');
|
||||
}]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a query to find items where the child has the given childSlug
|
||||
* where its parent has the bookSlug.
|
||||
*/
|
||||
public function scopeWhereSlugs(Builder $query, string $bookSlug, string $childSlug)
|
||||
{
|
||||
return $query->with('book')
|
||||
->whereHas('book', function (Builder $query) use ($bookSlug) {
|
||||
$query->where('slug', '=', $bookSlug);
|
||||
})
|
||||
->where('slug', '=', $childSlug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the book this page sits in.
|
||||
*/
|
||||
@@ -65,7 +36,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.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -10,16 +11,18 @@ use Illuminate\Support\Collection;
|
||||
* Class Chapter.
|
||||
*
|
||||
* @property Collection<Page> $pages
|
||||
* @property string $description
|
||||
* @property ?int $default_template_id
|
||||
* @property ?Page $defaultTemplate
|
||||
*/
|
||||
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.
|
||||
@@ -47,6 +50,14 @@ class Chapter extends BookChild
|
||||
return url('/' . implode('/', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Page that is used as default template for newly created pages within this Chapter.
|
||||
*/
|
||||
public function defaultTemplate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Page::class, 'default_template_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visible pages in this chapter.
|
||||
*/
|
||||
@@ -58,13 +69,4 @@ class Chapter extends BookChild
|
||||
->orderBy('priority', 'asc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible chapter by its book and page slugs.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlugs(string $bookSlug, string $chapterSlug): self
|
||||
{
|
||||
return static::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -32,12 +32,10 @@ class Page extends BookChild
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public static $listAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'text', 'created_at', 'updated_at', 'priority'];
|
||||
public static $contentAttributes = ['name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', 'template', 'html', 'text', 'created_at', 'updated_at', 'priority'];
|
||||
|
||||
protected $fillable = ['name', 'priority'];
|
||||
|
||||
public $textField = 'text';
|
||||
public string $textField = 'text';
|
||||
public string $htmlField = 'html';
|
||||
|
||||
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
|
||||
|
||||
@@ -144,13 +142,4 @@ class Page extends BookChild
|
||||
|
||||
return $refreshed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible page by its book and page slugs.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlugs(string $bookSlug, string $pageSlug): self
|
||||
{
|
||||
return static::visible()->whereSlugs($bookSlug, $pageSlug)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
72
app/Entities/Queries/BookQueries.php
Normal file
72
app/Entities/Queries/BookQueries.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class BookQueries implements ProvidesEntityQueries
|
||||
{
|
||||
protected static array $listAttributes = [
|
||||
'id', 'slug', 'name', 'description',
|
||||
'created_at', 'updated_at', 'image_id', 'owned_by',
|
||||
];
|
||||
|
||||
public function start(): Builder
|
||||
{
|
||||
return Book::query();
|
||||
}
|
||||
|
||||
public function findVisibleById(int $id): ?Book
|
||||
{
|
||||
return $this->start()->scopes('visible')->find($id);
|
||||
}
|
||||
|
||||
public function findVisibleByIdOrFail(int $id): Book
|
||||
{
|
||||
return $this->start()->scopes('visible')->findOrFail($id);
|
||||
}
|
||||
|
||||
public function findVisibleBySlugOrFail(string $slug): Book
|
||||
{
|
||||
/** @var ?Book $book */
|
||||
$book = $this->start()
|
||||
->scopes('visible')
|
||||
->where('slug', '=', $slug)
|
||||
->first();
|
||||
|
||||
if ($book === null) {
|
||||
throw new NotFoundException(trans('errors.book_not_found'));
|
||||
}
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
public function visibleForList(): Builder
|
||||
{
|
||||
return $this->start()->scopes('visible')
|
||||
->select(static::$listAttributes);
|
||||
}
|
||||
|
||||
public function visibleForListWithCover(): Builder
|
||||
{
|
||||
return $this->visibleForList()->with('cover');
|
||||
}
|
||||
|
||||
public function recentlyViewedForCurrentUser(): Builder
|
||||
{
|
||||
return $this->visibleForList()
|
||||
->scopes('withLastView')
|
||||
->having('last_viewed_at', '>', 0)
|
||||
->orderBy('last_viewed_at', 'desc');
|
||||
}
|
||||
|
||||
public function popularForList(): Builder
|
||||
{
|
||||
return $this->visibleForList()
|
||||
->scopes('withViewCount')
|
||||
->having('view_count', '>', 0)
|
||||
->orderBy('view_count', 'desc');
|
||||
}
|
||||
}
|
||||
77
app/Entities/Queries/BookshelfQueries.php
Normal file
77
app/Entities/Queries/BookshelfQueries.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class BookshelfQueries implements ProvidesEntityQueries
|
||||
{
|
||||
protected static array $listAttributes = [
|
||||
'id', 'slug', 'name', 'description',
|
||||
'created_at', 'updated_at', 'image_id', 'owned_by',
|
||||
];
|
||||
|
||||
public function start(): Builder
|
||||
{
|
||||
return Bookshelf::query();
|
||||
}
|
||||
|
||||
public function findVisibleById(int $id): ?Bookshelf
|
||||
{
|
||||
return $this->start()->scopes('visible')->find($id);
|
||||
}
|
||||
|
||||
public function findVisibleByIdOrFail(int $id): Bookshelf
|
||||
{
|
||||
$shelf = $this->findVisibleById($id);
|
||||
|
||||
if (is_null($shelf)) {
|
||||
throw new NotFoundException(trans('errors.bookshelf_not_found'));
|
||||
}
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
|
||||
public function findVisibleBySlugOrFail(string $slug): Bookshelf
|
||||
{
|
||||
/** @var ?Bookshelf $shelf */
|
||||
$shelf = $this->start()
|
||||
->scopes('visible')
|
||||
->where('slug', '=', $slug)
|
||||
->first();
|
||||
|
||||
if ($shelf === null) {
|
||||
throw new NotFoundException(trans('errors.bookshelf_not_found'));
|
||||
}
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
|
||||
public function visibleForList(): Builder
|
||||
{
|
||||
return $this->start()->scopes('visible')->select(static::$listAttributes);
|
||||
}
|
||||
|
||||
public function visibleForListWithCover(): Builder
|
||||
{
|
||||
return $this->visibleForList()->with('cover');
|
||||
}
|
||||
|
||||
public function recentlyViewedForCurrentUser(): Builder
|
||||
{
|
||||
return $this->visibleForList()
|
||||
->scopes('withLastView')
|
||||
->having('last_viewed_at', '>', 0)
|
||||
->orderBy('last_viewed_at', 'desc');
|
||||
}
|
||||
|
||||
public function popularForList(): Builder
|
||||
{
|
||||
return $this->visibleForList()
|
||||
->scopes('withViewCount')
|
||||
->having('view_count', '>', 0)
|
||||
->orderBy('view_count', 'desc');
|
||||
}
|
||||
}
|
||||
69
app/Entities/Queries/ChapterQueries.php
Normal file
69
app/Entities/Queries/ChapterQueries.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ChapterQueries implements ProvidesEntityQueries
|
||||
{
|
||||
protected static array $listAttributes = [
|
||||
'id', 'slug', 'name', 'description', 'priority',
|
||||
'book_id', 'created_at', 'updated_at', 'owned_by',
|
||||
];
|
||||
|
||||
public function start(): Builder
|
||||
{
|
||||
return Chapter::query();
|
||||
}
|
||||
|
||||
public function findVisibleById(int $id): ?Chapter
|
||||
{
|
||||
return $this->start()->scopes('visible')->find($id);
|
||||
}
|
||||
|
||||
public function findVisibleByIdOrFail(int $id): Chapter
|
||||
{
|
||||
return $this->start()->scopes('visible')->findOrFail($id);
|
||||
}
|
||||
|
||||
public function findVisibleBySlugsOrFail(string $bookSlug, string $chapterSlug): Chapter
|
||||
{
|
||||
/** @var ?Chapter $chapter */
|
||||
$chapter = $this->start()
|
||||
->scopes('visible')
|
||||
->with('book')
|
||||
->whereHas('book', function (Builder $query) use ($bookSlug) {
|
||||
$query->where('slug', '=', $bookSlug);
|
||||
})
|
||||
->where('slug', '=', $chapterSlug)
|
||||
->first();
|
||||
|
||||
if (is_null($chapter)) {
|
||||
throw new NotFoundException(trans('errors.chapter_not_found'));
|
||||
}
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
|
||||
public function usingSlugs(string $bookSlug, string $chapterSlug): Builder
|
||||
{
|
||||
return $this->start()
|
||||
->where('slug', '=', $chapterSlug)
|
||||
->whereHas('book', function (Builder $query) use ($bookSlug) {
|
||||
$query->where('slug', '=', $bookSlug);
|
||||
});
|
||||
}
|
||||
|
||||
public function visibleForList(): Builder
|
||||
{
|
||||
return $this->start()
|
||||
->scopes('visible')
|
||||
->select(array_merge(static::$listAttributes, ['book_slug' => function ($builder) {
|
||||
$builder->select('slug')
|
||||
->from('books')
|
||||
->whereColumn('books.id', '=', 'chapters.book_id');
|
||||
}]));
|
||||
}
|
||||
}
|
||||
62
app/Entities/Queries/EntityQueries.php
Normal file
62
app/Entities/Queries/EntityQueries.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class EntityQueries
|
||||
{
|
||||
public function __construct(
|
||||
public BookshelfQueries $shelves,
|
||||
public BookQueries $books,
|
||||
public ChapterQueries $chapters,
|
||||
public PageQueries $pages,
|
||||
public PageRevisionQueries $revisions,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an entity via an identifier string in the format:
|
||||
* {type}:{id}
|
||||
* Example: (book:5).
|
||||
*/
|
||||
public function findVisibleByStringIdentifier(string $identifier): ?Entity
|
||||
{
|
||||
$explodedId = explode(':', $identifier);
|
||||
$entityType = $explodedId[0];
|
||||
$entityId = intval($explodedId[1]);
|
||||
$queries = $this->getQueriesForType($entityType);
|
||||
|
||||
return $queries->findVisibleById($entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query of visible entities of the given type,
|
||||
* suitable for listing display.
|
||||
*/
|
||||
public function visibleForList(string $entityType): Builder
|
||||
{
|
||||
$queries = $this->getQueriesForType($entityType);
|
||||
return $queries->visibleForList();
|
||||
}
|
||||
|
||||
protected function getQueriesForType(string $type): ProvidesEntityQueries
|
||||
{
|
||||
/** @var ?ProvidesEntityQueries $queries */
|
||||
$queries = match ($type) {
|
||||
'page' => $this->pages,
|
||||
'chapter' => $this->chapters,
|
||||
'book' => $this->books,
|
||||
'bookshelf' => $this->shelves,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (is_null($queries)) {
|
||||
throw new InvalidArgumentException("No entity query class configured for {$type}");
|
||||
}
|
||||
|
||||
return $queries;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
|
||||
abstract class EntityQuery
|
||||
{
|
||||
protected function permissionService(): PermissionApplicator
|
||||
{
|
||||
return app()->make(PermissionApplicator::class);
|
||||
}
|
||||
|
||||
protected function entityProvider(): EntityProvider
|
||||
{
|
||||
return app()->make(EntityProvider::class);
|
||||
}
|
||||
}
|
||||
112
app/Entities/Queries/PageQueries.php
Normal file
112
app/Entities/Queries/PageQueries.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class PageQueries implements ProvidesEntityQueries
|
||||
{
|
||||
protected static array $contentAttributes = [
|
||||
'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft',
|
||||
'template', 'html', 'text', 'created_at', 'updated_at', 'priority',
|
||||
'created_by', 'updated_by', 'owned_by',
|
||||
];
|
||||
protected static array $listAttributes = [
|
||||
'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft',
|
||||
'template', 'text', 'created_at', 'updated_at', 'priority', 'owned_by',
|
||||
];
|
||||
|
||||
public function start(): Builder
|
||||
{
|
||||
return Page::query();
|
||||
}
|
||||
|
||||
public function findVisibleById(int $id): ?Page
|
||||
{
|
||||
return $this->start()->scopes('visible')->find($id);
|
||||
}
|
||||
|
||||
public function findVisibleByIdOrFail(int $id): Page
|
||||
{
|
||||
$page = $this->findVisibleById($id);
|
||||
|
||||
if (is_null($page)) {
|
||||
throw new NotFoundException(trans('errors.page_not_found'));
|
||||
}
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
public function findVisibleBySlugsOrFail(string $bookSlug, string $pageSlug): Page
|
||||
{
|
||||
/** @var ?Page $page */
|
||||
$page = $this->start()->with('book')
|
||||
->scopes('visible')
|
||||
->whereHas('book', function (Builder $query) use ($bookSlug) {
|
||||
$query->where('slug', '=', $bookSlug);
|
||||
})
|
||||
->where('slug', '=', $pageSlug)
|
||||
->first();
|
||||
|
||||
if (is_null($page)) {
|
||||
throw new NotFoundException(trans('errors.page_not_found'));
|
||||
}
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
public function usingSlugs(string $bookSlug, string $pageSlug): Builder
|
||||
{
|
||||
return $this->start()
|
||||
->where('slug', '=', $pageSlug)
|
||||
->whereHas('book', function (Builder $query) use ($bookSlug) {
|
||||
$query->where('slug', '=', $bookSlug);
|
||||
});
|
||||
}
|
||||
|
||||
public function visibleForList(): Builder
|
||||
{
|
||||
return $this->start()
|
||||
->scopes('visible')
|
||||
->select($this->mergeBookSlugForSelect(static::$listAttributes));
|
||||
}
|
||||
|
||||
public function visibleForChapterList(int $chapterId): Builder
|
||||
{
|
||||
return $this->visibleForList()
|
||||
->where('chapter_id', '=', $chapterId)
|
||||
->orderBy('draft', 'desc')
|
||||
->orderBy('priority', 'asc');
|
||||
}
|
||||
|
||||
public function visibleWithContents(): Builder
|
||||
{
|
||||
return $this->start()
|
||||
->scopes('visible')
|
||||
->select($this->mergeBookSlugForSelect(static::$contentAttributes));
|
||||
}
|
||||
|
||||
public function currentUserDraftsForList(): Builder
|
||||
{
|
||||
return $this->visibleForList()
|
||||
->where('draft', '=', true)
|
||||
->where('created_by', '=', user()->id);
|
||||
}
|
||||
|
||||
public function visibleTemplates(): Builder
|
||||
{
|
||||
return $this->visibleForList()
|
||||
->where('template', '=', true);
|
||||
}
|
||||
|
||||
protected function mergeBookSlugForSelect(array $columns): array
|
||||
{
|
||||
return array_merge($columns, ['book_slug' => function ($builder) {
|
||||
$builder->select('slug')
|
||||
->from('books')
|
||||
->whereColumn('books.id', '=', 'pages.book_id');
|
||||
}]);
|
||||
}
|
||||
}
|
||||
44
app/Entities/Queries/PageRevisionQueries.php
Normal file
44
app/Entities/Queries/PageRevisionQueries.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Entities\Models\PageRevision;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class PageRevisionQueries
|
||||
{
|
||||
public function start(): Builder
|
||||
{
|
||||
return PageRevision::query();
|
||||
}
|
||||
|
||||
public function findLatestVersionBySlugs(string $bookSlug, string $pageSlug): ?PageRevision
|
||||
{
|
||||
return PageRevision::query()
|
||||
->whereHas('page', function (Builder $query) {
|
||||
$query->scopes('visible');
|
||||
})
|
||||
->where('slug', '=', $pageSlug)
|
||||
->where('type', '=', 'version')
|
||||
->where('book_slug', '=', $bookSlug)
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
}
|
||||
|
||||
public function findLatestCurrentUserDraftsForPageId(int $pageId): ?PageRevision
|
||||
{
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $this->latestCurrentUserDraftsForPageId($pageId)->first();
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
public function latestCurrentUserDraftsForPageId(int $pageId): Builder
|
||||
{
|
||||
return $this->start()
|
||||
->where('created_by', '=', user()->id)
|
||||
->where('type', 'update_draft')
|
||||
->where('page_id', '=', $pageId)
|
||||
->orderBy('created_at', 'desc');
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Popular extends EntityQuery
|
||||
{
|
||||
public function run(int $count, int $page, array $filterModels = null)
|
||||
{
|
||||
$query = $this->permissionService()
|
||||
->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type')
|
||||
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
|
||||
->groupBy('viewable_id', 'viewable_type')
|
||||
->orderBy('view_count', 'desc');
|
||||
|
||||
if ($filterModels) {
|
||||
$query->whereIn('viewable_type', $this->entityProvider()->getMorphClasses($filterModels));
|
||||
}
|
||||
|
||||
$entities = $query->with('viewable')
|
||||
->skip($count * ($page - 1))
|
||||
->take($count)
|
||||
->get()
|
||||
->pluck('viewable')
|
||||
->filter();
|
||||
|
||||
$this->loadBooksForChildren($entities);
|
||||
|
||||
return $entities;
|
||||
}
|
||||
|
||||
protected function loadBooksForChildren(Collection $entities)
|
||||
{
|
||||
$bookChildren = $entities->filter(fn(Entity $entity) => $entity instanceof BookChild);
|
||||
$eloquent = (new \Illuminate\Database\Eloquent\Collection($bookChildren));
|
||||
$eloquent->load(['book' => function (BelongsTo $query) {
|
||||
$query->scopes('visible');
|
||||
}]);
|
||||
}
|
||||
}
|
||||
34
app/Entities/Queries/ProvidesEntityQueries.php
Normal file
34
app/Entities/Queries/ProvidesEntityQueries.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Interface for our classes which provide common queries for our
|
||||
* entity objects. Ideally all queries for entities should run through
|
||||
* these classes.
|
||||
* Any added methods should return a builder instances to allow extension
|
||||
* via building on the query, unless the method starts with 'find'
|
||||
* in which case an entity object should be returned.
|
||||
* (nullable unless it's a *OrFail method).
|
||||
*/
|
||||
interface ProvidesEntityQueries
|
||||
{
|
||||
/**
|
||||
* Start a new query for this entity type.
|
||||
*/
|
||||
public function start(): Builder;
|
||||
|
||||
/**
|
||||
* Find the entity of the given ID, or return null if not found.
|
||||
*/
|
||||
public function findVisibleById(int $id): ?Entity;
|
||||
|
||||
/**
|
||||
* Start a query for items that are visible, with selection
|
||||
* configured for list display of this item.
|
||||
*/
|
||||
public function visibleForList(): Builder;
|
||||
}
|
||||
42
app/Entities/Queries/QueryPopular.php
Normal file
42
app/Entities/Queries/QueryPopular.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Tools\MixedEntityListLoader;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class QueryPopular
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionApplicator $permissions,
|
||||
protected EntityProvider $entityProvider,
|
||||
protected MixedEntityListLoader $listLoader,
|
||||
) {
|
||||
}
|
||||
|
||||
public function run(int $count, int $page, array $filterModels = null): Collection
|
||||
{
|
||||
$query = $this->permissions
|
||||
->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type')
|
||||
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
|
||||
->groupBy('viewable_id', 'viewable_type')
|
||||
->orderBy('view_count', 'desc');
|
||||
|
||||
if ($filterModels) {
|
||||
$query->whereIn('viewable_type', $this->entityProvider->getMorphClasses($filterModels));
|
||||
}
|
||||
|
||||
$views = $query
|
||||
->skip($count * ($page - 1))
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
$this->listLoader->loadIntoRelations($views->all(), 'viewable', true);
|
||||
|
||||
return $views->pluck('viewable')->filter();
|
||||
}
|
||||
}
|
||||
43
app/Entities/Queries/QueryRecentlyViewed.php
Normal file
43
app/Entities/Queries/QueryRecentlyViewed.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Entities\Tools\MixedEntityListLoader;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class QueryRecentlyViewed
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionApplicator $permissions,
|
||||
protected MixedEntityListLoader $listLoader,
|
||||
) {
|
||||
}
|
||||
|
||||
public function run(int $count, int $page): Collection
|
||||
{
|
||||
$user = user();
|
||||
if ($user->isGuest()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$query = $this->permissions->restrictEntityRelationQuery(
|
||||
View::query(),
|
||||
'views',
|
||||
'viewable_id',
|
||||
'viewable_type'
|
||||
)
|
||||
->orderBy('views.updated_at', 'desc')
|
||||
->where('user_id', '=', user()->id);
|
||||
|
||||
$views = $query
|
||||
->skip(($page - 1) * $count)
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
$this->listLoader->loadIntoRelations($views->all(), 'viewable', false);
|
||||
|
||||
return $views->pluck('viewable')->filter();
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,18 @@
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Activity\Models\Favourite;
|
||||
use BookStack\Entities\Tools\MixedEntityListLoader;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
|
||||
class TopFavourites extends EntityQuery
|
||||
class QueryTopFavourites
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionApplicator $permissions,
|
||||
protected MixedEntityListLoader $listLoader,
|
||||
) {
|
||||
}
|
||||
|
||||
public function run(int $count, int $skip = 0)
|
||||
{
|
||||
$user = user();
|
||||
@@ -14,7 +22,7 @@ class TopFavourites extends EntityQuery
|
||||
return collect();
|
||||
}
|
||||
|
||||
$query = $this->permissionService()
|
||||
$query = $this->permissions
|
||||
->restrictEntityRelationQuery(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type')
|
||||
->select('favourites.*')
|
||||
->leftJoin('views', function (JoinClause $join) {
|
||||
@@ -25,11 +33,13 @@ class TopFavourites extends EntityQuery
|
||||
->orderBy('views.views', 'desc')
|
||||
->where('favourites.user_id', '=', user()->id);
|
||||
|
||||
return $query->with('favouritable')
|
||||
$favourites = $query
|
||||
->skip($skip)
|
||||
->take($count)
|
||||
->get()
|
||||
->pluck('favouritable')
|
||||
->filter();
|
||||
->get();
|
||||
|
||||
$this->listLoader->loadIntoRelations($favourites->all(), 'favouritable', false);
|
||||
|
||||
return $favourites->pluck('favouritable')->filter();
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Activity\Models\View;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class RecentlyViewed extends EntityQuery
|
||||
{
|
||||
public function run(int $count, int $page): Collection
|
||||
{
|
||||
$user = user();
|
||||
if ($user === null || $user->isGuest()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$query = $this->permissionService()->restrictEntityRelationQuery(
|
||||
View::query(),
|
||||
'views',
|
||||
'viewable_id',
|
||||
'viewable_type'
|
||||
)
|
||||
->orderBy('views.updated_at', 'desc')
|
||||
->where('user_id', '=', user()->id);
|
||||
|
||||
return $query->with('viewable')
|
||||
->skip(($page - 1) * $count)
|
||||
->take($count)
|
||||
->get()
|
||||
->pluck('viewable')
|
||||
->filter();
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,28 @@
|
||||
namespace BookStack\Entities\Repos;
|
||||
|
||||
use BookStack\Activity\TagRepo;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\HasCoverImage;
|
||||
use BookStack\Entities\Models\HasHtmlDescription;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\References\ReferenceStore;
|
||||
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,
|
||||
protected PageQueries $pageQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +33,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 +49,7 @@ class BaseRepo
|
||||
$entity->refresh();
|
||||
$entity->rebuildPermissions();
|
||||
$entity->indexForSearch();
|
||||
$this->referenceStore->updateForEntity($entity);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +60,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 +76,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 +107,47 @@ class BaseRepo
|
||||
$entity->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the default page template used for this item.
|
||||
* Checks that, if changing, the provided value is a valid template and the user
|
||||
* has visibility of the provided page template id.
|
||||
*/
|
||||
public function updateDefaultTemplate(Book|Chapter $entity, int $templateId): void
|
||||
{
|
||||
$changing = $templateId !== intval($entity->default_template_id);
|
||||
if (!$changing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($templateId === 0) {
|
||||
$entity->default_template_id = null;
|
||||
$entity->save();
|
||||
return;
|
||||
}
|
||||
|
||||
$templateExists = $this->pageQueries->visibleTemplates()
|
||||
->where('id', '=', $templateId)
|
||||
->exists();
|
||||
|
||||
$entity->default_template_id = $templateExists ? $templateId : null;
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,81 +7,19 @@ use BookStack\Activity\TagRepo;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all books in a paginated format.
|
||||
*/
|
||||
public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator
|
||||
{
|
||||
return Book::visible()->with('cover')->orderBy($sort, $order)->paginate($count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the books that were most recently viewed by this user.
|
||||
*/
|
||||
public function getRecentlyViewed(int $count = 20): Collection
|
||||
{
|
||||
return Book::visible()->withLastView()
|
||||
->having('last_viewed_at', '>', 0)
|
||||
->orderBy('last_viewed_at', 'desc')
|
||||
->take($count)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most popular books in the system.
|
||||
*/
|
||||
public function getPopular(int $count = 20): Collection
|
||||
{
|
||||
return Book::visible()->withViewCount()
|
||||
->having('view_count', '>', 0)
|
||||
->orderBy('view_count', 'desc')
|
||||
->take($count)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recently created books from the system.
|
||||
*/
|
||||
public function getRecentlyCreated(int $count = 20): Collection
|
||||
{
|
||||
return Book::visible()->orderBy('created_at', 'desc')
|
||||
->take($count)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a book by its slug.
|
||||
*/
|
||||
public function getBySlug(string $slug): Book
|
||||
{
|
||||
$book = Book::visible()->where('slug', '=', $slug)->first();
|
||||
|
||||
if ($book === null) {
|
||||
throw new NotFoundException(trans('errors.book_not_found'));
|
||||
}
|
||||
|
||||
return $book;
|
||||
public function __construct(
|
||||
protected BaseRepo $baseRepo,
|
||||
protected TagRepo $tagRepo,
|
||||
protected ImageRepo $imageRepo,
|
||||
protected TrashCan $trashCan,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +30,7 @@ class BookRepo
|
||||
$book = new Book();
|
||||
$this->baseRepo->create($book, $input);
|
||||
$this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
|
||||
$this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
|
||||
Activity::add(ActivityType::BOOK_CREATE, $book);
|
||||
|
||||
return $book;
|
||||
@@ -104,6 +43,10 @@ class BookRepo
|
||||
{
|
||||
$this->baseRepo->update($book, $input);
|
||||
|
||||
if (array_key_exists('default_template_id', $input)) {
|
||||
$this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id']));
|
||||
}
|
||||
|
||||
if (array_key_exists('image', $input)) {
|
||||
$this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null);
|
||||
}
|
||||
@@ -131,10 +74,9 @@ class BookRepo
|
||||
*/
|
||||
public function destroy(Book $book)
|
||||
{
|
||||
$trashCan = new TrashCan();
|
||||
$trashCan->softDestroyBook($book);
|
||||
$this->trashCan->softDestroyBook($book);
|
||||
Activity::add(ActivityType::BOOK_DELETE, $book);
|
||||
|
||||
$trashCan->autoClearOld();
|
||||
$this->trashCan->autoClearOld();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,81 +3,19 @@
|
||||
namespace BookStack\Entities\Repos;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Facades\Activity;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BookshelfRepo
|
||||
{
|
||||
protected $baseRepo;
|
||||
|
||||
/**
|
||||
* BookshelfRepo constructor.
|
||||
*/
|
||||
public function __construct(BaseRepo $baseRepo)
|
||||
{
|
||||
$this->baseRepo = $baseRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all bookshelves in a paginated format.
|
||||
*/
|
||||
public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator
|
||||
{
|
||||
return Bookshelf::visible()
|
||||
->with(['visibleBooks', 'cover'])
|
||||
->orderBy($sort, $order)
|
||||
->paginate($count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bookshelves that were most recently viewed by this user.
|
||||
*/
|
||||
public function getRecentlyViewed(int $count = 20): Collection
|
||||
{
|
||||
return Bookshelf::visible()->withLastView()
|
||||
->having('last_viewed_at', '>', 0)
|
||||
->orderBy('last_viewed_at', 'desc')
|
||||
->take($count)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most popular bookshelves in the system.
|
||||
*/
|
||||
public function getPopular(int $count = 20): Collection
|
||||
{
|
||||
return Bookshelf::visible()->withViewCount()
|
||||
->having('view_count', '>', 0)
|
||||
->orderBy('view_count', 'desc')
|
||||
->take($count)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recently created bookshelves from the system.
|
||||
*/
|
||||
public function getRecentlyCreated(int $count = 20): Collection
|
||||
{
|
||||
return Bookshelf::visible()->orderBy('created_at', 'desc')
|
||||
->take($count)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a shelf by its slug.
|
||||
*/
|
||||
public function getBySlug(string $slug): Bookshelf
|
||||
{
|
||||
$shelf = Bookshelf::visible()->where('slug', '=', $slug)->first();
|
||||
|
||||
if ($shelf === null) {
|
||||
throw new NotFoundException(trans('errors.bookshelf_not_found'));
|
||||
}
|
||||
|
||||
return $shelf;
|
||||
public function __construct(
|
||||
protected BaseRepo $baseRepo,
|
||||
protected BookQueries $bookQueries,
|
||||
protected TrashCan $trashCan,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,7 +62,7 @@ class BookshelfRepo
|
||||
return intval($id);
|
||||
});
|
||||
|
||||
$syncData = Book::visible()
|
||||
$syncData = $this->bookQueries->visibleForList()
|
||||
->whereIn('id', $bookIds)
|
||||
->pluck('id')
|
||||
->mapWithKeys(function ($bookId) use ($numericIDs) {
|
||||
@@ -141,9 +79,8 @@ class BookshelfRepo
|
||||
*/
|
||||
public function destroy(Bookshelf $shelf)
|
||||
{
|
||||
$trashCan = new TrashCan();
|
||||
$trashCan->softDestroyShelf($shelf);
|
||||
$this->trashCan->softDestroyShelf($shelf);
|
||||
Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf);
|
||||
$trashCan->autoClearOld();
|
||||
$this->trashCan->autoClearOld();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ namespace BookStack\Entities\Repos;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Facades\Activity;
|
||||
use Exception;
|
||||
@@ -17,26 +16,12 @@ use Exception;
|
||||
class ChapterRepo
|
||||
{
|
||||
public function __construct(
|
||||
protected BaseRepo $baseRepo
|
||||
protected BaseRepo $baseRepo,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected TrashCan $trashCan,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a chapter via the slug.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function getBySlug(string $bookSlug, string $chapterSlug): Chapter
|
||||
{
|
||||
$chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->first();
|
||||
|
||||
if ($chapter === null) {
|
||||
throw new NotFoundException(trans('errors.chapter_not_found'));
|
||||
}
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chapter in the system.
|
||||
*/
|
||||
@@ -46,6 +31,7 @@ class ChapterRepo
|
||||
$chapter->book_id = $parentBook->id;
|
||||
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
|
||||
$this->baseRepo->create($chapter, $input);
|
||||
$this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
|
||||
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
|
||||
|
||||
return $chapter;
|
||||
@@ -57,6 +43,11 @@ class ChapterRepo
|
||||
public function update(Chapter $chapter, array $input): Chapter
|
||||
{
|
||||
$this->baseRepo->update($chapter, $input);
|
||||
|
||||
if (array_key_exists('default_template_id', $input)) {
|
||||
$this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id']));
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
|
||||
|
||||
return $chapter;
|
||||
@@ -69,10 +60,9 @@ class ChapterRepo
|
||||
*/
|
||||
public function destroy(Chapter $chapter)
|
||||
{
|
||||
$trashCan = new TrashCan();
|
||||
$trashCan->softDestroyChapter($chapter);
|
||||
$this->trashCan->softDestroyChapter($chapter);
|
||||
Activity::add(ActivityType::CHAPTER_DELETE, $chapter);
|
||||
$trashCan->autoClearOld();
|
||||
$this->trashCan->autoClearOld();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,8 +75,8 @@ class ChapterRepo
|
||||
*/
|
||||
public function move(Chapter $chapter, string $parentIdentifier): Book
|
||||
{
|
||||
$parent = $this->findParentByIdentifier($parentIdentifier);
|
||||
if (is_null($parent)) {
|
||||
$parent = $this->entityQueries->findVisibleByStringIdentifier($parentIdentifier);
|
||||
if (!$parent instanceof Book) {
|
||||
throw new MoveOperationException('Book to move chapter into not found');
|
||||
}
|
||||
|
||||
@@ -100,24 +90,4 @@ class ChapterRepo
|
||||
|
||||
return $parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a page parent entity via an identifier string in the format:
|
||||
* {type}:{id}
|
||||
* Example: (book:5).
|
||||
*
|
||||
* @throws MoveOperationException
|
||||
*/
|
||||
public function findParentByIdentifier(string $identifier): ?Book
|
||||
{
|
||||
$stringExploded = explode(':', $identifier);
|
||||
$entityType = $stringExploded[0];
|
||||
$entityId = intval($stringExploded[1]);
|
||||
|
||||
if ($entityType !== 'book') {
|
||||
throw new MoveOperationException('Chapters can only be in books');
|
||||
}
|
||||
|
||||
return Book::visible()->where('id', '=', $entityId)->first();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,114 +8,30 @@ use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Models\PageRevision;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditorData;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\References\ReferenceStore;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use Exception;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class PageRepo
|
||||
{
|
||||
public function __construct(
|
||||
protected BaseRepo $baseRepo,
|
||||
protected RevisionRepo $revisionRepo,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected ReferenceStore $referenceStore,
|
||||
protected ReferenceUpdater $referenceUpdater
|
||||
protected ReferenceUpdater $referenceUpdater,
|
||||
protected TrashCan $trashCan,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a page by ID.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function getById(int $id, array $relations = ['book']): Page
|
||||
{
|
||||
/** @var Page $page */
|
||||
$page = Page::visible()->with($relations)->find($id);
|
||||
|
||||
if (!$page) {
|
||||
throw new NotFoundException(trans('errors.page_not_found'));
|
||||
}
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a page its book and own slug.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function getBySlug(string $bookSlug, string $pageSlug): Page
|
||||
{
|
||||
$page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->first();
|
||||
|
||||
if (!$page) {
|
||||
throw new NotFoundException(trans('errors.page_not_found'));
|
||||
}
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a page by its old slug but checking the revisions table
|
||||
* for the last revision that matched the given page and book slug.
|
||||
*/
|
||||
public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
|
||||
{
|
||||
$revision = $this->revisionRepo->getBySlugs($bookSlug, $pageSlug);
|
||||
|
||||
return $revision->page ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pages that have been marked as a template.
|
||||
*/
|
||||
public function getTemplates(int $count = 10, int $page = 1, string $search = ''): LengthAwarePaginator
|
||||
{
|
||||
$query = Page::visible()
|
||||
->where('template', '=', true)
|
||||
->orderBy('name', 'asc')
|
||||
->skip(($page - 1) * $count)
|
||||
->take($count);
|
||||
|
||||
if ($search) {
|
||||
$query->where('name', 'like', '%' . $search . '%');
|
||||
}
|
||||
|
||||
$paginator = $query->paginate($count, ['*'], 'page', $page);
|
||||
$paginator->withPath('/templates');
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a parent item via slugs.
|
||||
*/
|
||||
public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null): Entity
|
||||
{
|
||||
if ($chapterSlug !== null) {
|
||||
return Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
|
||||
}
|
||||
|
||||
return Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the draft copy of the given page for the current user.
|
||||
*/
|
||||
public function getUserDraft(Page $page): ?PageRevision
|
||||
{
|
||||
return $this->revisionRepo->getLatestDraftForCurrentUser($page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new draft page belonging to the given parent entity.
|
||||
*/
|
||||
@@ -136,6 +52,14 @@ class PageRepo
|
||||
$page->book_id = $parent->id;
|
||||
}
|
||||
|
||||
$defaultTemplate = $page->chapter->defaultTemplate ?? $page->book->defaultTemplate;
|
||||
if ($defaultTemplate && userCan('view', $defaultTemplate)) {
|
||||
$page->forceFill([
|
||||
'html' => $defaultTemplate->html,
|
||||
'markdown' => $defaultTemplate->markdown,
|
||||
]);
|
||||
}
|
||||
|
||||
$page->save();
|
||||
$page->refresh()->rebuildPermissions();
|
||||
|
||||
@@ -154,7 +78,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 +97,6 @@ class PageRepo
|
||||
|
||||
$this->updateTemplateStatusAndContentFromInput($page, $input);
|
||||
$this->baseRepo->update($page, $input);
|
||||
$this->referenceStore->updateForPage($page);
|
||||
|
||||
// Update with new details
|
||||
$page->revision_count++;
|
||||
@@ -263,10 +185,9 @@ class PageRepo
|
||||
*/
|
||||
public function destroy(Page $page)
|
||||
{
|
||||
$trashCan = new TrashCan();
|
||||
$trashCan->softDestroyPage($page);
|
||||
$this->trashCan->softDestroyPage($page);
|
||||
Activity::add(ActivityType::PAGE_DELETE, $page);
|
||||
$trashCan->autoClearOld();
|
||||
$this->trashCan->autoClearOld();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,13 +214,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);
|
||||
@@ -318,8 +239,8 @@ class PageRepo
|
||||
*/
|
||||
public function move(Page $page, string $parentIdentifier): Entity
|
||||
{
|
||||
$parent = $this->findParentByIdentifier($parentIdentifier);
|
||||
if (is_null($parent)) {
|
||||
$parent = $this->entityQueries->findVisibleByStringIdentifier($parentIdentifier);
|
||||
if (!$parent instanceof Chapter && !$parent instanceof Book) {
|
||||
throw new MoveOperationException('Book or chapter to move page into not found');
|
||||
}
|
||||
|
||||
@@ -337,28 +258,6 @@ class PageRepo
|
||||
return $parent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a page parent entity via an identifier string in the format:
|
||||
* {type}:{id}
|
||||
* Example: (book:5).
|
||||
*
|
||||
* @throws MoveOperationException
|
||||
*/
|
||||
public function findParentByIdentifier(string $identifier): ?Entity
|
||||
{
|
||||
$stringExploded = explode(':', $identifier);
|
||||
$entityType = $stringExploded[0];
|
||||
$entityId = intval($stringExploded[1]);
|
||||
|
||||
if ($entityType !== 'book' && $entityType !== 'chapter') {
|
||||
throw new MoveOperationException('Pages can only be in books or chapters');
|
||||
}
|
||||
|
||||
$parentClass = $entityType === 'book' ? Book::class : Chapter::class;
|
||||
|
||||
return $parentClass::visible()->where('id', '=', $entityId)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new priority for a page.
|
||||
*/
|
||||
|
||||
@@ -4,39 +4,13 @@ namespace BookStack\Entities\Repos;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Models\PageRevision;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use BookStack\Entities\Queries\PageRevisionQueries;
|
||||
|
||||
class RevisionRepo
|
||||
{
|
||||
/**
|
||||
* Get a revision by its stored book and page slug values.
|
||||
*/
|
||||
public function getBySlugs(string $bookSlug, string $pageSlug): ?PageRevision
|
||||
{
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = PageRevision::query()
|
||||
->whereHas('page', function (Builder $query) {
|
||||
$query->scopes('visible');
|
||||
})
|
||||
->where('slug', '=', $pageSlug)
|
||||
->where('type', '=', 'version')
|
||||
->where('book_slug', '=', $bookSlug)
|
||||
->orderBy('created_at', 'desc')
|
||||
->with('page')
|
||||
->first();
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest draft revision, for the given page, belonging to the current user.
|
||||
*/
|
||||
public function getLatestDraftForCurrentUser(Page $page): ?PageRevision
|
||||
{
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $this->queryForCurrentUserDraft($page->id)->first();
|
||||
|
||||
return $revision;
|
||||
public function __construct(
|
||||
protected PageRevisionQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,7 +18,7 @@ class RevisionRepo
|
||||
*/
|
||||
public function deleteDraftsForCurrentUser(Page $page): void
|
||||
{
|
||||
$this->queryForCurrentUserDraft($page->id)->delete();
|
||||
$this->queries->latestCurrentUserDraftsForPageId($page->id)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,7 +27,7 @@ class RevisionRepo
|
||||
*/
|
||||
public function getNewDraftForCurrentUser(Page $page): PageRevision
|
||||
{
|
||||
$draft = $this->getLatestDraftForCurrentUser($page);
|
||||
$draft = $this->queries->findLatestCurrentUserDraftsForPageId($page->id);
|
||||
|
||||
if ($draft) {
|
||||
return $draft;
|
||||
@@ -116,16 +90,4 @@ class RevisionRepo
|
||||
PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query update draft revisions for the current user.
|
||||
*/
|
||||
protected function queryForCurrentUserDraft(int $pageId): Builder
|
||||
{
|
||||
return PageRevision::query()
|
||||
->where('created_by', '=', user()->id)
|
||||
->where('type', 'update_draft')
|
||||
->where('page_id', '=', $pageId)
|
||||
->orderBy('created_at', 'desc');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,17 @@ use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BookContents
|
||||
{
|
||||
protected Book $book;
|
||||
protected EntityQueries $queries;
|
||||
|
||||
public function __construct(Book $book)
|
||||
{
|
||||
$this->book = $book;
|
||||
public function __construct(
|
||||
protected Book $book,
|
||||
) {
|
||||
$this->queries = app()->make(EntityQueries::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,10 +25,12 @@ class BookContents
|
||||
*/
|
||||
public function getLastPriority(): int
|
||||
{
|
||||
$maxPage = Page::visible()->where('book_id', '=', $this->book->id)
|
||||
$maxPage = $this->book->pages()
|
||||
->where('draft', '=', false)
|
||||
->where('chapter_id', '=', 0)->max('priority');
|
||||
$maxChapter = Chapter::visible()->where('book_id', '=', $this->book->id)
|
||||
->where('chapter_id', '=', 0)
|
||||
->max('priority');
|
||||
|
||||
$maxChapter = $this->book->chapters()
|
||||
->max('priority');
|
||||
|
||||
return max($maxChapter, $maxPage, 1);
|
||||
@@ -38,7 +42,7 @@ class BookContents
|
||||
public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection
|
||||
{
|
||||
$pages = $this->getPages($showDrafts, $renderPages);
|
||||
$chapters = Chapter::visible()->where('book_id', '=', $this->book->id)->get();
|
||||
$chapters = $this->book->chapters()->scopes('visible')->get();
|
||||
$all = collect()->concat($pages)->concat($chapters);
|
||||
$chapterMap = $chapters->keyBy('id');
|
||||
$lonePages = collect();
|
||||
@@ -87,15 +91,17 @@ class BookContents
|
||||
*/
|
||||
protected function getPages(bool $showDrafts = false, bool $getPageContent = false): Collection
|
||||
{
|
||||
$query = Page::visible()
|
||||
->select($getPageContent ? Page::$contentAttributes : Page::$listAttributes)
|
||||
->where('book_id', '=', $this->book->id);
|
||||
if ($getPageContent) {
|
||||
$query = $this->queries->pages->visibleWithContents();
|
||||
} else {
|
||||
$query = $this->queries->pages->visibleForList();
|
||||
}
|
||||
|
||||
if (!$showDrafts) {
|
||||
$query->where('draft', '=', false);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
return $query->where('book_id', '=', $this->book->id)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +132,7 @@ class BookContents
|
||||
|
||||
/** @var Book[] $booksInvolved */
|
||||
$booksInvolved = array_values(array_filter($modelMap, function (string $key) {
|
||||
return strpos($key, 'book:') === 0;
|
||||
return str_starts_with($key, 'book:');
|
||||
}, ARRAY_FILTER_USE_KEY));
|
||||
|
||||
// Update permissions of books involved
|
||||
@@ -279,7 +285,7 @@ class BookContents
|
||||
}
|
||||
}
|
||||
|
||||
$pages = Page::visible()->whereIn('id', array_unique($ids['page']))->get(Page::$listAttributes);
|
||||
$pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
|
||||
/** @var Page $page */
|
||||
foreach ($pages as $page) {
|
||||
$modelMap['page:' . $page->id] = $page;
|
||||
@@ -289,14 +295,14 @@ class BookContents
|
||||
}
|
||||
}
|
||||
|
||||
$chapters = Chapter::visible()->whereIn('id', array_unique($ids['chapter']))->get();
|
||||
$chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get();
|
||||
/** @var Chapter $chapter */
|
||||
foreach ($chapters as $chapter) {
|
||||
$modelMap['chapter:' . $chapter->id] = $chapter;
|
||||
$ids['book'][] = $chapter->book_id;
|
||||
}
|
||||
|
||||
$books = Book::visible()->whereIn('id', array_unique($ids['book']))->get();
|
||||
$books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
|
||||
/** @var Book $book */
|
||||
foreach ($books as $book) {
|
||||
$modelMap['book:' . $book->id] = $book;
|
||||
|
||||
@@ -77,7 +77,7 @@ class Cloner
|
||||
$copyBook = $this->bookRepo->create($bookDetails);
|
||||
|
||||
// Clone contents
|
||||
$directChildren = $original->getDirectChildren();
|
||||
$directChildren = $original->getDirectVisibleChildren();
|
||||
foreach ($directChildren as $child) {
|
||||
if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) {
|
||||
$this->cloneChapter($child, $copyBook, $child->name);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
89
app/Entities/Tools/MixedEntityListLoader.php
Normal file
89
app/Entities/Tools/MixedEntityListLoader.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class MixedEntityListLoader
|
||||
{
|
||||
public function __construct(
|
||||
protected EntityQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, bool $loadParents): 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, $loadParents);
|
||||
|
||||
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, bool $eagerLoadParents): array
|
||||
{
|
||||
$modelMap = [];
|
||||
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
$models = $this->queries->visibleForList($type)
|
||||
->whereIn('id', $ids)
|
||||
->with($eagerLoadParents ? $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;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Facades\Theme;
|
||||
@@ -11,19 +12,22 @@ 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
|
||||
{
|
||||
protected PageQueries $pageQueries;
|
||||
|
||||
public function __construct(
|
||||
protected Page $page
|
||||
) {
|
||||
$this->pageQueries = app()->make(PageQueries::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,27 +62,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 +181,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 +225,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 +295,66 @@ 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;
|
||||
$queries = $this->pageQueries;
|
||||
|
||||
return function (PageIncludeTag $tag) use ($blankIncludes, $contextPage, $queries): PageIncludeContent {
|
||||
if ($blankIncludes) {
|
||||
return PageIncludeContent::fromHtmlAndTag('', $tag);
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
$matchedPage = $queries->findVisibleById($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 +366,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 +402,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Activity\Tools\CommentTree;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||
|
||||
@@ -15,7 +15,7 @@ class PageEditorData
|
||||
|
||||
public function __construct(
|
||||
protected Page $page,
|
||||
protected PageRepo $pageRepo,
|
||||
protected EntityQueries $queries,
|
||||
protected string $requestedEditor
|
||||
) {
|
||||
$this->viewData = $this->build();
|
||||
@@ -35,7 +35,12 @@ class PageEditorData
|
||||
{
|
||||
$page = clone $this->page;
|
||||
$isDraft = boolval($this->page->draft);
|
||||
$templates = $this->pageRepo->getTemplates(10);
|
||||
$templates = $this->queries->pages->visibleTemplates()
|
||||
->orderBy('name', 'asc')
|
||||
->take(10)
|
||||
->paginate()
|
||||
->withPath('/templates');
|
||||
|
||||
$draftsEnabled = auth()->check();
|
||||
|
||||
$isDraftRevision = false;
|
||||
@@ -47,8 +52,8 @@ class PageEditorData
|
||||
}
|
||||
|
||||
// Check for a current draft version for this user
|
||||
$userDraft = $this->pageRepo->getUserDraft($page);
|
||||
if ($userDraft !== null) {
|
||||
$userDraft = $this->queries->revisions->findLatestCurrentUserDraftsForPageId($page->id);
|
||||
if (!is_null($userDraft)) {
|
||||
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
|
||||
$isDraftRevision = true;
|
||||
$this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
|
||||
|
||||
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] ?? '');
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,16 @@ namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
|
||||
class ShelfContext
|
||||
{
|
||||
protected $KEY_SHELF_CONTEXT_ID = 'context_bookshelf_id';
|
||||
protected string $KEY_SHELF_CONTEXT_ID = 'context_bookshelf_id';
|
||||
|
||||
public function __construct(
|
||||
protected BookshelfQueries $shelfQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current bookshelf context for the given book.
|
||||
@@ -20,8 +26,7 @@ class ShelfContext
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var Bookshelf $shelf */
|
||||
$shelf = Bookshelf::visible()->find($contextBookshelfId);
|
||||
$shelf = $this->shelfQueries->findVisibleById($contextBookshelfId);
|
||||
$shelfContainsBook = $shelf && $shelf->contains($book);
|
||||
|
||||
return $shelfContainsBook ? $shelf : null;
|
||||
@@ -30,7 +35,7 @@ class ShelfContext
|
||||
/**
|
||||
* Store the current contextual shelf ID.
|
||||
*/
|
||||
public function setShelfContext(int $shelfId)
|
||||
public function setShelfContext(int $shelfId): void
|
||||
{
|
||||
session()->put($this->KEY_SHELF_CONTEXT_ID, $shelfId);
|
||||
}
|
||||
@@ -38,7 +43,7 @@ class ShelfContext
|
||||
/**
|
||||
* Clear the session stored shelf context id.
|
||||
*/
|
||||
public function clearShelfContext()
|
||||
public function clearShelfContext(): void
|
||||
{
|
||||
session()->forget($this->KEY_SHELF_CONTEXT_ID);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,17 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class SiblingFetcher
|
||||
{
|
||||
public function __construct(
|
||||
protected EntityQueries $queries,
|
||||
protected ShelfContext $shelfContext,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search among the siblings of the entity of given type and id.
|
||||
*/
|
||||
@@ -26,23 +33,23 @@ class SiblingFetcher
|
||||
|
||||
// Page in book or chapter
|
||||
if (($entity instanceof Page && !$entity->chapter) || $entity instanceof Chapter) {
|
||||
$entities = $entity->book->getDirectChildren();
|
||||
$entities = $entity->book->getDirectVisibleChildren();
|
||||
}
|
||||
|
||||
// Book
|
||||
// Gets just the books in a shelf if shelf is in context
|
||||
if ($entity instanceof Book) {
|
||||
$contextShelf = (new ShelfContext())->getContextualShelfForBook($entity);
|
||||
$contextShelf = $this->shelfContext->getContextualShelfForBook($entity);
|
||||
if ($contextShelf) {
|
||||
$entities = $contextShelf->visibleBooks()->get();
|
||||
} else {
|
||||
$entities = Book::visible()->get();
|
||||
$entities = $this->queries->books->visibleForList()->get();
|
||||
}
|
||||
}
|
||||
|
||||
// Shelf
|
||||
if ($entity instanceof Bookshelf) {
|
||||
$entities = Bookshelf::visible()->get();
|
||||
$entities = $this->queries->shelves->visibleForList()->get();
|
||||
}
|
||||
|
||||
return $entities;
|
||||
|
||||
@@ -10,6 +10,7 @@ use BookStack\Entities\Models\Deletion;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\HasCoverImage;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Uploads\AttachmentService;
|
||||
@@ -20,6 +21,11 @@ use Illuminate\Support\Carbon;
|
||||
|
||||
class TrashCan
|
||||
{
|
||||
public function __construct(
|
||||
protected EntityQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a shelf to the recycle bin.
|
||||
*
|
||||
@@ -202,6 +208,16 @@ class TrashCan
|
||||
$attachmentService->deleteFile($attachment);
|
||||
}
|
||||
|
||||
// Remove book template usages
|
||||
$this->queries->books->start()
|
||||
->where('default_template_id', '=', $page->id)
|
||||
->update(['default_template_id' => null]);
|
||||
|
||||
// Remove chapter template usages
|
||||
$this->queries->chapters->start()
|
||||
->where('default_template_id', '=', $page->id)
|
||||
->update(['default_template_id' => null]);
|
||||
|
||||
$page->forceDelete();
|
||||
|
||||
return 1;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user