Compare commits

..

1 Commits

Author SHA1 Message Date
McTom234
743657dbac feat(auth/oidc): wider key algorithm support 2025-11-23 02:58:36 +01:00
664 changed files with 5996 additions and 13546 deletions

View File

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

View File

@@ -351,25 +351,10 @@ EXPORT_PDF_COMMAND_TIMEOUT=15
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
WKHTMLTOPDF=false
# Allow JavaScript, and other potentiall dangerous content in page content.
# This also removes CSP-level JavaScript control.
# Allow <script> tags in page content
# Note, if set to 'true' the page editor may still escape scripts.
# DEPRECATED: Use 'APP_CONTENT_FILTERING' instead as detailed below. Activiting this option
# effectively sets APP_CONTENT_FILTERING='' (No filtering)
ALLOW_CONTENT_SCRIPTS=false
# Control the behaviour of content filtering, primarily used for page content.
# This setting is a string of characters which represent different available filters:
# - j - Filter out JavaScript and unknown binary data based content
# - h - Filter out unexpected, and potentially dangerous, HTML elements
# - f - Filter out unexpected form elements
# - a - Run content through a more complex allowlist filter
# This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
# Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
# Note: The default value will always be the most-strict, so it's advised to leave this unset in your own configuration
# to ensure you are always using the full range of filters.
APP_CONTENT_FILTERING="jfha"
# Indicate if robots/crawlers should crawl your instance.
# Can be 'true', 'false' or 'null'.
# The behaviour of the default 'null' option will depend on the 'app-public' admin setting.

View File

@@ -1,2 +1,84 @@
Please find our community rules on our website here:
https://www.bookstackapp.com/about/community-rules/
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance, race,
religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
### Project Maintainer Standards
Project maintainers should generally follow these additional standards:
* Avoid using a negative or harsh tone in communication, Even if the other party
is being negative themselves.
* When providing criticism, try to make it constructive to lead the other person
down the correct path.
* Keep the [project definition](https://github.com/BookStackApp/BookStack#project-definition)
in mind when deciding what's in scope of the Project.
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior. In addition, Project
maintainers are responsible for following the standards themselves.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at the email address shown on [the profile here](https://github.com/ssddanbrown). All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org

View File

@@ -56,13 +56,3 @@ body:
description: Add any other context or screenshots about the feature request here.
validations:
required: false
- type: checkboxes
id: ai-thoughts
attributes:
label: Have you used generative AI/LLMs to create any thoughts in this request?
description: |
We ask that no machine generated thoughts or ideas are provided, to avoid us spending time considering the ideas
of a machine instead of a human. Further guidance on this can be found [in the BookStack community rules](https://www.bookstackapp.com/about/community-rules/#use-of-llmsai).
options:
- label: This request only contains the thoughts & ideas of a human
required: true

View File

@@ -1,11 +0,0 @@
## Details
<!-- Write details of your pull request in here -->
<!-- Include references to any relevant issues/discussions -->
## Checklist
<!-- Put an 'x' in between the brackets below to confirm these elements -->
- [ ] I have read the [BookStack community rules](https://www.bookstackapp.com/about/community-rules/).
- [ ] This PR does not feature significant use of LLM/AI generation as per the community rules above.

View File

@@ -444,7 +444,7 @@ Irjan Olsen (Irch) :: Norwegian Bokmal
Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic)
Red (RedVortex) :: Hebrew
xgrug :: Chinese Simplified
Calle Calmar (HrCalmar) :: Danish
HrCalmar :: Danish
Avishay Rapp (AvishayRapp) :: Hebrew
matthias4217 :: French
Berke BOYLU2 (berkeboylu2) :: Turkish
@@ -511,29 +511,3 @@ MrCharlesIII :: Arabic
David Olsen (dawin) :: Danish
ltnzr :: French
Frank Holler (holler.frank) :: German; German Informal
Korab Arifi (korabidev) :: Albanian
Petr Husák (petrhusak) :: Czech
Bernardo Maia (bernardo.bmaia2) :: Portuguese, Brazilian
Amr (amr3k) :: Arabic
Tahsin Ahmed (tahsinahmed2012) :: Bengali
bojan_che :: Serbian (Cyrillic)
setiawan setiawan (culture.setiawan) :: Indonesian
Donald Mac Kenzie (kiuman) :: Norwegian Bokmal
Gabriel Silver (GabrielBSilver) :: Hebrew
Tomas Darius Davainis (Tomasdd) :: Lithuanian
CriedHero :: Chinese Simplified
Henrik (henrik2105) :: Norwegian Bokmal
FoW (fofwisdom) :: Korean
serinf-lauza :: French
Diyan Nikolaev (nikolaev.diyan) :: Bulgarian
Shadluk Avan (quldosh) :: Uzbek
Marci (MartonPoto) :: Hungarian
Michał Sadurski (wheeskeey) :: Polish
JanDziaslo :: Polish
Charllys Fernandes (CharllysFernandes) :: Portuguese, Brazilian
Ilgiz Zigangirov (inov8) :: Russian
Max Israelsson (Blezie) :: Swedish
Skiddybison5924 (chris-devel0per) :: German
Veyilla Nightwhisper (Veyilla) :: German
João Barbosa (hypeedd) :: Portuguese
Abcdefg Hijklmn (collatek) :: Korean

View File

@@ -17,7 +17,7 @@ jobs:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Install NPM deps
run: npm ci

View File

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

View File

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

1
.gitignore vendored
View File

@@ -2,7 +2,6 @@
/node_modules
/.vscode
/composer
/composer.phar
/coverage
Homestead.yaml
.env

View File

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

View File

@@ -45,11 +45,11 @@ class ForgotPasswordController extends Controller
);
if ($response === Password::RESET_LINK_SENT) {
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->input('email'));
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->get('email'));
}
if (in_array($response, [Password::RESET_LINK_SENT, Password::INVALID_USER, Password::RESET_THROTTLED])) {
$message = trans('auth.reset_password_sent', ['email' => $request->input('email')]);
$message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
$this->showSuccessNotification($message);
return redirect('/password/email')->with('status', trans($response));

View File

@@ -32,12 +32,12 @@ class LoginController extends Controller
{
$socialDrivers = $this->socialDriverManager->getActive();
$authMethod = config('auth.method');
$preventInitiation = $request->input('prevent_auto_init') === 'true';
$preventInitiation = $request->get('prevent_auto_init') === 'true';
if ($request->has('email')) {
session()->flashInput([
'email' => $request->input('email'),
'password' => (config('app.env') === 'demo') ? $request->input('password', '') : '',
'email' => $request->get('email'),
'password' => (config('app.env') === 'demo') ? $request->get('password', '') : '',
]);
}
@@ -62,7 +62,7 @@ class LoginController extends Controller
public function login(Request $request)
{
$this->validateLogin($request);
$username = $request->input($this->username());
$username = $request->get($this->username());
// Check login throttling attempts to see if they've gone over the limit
if ($this->hasTooManyLoginAttempts($request)) {

View File

@@ -84,7 +84,7 @@ class MfaBackupCodesController extends Controller
],
]);
$updatedCodes = $codeService->removeInputCodeFromSet($request->input('code'), $codes);
$updatedCodes = $codeService->removeInputCodeFromSet($request->get('code'), $codes);
MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, $updatedCodes);
$mfaSession->markVerifiedForUser($user);

View File

@@ -51,14 +51,14 @@ class MfaController extends Controller
*/
public function verify(Request $request)
{
$desiredMethod = $request->input('method');
$desiredMethod = $request->get('method');
$userMethods = $this->currentOrLastAttemptedUser()
->mfaValues()
->get(['id', 'method'])
->groupBy('method');
// Basic search for the default option for a user.
// (Prioritises TOTP over backup codes)
// (Prioritises totp over backup codes)
$method = $userMethods->has($desiredMethod) ? $desiredMethod : $userMethods->keys()->sort()->reverse()->first();
$otherMethods = $userMethods->keys()->filter(function ($userMethod) use ($method) {
return $method !== $userMethod;

View File

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

View File

@@ -48,7 +48,8 @@ class RegisterController extends Controller
public function postRegister(Request $request)
{
$this->registrationService->ensureRegistrationAllowed();
$userData = $this->validator($request->all())->validate();
$this->validator($request->all())->validate();
$userData = $request->all();
try {
$user = $this->registrationService->registerUser($userData);

View File

@@ -48,7 +48,7 @@ class ResetPasswordController extends Controller
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise, we will parse the error and return the response.
// database. Otherwise we will parse the error and return the response.
$credentials = $request->only('email', 'password', 'password_confirmation', 'token');
$response = Password::broker()->reset($credentials, function (User $user, string $password) {
$user->password = Hash::make($password);
@@ -63,7 +63,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, $request->input('token'));
: $this->sendResetFailedResponse($request, $response, $request->get('token'));
}
/**

View File

@@ -78,7 +78,7 @@ class Saml2Controller extends Controller
*/
public function startAcs(Request $request)
{
$samlResponse = $request->input('SAMLResponse', null);
$samlResponse = $request->get('SAMLResponse', null);
if (empty($samlResponse)) {
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
@@ -100,7 +100,7 @@ class Saml2Controller extends Controller
*/
public function processAcs(Request $request)
{
$acsId = $request->input('id', null);
$acsId = $request->get('id', null);
$cacheKey = 'saml2_acs:' . $acsId;
$samlResponse = null;

View File

@@ -67,7 +67,7 @@ class SocialController extends Controller
if ($request->has('error') && $request->has('error_description')) {
throw new SocialSignInException(trans('errors.social_login_bad_response', [
'socialAccount' => $socialDriver,
'error' => $request->input('error_description'),
'error' => $request->get('error_description'),
]), '/login');
}

View File

@@ -67,7 +67,7 @@ class UserInviteController extends Controller
}
$user = $this->userRepo->getById($userId);
$user->password = Hash::make($request->input('password'));
$user->password = Hash::make($request->get('password'));
$user->email_confirmed = true;
$user->save();

View File

@@ -5,7 +5,6 @@ namespace BookStack\Access;
use BookStack\Access\Notifications\ConfirmEmailNotification;
use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Users\Models\User;
use Exception;
class EmailConfirmationService extends UserTokenService
{
@@ -17,7 +16,6 @@ class EmailConfirmationService extends UserTokenService
* Also removes any existing old ones.
*
* @throws ConfirmationEmailException
* @throws Exception
*/
public function sendConfirmation(User $user): void
{

View File

@@ -71,7 +71,7 @@ class LoginService
}
$lastLoginDetails = $this->getLastLoginAttemptDetails();
$this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember']);
$this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember'] ?? false);
}
/**

View File

@@ -48,16 +48,17 @@ class MfaValue extends Model
}
/**
* Get the decrypted MFA value for the given user and method.
* Easily get the decrypted MFA value for the given user and method.
*/
public static function getValueForUser(User $user, string $method): ?string
{
/** @var MfaValue $mfaVal */
$mfaVal = static::query()
->where('user_id', '=', $user->id)
->where('method', '=', $method)
->first();
return $mfaVal?->getValue();
return $mfaVal ? $mfaVal->getValue() : null;
}
/**

View File

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

View File

@@ -1,27 +0,0 @@
<?php
namespace BookStack\Access\Oidc;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
/**
* Option provider that sends credentials via HTTP Basic Auth header
* and also includes client_id in the request body.
*/
class OidcHttpBasicWithClientIdOptionProvider extends HttpBasicAuthOptionProvider
{
public function getAccessTokenOptions($method, array $params)
{
$clientId = $params['client_id'] ?? null;
$options = parent::getAccessTokenOptions($method, $params);
if ($clientId) {
parse_str($options['body'] ?? '', $body);
$body['client_id'] = $clientId;
$options['body'] = http_build_query($body);
}
return $options;
}
}

View File

@@ -9,10 +9,10 @@ class OidcIdToken extends OidcJwtWithClaims implements ProvidesClaims
*
* @throws OidcInvalidTokenException
*/
public function validate(OidcProviderSettings $settings): bool
public function validate(string $clientId): bool
{
parent::validateCommonTokenDetails($settings);
$this->validateTokenClaims($settings->clientId);
parent::validateCommonTokenDetails($clientId);
$this->validateTokenClaims($clientId);
return true;
}

View File

@@ -2,6 +2,7 @@
namespace BookStack\Access\Oidc;
use Illuminate\Support\Facades\Log;
use phpseclib3\Crypt\Common\PublicKey;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Crypt\RSA;
@@ -9,7 +10,10 @@ use phpseclib3\Math\BigInteger;
class OidcJwtSigningKey
{
protected PublicKey $key;
/**
* @var PublicKey
*/
protected $key;
/**
* Can be created either from a JWK parameter array or local file path to load a certificate from.
@@ -17,13 +21,15 @@ class OidcJwtSigningKey
* 'file:///var/www/cert.pem'
* ['kty' => 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'].
*
* @param array|string $jwkOrKeyPath
*
* @throws OidcInvalidKeyException
*/
public function __construct(array|string $jwkOrKeyPath)
public function __construct($jwkOrKeyPath)
{
if (is_array($jwkOrKeyPath)) {
$this->loadFromJwkArray($jwkOrKeyPath);
} elseif (str_starts_with($jwkOrKeyPath, 'file://')) {
} elseif (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) {
$this->loadFromPath($jwkOrKeyPath);
} else {
throw new OidcInvalidKeyException('Unexpected type of key value provided');
@@ -33,7 +39,7 @@ class OidcJwtSigningKey
/**
* @throws OidcInvalidKeyException
*/
protected function loadFromPath(string $path): void
protected function loadFromPath(string $path)
{
try {
$key = PublicKeyLoader::load(
@@ -53,13 +59,14 @@ class OidcJwtSigningKey
/**
* @throws OidcInvalidKeyException
*/
protected function loadFromJwkArray(array $jwk): void
protected function loadFromJwkArray(array $jwk)
{
// 'alg' is optional for a JWK, but we will still attempt to validate if
// it exists otherwise presume it will be compatible.
$alg = $jwk['alg'] ?? null;
if ($jwk['kty'] !== 'RSA' || !(is_null($alg) || $alg === 'RS256')) {
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
$algorithm = OidcJwtSigningKeyAlgorithm::tryFrom($alg ?? OidcJwtSigningKeyAlgorithm::RS256->value);
if ($jwk['kty'] !== 'RSA' || $algorithm === null) {
throw new OidcInvalidKeyException("Only " . OidcJwtSigningKeyAlgorithm::getSupportedAlgorithms() . " keys are currently supported. Found key using {$alg}");
}
// 'use' is optional for a JWK but we assume 'sig' where no value exists since that's what
@@ -77,7 +84,7 @@ class OidcJwtSigningKey
throw new OidcInvalidKeyException('A "n" parameter on the provided key is expected');
}
$n = strtr($jwk['n'], '-_', '+/');
$n = strtr($jwk['n'] ?? '', '-_', '+/');
try {
$key = PublicKeyLoader::load([
@@ -92,7 +99,16 @@ class OidcJwtSigningKey
throw new OidcInvalidKeyException('Key loaded from file path is not an RSA key as expected');
}
$this->key = $key->withPadding(RSA::SIGNATURE_PKCS1);
// apply key-algorithm depending hash
$key = match ($algorithm) {
OidcJwtSigningKeyAlgorithm::RS256 => $key->withHash('sha256'),
OidcJwtSigningKeyAlgorithm::RS512 => $key->withHash('sha512'),
};
// apply key-algorithm depending padding
$this->key = match ($algorithm) {
OidcJwtSigningKeyAlgorithm::RS256,
OidcJwtSigningKeyAlgorithm::RS512 => $key->withPadding(RSA::SIGNATURE_PKCS1),
};
}
/**

View File

@@ -0,0 +1,16 @@
<?php
namespace BookStack\Access\Oidc;
use UnitEnum;
enum OidcJwtSigningKeyAlgorithm: string
{
case RS256 = 'RS256';
case RS512 = 'RS512';
public static function getSupportedAlgorithms(): string
{
return join(',', array_map(static fn (UnitEnum $enum) => $enum->value, self::cases()));
}
}

View File

@@ -9,9 +9,7 @@ class OidcJwtWithClaims implements ProvidesClaims
protected string $signature;
protected string $issuer;
protected array $tokenParts = [];
protected array $acceptedSignatures = [self::hs256Signature, self::rs256Signature];
private const hs256Signature = 'HS256'
, rs256Signature = 'RS256';
/**
* @var array[]|string[]
*/
@@ -61,11 +59,11 @@ class OidcJwtWithClaims implements ProvidesClaims
*
* @throws OidcInvalidTokenException
*/
public function validateCommonTokenDetails(OidcProviderSettings $settings): bool
public function validateCommonTokenDetails(string $clientId): bool
{
$this->validateTokenStructure();
$this->validateTokenSignature($settings);
$this->validateCommonClaims($settings->clientId);
$this->validateTokenSignature();
$this->validateCommonClaims($clientId);
return true;
}
@@ -119,42 +117,31 @@ class OidcJwtWithClaims implements ProvidesClaims
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenSignature(OidcProviderSettings $settings): void {
$validSignatures = implode(', ',$this->acceptedSignatures);
switch ($this->header['alg']) {
case self::rs256Signature:
$parsedKeys = array_map(function ($key) {
try {
return new OidcJwtSigningKey($key);
} catch (OidcInvalidKeyException $e) {
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
}
}, $this->keys);
$parsedKeys = array_filter($parsedKeys);
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
/** @var OidcJwtSigningKey $parsedKey */
foreach ($parsedKeys as $parsedKey) {
if ($parsedKey->verify($contentToSign, $this->signature)) {
return;
}
}
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
case self::hs256Signature:
$secret = $settings->clientSecret;
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
$expectedSignature = hash_hmac('sha256', $contentToSign, $secret, true);
if (hash_equals($expectedSignature, $this->signature)) {
return;
}
throw new OidcInvalidTokenException('Token signature could not be validated using the provided secret');
default:
throw new OidcInvalidTokenException("Only $validSignatures signatures validation are supported. Token reports using {$this->header['alg']}");
protected function validateTokenSignature(): void
{
if (OidcJwtSigningKeyAlgorithm::tryFrom($this->header['alg']) === null) {
throw new OidcInvalidTokenException("Only " . OidcJwtSigningKeyAlgorithm::getSupportedAlgorithms() . " signature validation is supported. Token reports using {$this->header['alg']}");
}
$parsedKeys = array_map(function ($key) {
try {
return new OidcJwtSigningKey($key);
} catch (OidcInvalidKeyException $e) {
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
}
}, $this->keys);
$parsedKeys = array_filter($parsedKeys);
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
/** @var OidcJwtSigningKey $parsedKey */
foreach ($parsedKeys as $parsedKey) {
if ($parsedKey->verify($contentToSign, $this->signature)) {
return;
}
}
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
}
/**

View File

@@ -74,7 +74,7 @@ class OidcProviderSettings
{
$this->validateInitial();
$required = ['tokenEndpoint', 'authorizationEndpoint'];
$required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
@@ -158,10 +158,10 @@ class OidcProviderSettings
protected function filterKeys(array $keys): array
{
return array_filter($keys, function (array $key) {
$alg = $key['alg'] ?? 'RS256';
$alg = $key['alg'] ?? OidcJwtSigningKeyAlgorithm::RS256->value;
$use = $key['use'] ?? 'sig';
return $key['kty'] === 'RSA' && $use === 'sig' && $alg === 'RS256';
return $key['kty'] === 'RSA' && $use === 'sig' && OidcJwtSigningKeyAlgorithm::tryFrom($alg) !== null;
});
}

View File

@@ -14,6 +14,7 @@ use BookStack\Theming\ThemeEvents;
use BookStack\Uploads\UserAvatars;
use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
/**
@@ -48,11 +49,6 @@ class OidcService
$url = $provider->getAuthorizationUrl();
session()->put('oidc_pkce_code', $provider->getPkceCode() ?? '');
$returnUrl = Theme::dispatch(ThemeEvents::OIDC_AUTH_PRE_REDIRECT, $url);
if (is_string($returnUrl)) {
$url = $returnUrl;
}
return [
'url' => $url,
'state' => $provider->getState(),
@@ -139,7 +135,7 @@ class OidcService
'redirectUri' => url('/oidc/callback'),
], [
'httpClient' => $this->http->buildClient(5),
'optionProvider' => new OidcHttpBasicWithClientIdOptionProvider(),
'optionProvider' => new HttpBasicAuthOptionProvider(),
]);
foreach ($this->getAdditionalScopes() as $scope) {
@@ -198,7 +194,7 @@ class OidcService
}
try {
$idToken->validate($settings);
$idToken->validate($settings->clientId);
} catch (OidcInvalidTokenException $exception) {
throw new OidcException("ID token validation failed with error: {$exception->getMessage()}");
}

View File

@@ -39,7 +39,7 @@ class OidcUserDetails
): void {
$this->externalId = $claims->getClaim($idClaim) ?? $this->externalId;
$this->email = $claims->getClaim('email') ?? $this->email;
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?: $this->name;
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name;
$this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups;
$this->picture = static::getPicture($claims) ?: $this->picture;
}

View File

@@ -83,7 +83,7 @@ class RegistrationService
// Email restriction
$this->ensureEmailDomainAllowed($userEmail);
// Ensure the user does not already exist
// Ensure user does not already exist
$alreadyUser = !is_null($this->userRepo->getByEmail($userEmail));
if ($alreadyUser) {
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login');
@@ -99,7 +99,7 @@ class RegistrationService
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
$newUser->attachDefaultRole();
// Assign a social account if given
// Assign social account if given
if ($socialAccount) {
$newUser->socialAccounts()->save($socialAccount);
}
@@ -107,7 +107,7 @@ class RegistrationService
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $authSystem, $newUser);
// Start the email confirmation flow if required
// Start email confirmation flow if required
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
$newUser->save();

View File

@@ -266,7 +266,7 @@ class Saml2Service
/**
* Extract the details of a user from a SAML response.
*
* @return array{external_id: string, name: string, email: string|null, saml_id: string}
* @return array{external_id: string, name: string, email: string, saml_id: string}
*/
protected function getUserDetails(string $samlID, $samlAttributes): array
{
@@ -357,7 +357,7 @@ class Saml2Service
]);
}
if (empty($userDetails['email'])) {
if ($userDetails['email'] === null) {
throw new SamlException(trans('errors.saml_no_email_address'));
}

View File

@@ -117,14 +117,14 @@ class SocialAuthService
}
// When a user is logged in and the social account exists and is already linked to the current user.
if ($isLoggedIn && $socialAccount->user->id === $currentUser->id) {
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) {
session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver]));
return redirect('/my-account/auth#social_accounts');
}
// When a user is logged in, A social account exists but the users do not match.
if ($isLoggedIn && $socialAccount->user->id != $currentUser->id) {
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) {
session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver]));
return redirect('/my-account/auth#social_accounts');

View File

@@ -17,19 +17,19 @@ class AuditLogController extends Controller
$this->checkPermission(Permission::SettingsManage);
$this->checkPermission(Permission::UsersManage);
$sort = $request->input('sort', 'activity_date');
$order = $request->input('order', 'desc');
$sort = $request->get('sort', 'activity_date');
$order = $request->get('order', 'desc');
$listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([
'created_at' => trans('settings.audit_table_date'),
'type' => trans('settings.audit_table_event'),
]);
$filters = [
'event' => $request->input('event', ''),
'date_from' => $request->input('date_from', ''),
'date_to' => $request->input('date_to', ''),
'user' => $request->input('user', ''),
'ip' => $request->input('ip', ''),
'event' => $request->get('event', ''),
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
'user' => $request->get('user', ''),
'ip' => $request->get('ip', ''),
];
$query = Activity::query()

View File

@@ -20,7 +20,7 @@ class FavouriteController extends Controller
public function index(Request $request, QueryTopFavourites $topFavourites)
{
$viewCount = 20;
$page = intval($request->input('page', 1));
$page = intval($request->get('page', 1));
$favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount));
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;

View File

@@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace BookStack\Activity\Controllers;
use BookStack\Activity\TagRepo;
use BookStack\Http\ApiController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Endpoints to query data about tags in the system.
* You'll only see results based on tags applied to content you have access to.
* There are no general create/update/delete endpoints here since tags do not exist
* by themselves, they are managed via the items they are assigned to.
*/
class TagApiController extends ApiController
{
public function __construct(
protected TagRepo $tagRepo,
) {
}
protected function rules(): array
{
return [
'listValues' => [
'name' => ['required', 'string'],
],
];
}
/**
* Get a list of tag names used in the system.
* Only the name field can be used in filters.
*/
public function listNames(): JsonResponse
{
$tagQuery = $this->tagRepo
->queryWithTotalsForApi('');
return $this->apiListingResponse($tagQuery, [
'name', 'values', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count',
], [], [
'name'
]);
}
/**
* Get a list of tag values, which have been set for the given tag name,
* which must be provided as a query parameter on the request.
* Only the value field can be used in filters.
*/
public function listValues(Request $request): JsonResponse
{
$data = $this->validate($request, $this->rules()['listValues']);
$name = $data['name'];
$tagQuery = $this->tagRepo->queryWithTotalsForApi($name);
return $this->apiListingResponse($tagQuery, [
'name', 'value', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count',
], [], [
'value',
]);
}
}

View File

@@ -24,9 +24,9 @@ class TagController extends Controller
'usages' => trans('entities.tags_usages'),
]);
$nameFilter = $request->input('name', '');
$nameFilter = $request->get('name', '');
$tags = $this->tagRepo
->queryWithTotalsForList($listOptions, $nameFilter)
->queryWithTotals($listOptions, $nameFilter)
->paginate(50)
->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [
'name' => $nameFilter,
@@ -46,7 +46,7 @@ class TagController extends Controller
*/
public function getNameSuggestions(Request $request)
{
$searchTerm = $request->input('search', '');
$searchTerm = $request->get('search', '');
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
return response()->json($suggestions);
@@ -57,8 +57,8 @@ class TagController extends Controller
*/
public function getValueSuggestions(Request $request)
{
$searchTerm = $request->input('search', '');
$tagName = $request->input('name', '');
$searchTerm = $request->get('search', '');
$tagName = $request->get('name', '');
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
return response()->json($suggestions);

View File

@@ -8,8 +8,6 @@ use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\HasCreatorAndUpdater;
use BookStack\Users\Models\OwnableInterface;
use BookStack\Util\HtmlContentFilter;
use BookStack\Util\HtmlContentFilterConfig;
use BookStack\Util\HtmlToPlainText;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -43,19 +41,7 @@ class Comment extends Model implements Loggable, OwnableInterface
*/
public function entity(): MorphTo
{
// We specifically define null here to avoid the different name (commentable)
// being used by Laravel eager loading instead of the method name, which it was doing
// in some scenarios like when deserialized when going through the queue system.
// So we instead specify the type and id column names to use.
// Related to:
// https://github.com/laravel/framework/pull/24815
// https://github.com/laravel/framework/issues/27342
// https://github.com/laravel/framework/issues/47953
// (and probably more)
// Ultimately, we could just align the method name to 'commentable' but that would be a potential
// breaking change and not really worthwhile in a patch due to the risk of creating extra problems.
return $this->morphTo(null, 'commentable_type', 'commentable_id');
return $this->morphTo('commentable');
}
/**
@@ -84,14 +70,7 @@ class Comment extends Model implements Loggable, OwnableInterface
public function safeHtml(): string
{
$filter = new HtmlContentFilter(new HtmlContentFilterConfig());
return $filter->filterString($this->html ?? '');
}
public function getPlainText(): string
{
$converter = new HtmlToPlainText();
return $converter->convert($this->html ?? '');
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
}
public function jointPermissions(): HasMany

View File

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

View File

@@ -20,7 +20,6 @@ abstract class BaseNotificationHandler implements NotificationHandler
{
$users = User::query()->whereIn('id', array_unique($userIds))->get();
/** @var User $user */
foreach ($users as $user) {
// Prevent sending to the user that initiated the activity
if ($user->id === $initiator->id) {

View File

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

View File

@@ -24,7 +24,7 @@ class CommentCreationNotification extends BaseActivityNotification
$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') => $comment->getPlainText(),
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
]);
return $this->newMailMessage($locale)

View File

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

View File

@@ -6,7 +6,6 @@ use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\CommentMentionNotificationHandler;
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
@@ -15,14 +14,14 @@ use BookStack\Users\Models\User;
class NotificationManager
{
/**
* @var array<string, class-string<NotificationHandler>[]>
* @var class-string<NotificationHandler>[]
*/
protected array $handlersByActivity = [];
protected array $handlers = [];
public function handle(Activity $activity, string|Loggable $detail, User $user): void
{
$activityType = $activity->type;
$handlersToRun = $this->handlersByActivity[$activityType] ?? [];
$handlersToRun = $this->handlers[$activityType] ?? [];
foreach ($handlersToRun as $handlerClass) {
/** @var NotificationHandler $handler */
$handler = new $handlerClass();
@@ -35,12 +34,12 @@ class NotificationManager
*/
public function registerHandler(string $activityType, string $handlerClass): void
{
if (!isset($this->handlersByActivity[$activityType])) {
$this->handlersByActivity[$activityType] = [];
if (!isset($this->handlers[$activityType])) {
$this->handlers[$activityType] = [];
}
if (!in_array($handlerClass, $this->handlersByActivity[$activityType])) {
$this->handlersByActivity[$activityType][] = $handlerClass;
if (!in_array($handlerClass, $this->handlers[$activityType])) {
$this->handlers[$activityType][] = $handlerClass;
}
}
@@ -49,7 +48,5 @@ class NotificationManager
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class);
}
}

View File

@@ -18,10 +18,9 @@ class TagRepo
}
/**
* Start a query against all tags in the system, with total counts for their usage,
* suitable for a system interface list with listing options.
* Start a query against all tags in the system.
*/
public function queryWithTotalsForList(SimpleListOptions $listOptions, string $nameFilter): Builder
public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder
{
$searchTerm = $listOptions->getSearch();
$sort = $listOptions->getSort();
@@ -29,34 +28,17 @@ class TagRepo
$sort = 'value';
}
$query = $this->baseQueryWithTotals($nameFilter, $searchTerm)
->orderBy($sort, $listOptions->getOrder());
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
}
/**
* Start a query against all tags in the system, with total counts for their usage,
* which can be used via the API.
*/
public function queryWithTotalsForApi(string $nameFilter): Builder
{
$query = $this->baseQueryWithTotals($nameFilter, '');
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
}
protected function baseQueryWithTotals(string $nameFilter, string $searchTerm): Builder
{
$query = Tag::query()
->select([
'name',
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
DB::raw('COUNT(id) as usages'),
DB::raw('CAST(SUM(IF(entity_type = \'page\', 1, 0)) as UNSIGNED) as page_count'),
DB::raw('CAST(SUM(IF(entity_type = \'chapter\', 1, 0)) as UNSIGNED) as chapter_count'),
DB::raw('CAST(SUM(IF(entity_type = \'book\', 1, 0)) as UNSIGNED) as book_count'),
DB::raw('CAST(SUM(IF(entity_type = \'bookshelf\', 1, 0)) as UNSIGNED) as shelf_count'),
DB::raw('SUM(IF(entity_type = \'page\', 1, 0)) as page_count'),
DB::raw('SUM(IF(entity_type = \'chapter\', 1, 0)) as chapter_count'),
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
])
->orderBy($sort, $listOptions->getOrder())
->whereHas('entity');
if ($nameFilter) {
@@ -75,7 +57,7 @@ class TagRepo
});
}
return $query;
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
}
/**

View File

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

View File

@@ -17,14 +17,7 @@ use ReflectionMethod;
class ApiDocsGenerator
{
/**
* @var array<string, ReflectionClass>
*/
protected array $reflectionClasses = [];
/**
* @var array<string, ApiController>
*/
protected array $controllerClasses = [];
/**
@@ -114,6 +107,7 @@ class ApiDocsGenerator
*/
protected function getBodyParamsFromClass(string $className, string $methodName): ?array
{
/** @var ApiController $class */
$class = $this->controllerClasses[$className] ?? null;
if ($class === null) {
$class = app()->make($className);
@@ -159,7 +153,7 @@ class ApiDocsGenerator
$matches = [];
preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches);
$text = implode(' ', $matches[1]);
$text = implode(' ', $matches[1] ?? []);
return str_replace(' ', "\n", $text);
}
@@ -195,12 +189,11 @@ class ApiDocsGenerator
protected function getFlatApiRoutes(): Collection
{
return collect(Route::getRoutes()->getRoutes())->filter(function ($route) {
return str_starts_with($route->uri, 'api/');
return strpos($route->uri, 'api/') === 0;
})->map(function ($route) {
[$controller, $controllerMethod] = explode('@', $route->action['uses']);
$baseModelName = explode('.', explode('/', $route->uri)[1])[0];
$controllerMethodKebab = Str::kebab($controllerMethod);
$shortName = $baseModelName . '-' . $controllerMethodKebab;
$shortName = $baseModelName . '-' . $controllerMethod;
return [
'name' => $shortName,
@@ -208,7 +201,7 @@ class ApiDocsGenerator
'method' => $route->methods[0],
'controller' => $controller,
'controller_method' => $controllerMethod,
'controller_method_kebab' => $controllerMethodKebab,
'controller_method_kebab' => Str::kebab($controllerMethod),
'base_model' => $baseModelName,
];
});

View File

@@ -74,21 +74,18 @@ class ApiEntityListFormatter
/**
* Include parent book/chapter info in the formatted data.
* These functions are careful to not load the relation themselves, since they should
* have already been loaded in a more efficient manner, with permissions applied, by the time
* the parent fields are handled here.
*/
public function withParents(): self
{
$this->withField('book', function (Entity $entity) {
if ($entity instanceof BookChild && $entity->relationLoaded('book') && $entity->getRelationValue('book')) {
if ($entity instanceof BookChild && $entity->book) {
return $entity->book->only(['id', 'name', 'slug']);
}
return null;
});
$this->withField('chapter', function (Entity $entity) {
if ($entity instanceof Page && $entity->relationLoaded('chapter') && $entity->getRelationValue('chapter')) {
if ($entity instanceof Page && $entity->chapter) {
return $entity->chapter->only(['id', 'name', 'slug']);
}
return null;

View File

@@ -17,14 +17,29 @@ class ApiTokenGuard implements Guard
use GuardHelpers;
/**
* The last auth exception thrown in this request.
* The request instance.
*/
protected ApiAuthException|null $lastAuthException = null;
protected $request;
public function __construct(
protected Request $request,
protected LoginService $loginService
) {
/**
* @var LoginService
*/
protected $loginService;
/**
* The last auth exception thrown in this request.
*
* @var ApiAuthException
*/
protected $lastAuthException;
/**
* ApiTokenGuard constructor.
*/
public function __construct(Request $request, LoginService $loginService)
{
$this->request = $request;
$this->loginService = $loginService;
}
/**
@@ -52,7 +67,7 @@ class ApiTokenGuard implements Guard
}
/**
* Determine if the current user is authenticated. If not, throw an exception.
* Determine if current user is authenticated. If not, throw an exception.
*
* @throws ApiAuthException
*
@@ -106,7 +121,7 @@ class ApiTokenGuard implements Guard
throw new ApiAuthException(trans('errors.api_no_authorization_found'));
}
if (!str_contains($authToken, ':') || !str_starts_with($authToken, 'Token ')) {
if (strpos($authToken, ':') === false || strpos($authToken, 'Token ') !== 0) {
throw new ApiAuthException(trans('errors.api_bad_authorization_format'));
}
}
@@ -140,7 +155,7 @@ class ApiTokenGuard implements Guard
/**
* {@inheritdoc}
*/
public function validate(array $credentials = []): bool
public function validate(array $credentials = [])
{
if (empty($credentials['id']) || empty($credentials['secret'])) {
return false;
@@ -160,7 +175,7 @@ class ApiTokenGuard implements Guard
/**
* "Log out" the currently authenticated user.
*/
public function logout(): void
public function logout()
{
$this->user = null;
}

View File

@@ -18,13 +18,6 @@ class ListingResponseBuilder
*/
protected array $fields;
/**
* Which fields are filterable.
* When null, the $fields above are used instead (Allow all fields).
* @var string[]|null
*/
protected array|null $filterableFields = null;
/**
* @var array<callable>
*/
@@ -61,7 +54,7 @@ class ListingResponseBuilder
{
$filteredQuery = $this->filterQuery($this->query);
$total = $filteredQuery->getCountForPagination();
$total = $filteredQuery->count();
$data = $this->fetchData($filteredQuery)->each(function ($model) {
foreach ($this->resultModifiers as $modifier) {
$modifier($model);
@@ -84,14 +77,6 @@ class ListingResponseBuilder
$this->resultModifiers[] = $modifier;
}
/**
* Limit filtering to just the given set of fields.
*/
public function setFilterableFields(array $fields): void
{
$this->filterableFields = $fields;
}
/**
* Fetch the data to return within the response.
*/
@@ -109,7 +94,7 @@ class ListingResponseBuilder
protected function filterQuery(Builder $query): Builder
{
$query = clone $query;
$requestFilters = $this->request->input('filter', []);
$requestFilters = $this->request->get('filter', []);
if (!is_array($requestFilters)) {
return $query;
}
@@ -129,11 +114,10 @@ class ListingResponseBuilder
protected function requestFilterToQueryFilter($fieldKey, $value): ?array
{
$splitKey = explode(':', $fieldKey);
$field = strtolower($splitKey[0]);
$field = $splitKey[0];
$filterOperator = $splitKey[1] ?? 'eq';
$filterFields = $this->filterableFields ?? $this->fields;
if (!in_array($field, $filterFields)) {
if (!in_array($field, $this->fields)) {
return null;
}
@@ -156,8 +140,8 @@ class ListingResponseBuilder
$defaultSortName = $this->fields[0];
$direction = 'asc';
$sort = $this->request->input('sort', '');
if (str_starts_with($sort, '-')) {
$sort = $this->request->get('sort', '');
if (strpos($sort, '-') === 0) {
$direction = 'desc';
}
@@ -176,9 +160,9 @@ class ListingResponseBuilder
protected function countAndOffsetQuery(Builder $query): Builder
{
$query = clone $query;
$offset = max(0, $this->request->input('offset', 0));
$offset = max(0, $this->request->get('offset', 0));
$maxCount = config('api.max_item_count');
$count = $this->request->input('count', config('api.default_item_count'));
$count = $this->request->get('count', config('api.default_item_count'));
$count = max(min($maxCount, $count), 1);
return $query->skip($offset)->take($count);

View File

@@ -48,11 +48,11 @@ class UserApiTokenController extends Controller
$secret = Str::random(32);
$token = (new ApiToken())->forceFill([
'name' => $request->input('name'),
'name' => $request->get('name'),
'token_id' => Str::random(32),
'secret' => Hash::make($secret),
'user_id' => $user->id,
'expires_at' => $request->input('expires_at') ?: ApiToken::defaultExpiry(),
'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(),
]);
while (ApiToken::query()->where('token_id', '=', $token->token_id)->exists()) {
@@ -100,8 +100,8 @@ class UserApiTokenController extends Controller
[$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);
$token->fill([
'name' => $request->input('name'),
'expires_at' => $request->input('expires_at') ?: ApiToken::defaultExpiry(),
'name' => $request->get('name'),
'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(),
])->save();
$this->logActivity(ActivityType::API_TOKEN_UPDATE, $token);

View File

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

View File

@@ -3,7 +3,6 @@
namespace BookStack\App\Providers;
use BookStack\Access\SocialDriverManager;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Tools\ActivityLogger;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
@@ -65,13 +64,6 @@ class AppServiceProvider extends ServiceProvider
URL::forceScheme($isHttps ? 'https' : 'http');
}
// Set SMTP mail driver to use a local domain matching the app domain,
// which helps avoid defaulting to a 127.0.0.1 domain
if ($appUrl) {
$hostName = parse_url($appUrl, PHP_URL_HOST) ?: null;
config()->set('mail.mailers.smtp.local_domain', $hostName);
}
// Allow longer string lengths after upgrade to utf8mb4
Schema::defaultStringLength(191);
@@ -81,7 +73,6 @@ class AppServiceProvider extends ServiceProvider
'book' => Book::class,
'chapter' => Chapter::class,
'page' => Page::class,
'comment' => Comment::class,
]);
}
}

View File

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

View File

@@ -5,9 +5,11 @@ namespace BookStack\App;
/**
* Assigned to models that can have slugs.
* Must have the below properties.
*
* @property string $slug
*/
interface SluggableInterface
{
/**
* Regenerate the slug for this model.
*/
public function refreshSlug(): string;
}

View File

@@ -81,7 +81,8 @@ function setting(?string $key = null, mixed $default = null): mixed
/**
* Get a path to a theme resource.
* Returns null if a theme is not configured, and therefore a full path is not available for use.
* Returns null if a theme is not configured and
* therefore a full path is not available for use.
*/
function theme_path(string $path = ''): ?string
{

View File

@@ -37,15 +37,10 @@ return [
// The limit for all uploaded files, including images and attachments in MB.
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
// Control the behaviour of content filtering, primarily used for page content.
// This setting is a string of characters which represent different available filters:
// - j - Filter out JavaScript and unknown binary data based content
// - h - Filter out unexpected, and potentially dangerous, HTML elements
// - f - Filter out unexpected form elements
// - a - Run content through a more complex allowlist filter
// This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
// Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
'content_filtering' => env('APP_CONTENT_FILTERING', env('ALLOW_CONTENT_SCRIPTS', false) === true ? '' : 'jhfa'),
// Allow <script> tags to entered within page content.
// <script> tags are escaped by default.
// Even when overridden the WYSIWYG editor may still escape script content.
'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false),
// Allow server-side fetches to be performed to potentially unknown
// and user-provided locations. Primarily used in exports when loading
@@ -53,8 +48,8 @@ return [
'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
// Override the default behaviour for allowing crawlers to crawl the instance.
// May be ignored if the underlying view has been overridden or modified.
// Defaults to null in which case the 'app-public' status is used instead.
// May be ignored if view has be overridden or modified.
// Defaults to null since, if not set, 'app-public' status used instead.
'allow_robots' => env('ALLOW_ROBOTS', null),
// Application Base URL, Used by laravel in development commands

View File

@@ -81,8 +81,7 @@ return [
'strict' => false,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
// @phpstan-ignore class.notFound
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ class AssignSortRuleCommand extends Command
*/
public function handle(BookSorter $sorter): int
{
$sortRuleId = intval($this->argument('sort-rule'));
$sortRuleId = intval($this->argument('sort-rule')) ?? 0;
if ($sortRuleId === 0) {
return $this->listSortRules();
}

View File

@@ -32,7 +32,6 @@ class CopyShelfPermissionsCommand extends Command
{
$shelfSlug = $this->option('slug');
$cascadeAll = $this->option('all');
$noInteraction = boolval($this->option('no-interaction'));
$shelves = null;
if (!$cascadeAll && !$shelfSlug) {
@@ -42,16 +41,14 @@ class CopyShelfPermissionsCommand extends Command
}
if ($cascadeAll) {
if (!$noInteraction) {
$continue = $this->confirm(
'Permission settings for all shelves will be cascaded. ' .
'Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. ' .
'Are you sure you want to proceed?',
);
$continue = $this->confirm(
'Permission settings for all shelves will be cascaded. ' .
'Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. ' .
'Are you sure you want to proceed?'
);
if (!$continue) {
return 0;
}
if (!$continue && !$this->hasOption('no-interaction')) {
return 0;
}
$shelves = $queries->start()->get(['id']);

View File

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

View File

@@ -7,14 +7,11 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
@@ -24,7 +21,6 @@ class BookApiController extends ApiController
protected BookRepo $bookRepo,
protected BookQueries $queries,
protected PageQueries $pageQueries,
protected BookshelfQueries $shelfQueries,
) {
}
@@ -64,20 +60,13 @@ class BookApiController extends ApiController
* View the details of a single book.
* The response data will contain a 'content' property listing the chapter and pages directly within, in
* the same structure as you'd see within the BookStack interface when viewing a book. Top-level
* contents will have a 'type' property to distinguish between pages and chapters.
* contents will have a 'type' property to distinguish between pages & chapters.
*/
public function read(string $id)
{
$book = $this->queries->findVisibleByIdOrFail(intval($id));
$book = $this->forJsonDisplay($book);
$book->load([
'createdBy',
'updatedBy',
'ownedBy',
'shelves' => function (BelongsToMany $query) {
$query->select(['id', 'name', 'slug'])->scopes('visible');
}
]);
$book->load(['createdBy', 'updatedBy', 'ownedBy']);
$contents = (new BookContents($book))->getTree(true, false)->all();
$contentsApiData = (new ApiEntityListFormatter($contents))

View File

@@ -8,7 +8,6 @@ use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner;
@@ -32,7 +31,6 @@ class BookController extends Controller
protected ShelfContext $shelfContext,
protected BookRepo $bookRepo,
protected BookQueries $queries,
protected EntityQueries $entityQueries,
protected BookshelfQueries $shelfQueries,
protected ReferenceFetcher $referenceFetcher,
) {
@@ -52,7 +50,7 @@ class BookController extends Controller
$books = $this->queries->visibleForListWithCover()
->orderBy($listOptions->getSort(), $listOptions->getOrder())
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
->paginate(18);
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
$popular = $this->queries->popularForList()->take(4)->get();
$new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get();
@@ -129,22 +127,13 @@ class BookController extends Controller
*/
public function show(Request $request, ActivityQueries $activities, string $slug)
{
try {
$book = $this->queries->findVisibleBySlugOrFail($slug);
} catch (NotFoundException $exception) {
$book = $this->entityQueries->findVisibleByOldSlugs('book', $slug);
if (is_null($book)) {
throw $exception;
}
return redirect($book->getUrl());
}
$book = $this->queries->findVisibleBySlugOrFail($slug);
$bookChildren = (new BookContents($book))->getTree(true);
$bookParentShelves = $book->shelves()->scopes('visible')->get();
View::incrementFor($book);
if ($request->has('shelf')) {
$this->shelfContext->setShelfContext(intval($request->input('shelf')));
$this->shelfContext->setShelfContext(intval($request->get('shelf')));
}
$this->setPageTitle($book->getShortName());
@@ -224,14 +213,9 @@ class BookController extends Controller
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission(Permission::BookDelete, $book);
$contextShelf = $this->shelfContext->getContextualShelfForBook($book);
$this->bookRepo->destroy($book);
if ($contextShelf) {
return redirect($contextShelf->getUrl());
}
return redirect('/books');
}
@@ -263,7 +247,7 @@ class BookController extends Controller
$this->checkOwnablePermission(Permission::BookView, $book);
$this->checkPermission(Permission::BookCreateAll);
$newName = $request->input('name') ?: $book->name;
$newName = $request->get('name') ?: $book->name;
$bookCopy = $cloner->cloneBook($book, $newName);
$this->showSuccessNotification(trans('entities.books_copy_success'));

View File

@@ -49,7 +49,7 @@ class BookshelfApiController extends ApiController
$this->checkPermission(Permission::BookshelfCreateAll);
$requestData = $this->validate($request, $this->rules()['create']);
$bookIds = $request->input('books', []);
$bookIds = $request->get('books', []);
$shelf = $this->bookshelfRepo->create($requestData, $bookIds);
return response()->json($this->forJsonDisplay($shelf));
@@ -88,7 +88,7 @@ class BookshelfApiController extends ApiController
$this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf);
$requestData = $this->validate($request, $this->rules()['update']);
$bookIds = $request->input('books', null);
$bookIds = $request->get('books', null);
$shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);

View File

@@ -6,7 +6,6 @@ use BookStack\Activity\ActivityQueries;
use BookStack\Activity\Models\View;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Entities\Tools\ShelfContext;
use BookStack\Exceptions\ImageUploadException;
@@ -24,7 +23,6 @@ class BookshelfController extends Controller
public function __construct(
protected BookshelfRepo $shelfRepo,
protected BookshelfQueries $queries,
protected EntityQueries $entityQueries,
protected BookQueries $bookQueries,
protected ShelfContext $shelfContext,
protected ReferenceFetcher $referenceFetcher,
@@ -45,7 +43,7 @@ class BookshelfController extends Controller
$shelves = $this->queries->visibleForListWithCover()
->orderBy($listOptions->getSort(), $listOptions->getOrder())
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
->paginate(18);
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
$popular = $this->queries->popularForList()->get();
$new = $this->queries->visibleForList()
@@ -94,7 +92,7 @@ class BookshelfController extends Controller
'tags' => ['array'],
]);
$bookIds = explode(',', $request->input('books', ''));
$bookIds = explode(',', $request->get('books', ''));
$shelf = $this->shelfRepo->create($validated, $bookIds);
return redirect($shelf->getUrl());
@@ -107,16 +105,7 @@ class BookshelfController extends Controller
*/
public function show(Request $request, ActivityQueries $activities, string $slug)
{
try {
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
} catch (NotFoundException $exception) {
$shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug);
if (is_null($shelf)) {
throw $exception;
}
return redirect($shelf->getUrl());
}
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
$this->checkOwnablePermission(Permission::BookshelfView, $shelf);
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
@@ -196,7 +185,7 @@ class BookshelfController extends Controller
unset($validated['image']);
}
$bookIds = explode(',', $request->input('books', ''));
$bookIds = explode(',', $request->get('books', ''));
$shelf = $this->shelfRepo->update($shelf, $validated, $bookIds);
return redirect($shelf->getUrl());

View File

@@ -64,7 +64,7 @@ class ChapterApiController extends ApiController
{
$requestData = $this->validate($request, $this->rules['create']);
$bookId = $request->input('book_id');
$bookId = $request->get('book_id');
$book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId));
$this->checkOwnablePermission(Permission::ChapterCreate, $book);

View File

@@ -77,15 +77,7 @@ class ChapterController extends Controller
*/
public function show(string $bookSlug, string $chapterSlug)
{
try {
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
} catch (NotFoundException $exception) {
$chapter = $this->entityQueries->findVisibleByOldSlugs('chapter', $chapterSlug, $bookSlug);
if (is_null($chapter)) {
throw $exception;
}
return redirect($chapter->getUrl());
}
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$sidebarTree = (new BookContents($chapter->book))->getTree();
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
@@ -203,7 +195,7 @@ class ChapterController extends Controller
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
$entitySelection = $request->input('entity_selection', null);
$entitySelection = $request->get('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') {
return redirect($chapter->getUrl());
}
@@ -248,7 +240,7 @@ class ChapterController extends Controller
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$entitySelection = $request->input('entity_selection') ?: null;
$entitySelection = $request->get('entity_selection') ?: null;
$newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent();
if (!$newParentBook instanceof Book) {
@@ -259,7 +251,7 @@ class ChapterController extends Controller
$this->checkOwnablePermission(Permission::ChapterCreate, $newParentBook);
$newName = $request->input('name') ?: $chapter->name;
$newName = $request->get('name') ?: $chapter->name;
$chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName);
$this->showSuccessNotification(trans('entities.chapters_copy_success'));

View File

@@ -74,9 +74,9 @@ class PageApiController extends ApiController
$this->validate($request, $this->rules['create']);
if ($request->has('chapter_id')) {
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id')));
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id')));
} else {
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('book_id')));
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
}
$this->checkOwnablePermission(Permission::PageCreate, $parent);
@@ -133,9 +133,9 @@ class PageApiController extends ApiController
$parent = null;
if ($request->has('chapter_id')) {
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id')));
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id')));
} elseif ($request->has('book_id')) {
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('book_id')));
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
}
if ($parent && !$parent->matches($page->getParent())) {

View File

@@ -17,12 +17,11 @@ use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditActivity;
use BookStack\Entities\Tools\PageEditorData;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceFetcher;
use BookStack\Util\HtmlContentFilter;
use BookStack\Util\HtmlContentFilterConfig;
use Exception;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Http\Request;
@@ -88,7 +87,7 @@ class PageController extends Controller
$page = $this->pageRepo->getNewDraftPage($parent);
$this->pageRepo->publishDraft($page, [
'name' => $request->input('name'),
'name' => $request->get('name'),
]);
return redirect($page->getUrl('/edit'));
@@ -141,7 +140,9 @@ class PageController extends Controller
try {
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
} catch (NotFoundException $e) {
$page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug);
$revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
$page = $revision->page ?? null;
if (is_null($page)) {
throw $e;
}
@@ -175,7 +176,7 @@ class PageController extends Controller
}
/**
* Get a page from an ajax request.
* Get page from an ajax request.
*
* @throws NotFoundException
*/
@@ -185,10 +186,6 @@ class PageController extends Controller
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
$page->makeHidden(['book']);
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
$filter = new HtmlContentFilter($filterConfig);
$page->html = $filter->filterString($page->html);
return response()->json($page);
}
@@ -408,7 +405,7 @@ class PageController extends Controller
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$this->checkOwnablePermission(Permission::PageDelete, $page);
$entitySelection = $request->input('entity_selection', null);
$entitySelection = $request->get('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') {
return redirect($page->getUrl());
}
@@ -453,7 +450,7 @@ class PageController extends Controller
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission(Permission::PageView, $page);
$entitySelection = $request->input('entity_selection') ?: null;
$entitySelection = $request->get('entity_selection') ?: null;
$newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent();
if (!$newParent instanceof Book && !$newParent instanceof Chapter) {
@@ -464,7 +461,7 @@ class PageController extends Controller
$this->checkOwnablePermission(Permission::PageCreate, $newParent);
$newName = $request->input('name') ?: $page->name;
$newName = $request->get('name') ?: $page->name;
$pageCopy = $cloner->clonePage($page, $newParent, $newName);
$this->showSuccessNotification(trans('entities.pages_copy_success'));

View File

@@ -12,8 +12,6 @@ use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Util\HtmlContentFilter;
use BookStack\Util\HtmlContentFilterConfig;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
use Ssddanbrown\HtmlDiff\Diff;
@@ -34,7 +32,6 @@ class PageRevisionController extends Controller
*/
public function index(Request $request, string $bookSlug, string $pageSlug)
{
$this->checkPermission(Permission::RevisionViewAll);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([
'id' => trans('entities.pages_revisions_sort_number')
@@ -66,8 +63,6 @@ class PageRevisionController extends Controller
*/
public function show(string $bookSlug, string $pageSlug, int $revisionId)
{
$this->checkPermission(Permission::RevisionViewAll);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
/** @var ?PageRevision $revision */
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
@@ -97,8 +92,6 @@ class PageRevisionController extends Controller
*/
public function changes(string $bookSlug, string $pageSlug, int $revisionId)
{
$this->checkPermission(Permission::RevisionViewAll);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
/** @var ?PageRevision $revision */
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
@@ -108,15 +101,12 @@ class PageRevisionController extends Controller
$prev = $revision->getPreviousRevision();
$prevContent = $prev->html ?? '';
// TODO - Refactor PageContent so we can de-dupe these steps
$rawDiff = Diff::excecute($prevContent, $revision->html);
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
$filter = new HtmlContentFilter($filterConfig);
$diff = $filter->filterString($rawDiff);
$diff = Diff::excecute($prevContent, $revision->html);
$page->fill($revision->toArray());
$page->html = '';
// TODO - Refactor PageContent so we don't need to juggle this
$page->html = $revision->html;
$page->html = (new PageContent($page))->render();
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
return view('pages.revision', [
@@ -134,7 +124,6 @@ class PageRevisionController extends Controller
*/
public function restore(string $bookSlug, string $pageSlug, int $revisionId)
{
$this->checkPermission(Permission::RevisionViewAll);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
@@ -150,7 +139,6 @@ class PageRevisionController extends Controller
*/
public function destroy(string $bookSlug, string $pageSlug, int $revId)
{
$this->checkPermission(Permission::RevisionViewAll);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission(Permission::PageDelete, $page);

View File

@@ -21,8 +21,8 @@ class PageTemplateController extends Controller
*/
public function list(Request $request)
{
$page = $request->input('page', 1);
$search = $request->input('search', '');
$page = $request->get('page', 1);
$search = $request->get('search', '');
$count = 10;
$query = $this->pageQueries->visibleTemplates()

View File

@@ -17,7 +17,7 @@ use Illuminate\Support\Collection;
*
* @property string $description
* @property string $description_html
* @property ?int $image_id
* @property int $image_id
* @property ?int $default_template_id
* @property ?int $sort_rule_id
* @property \Illuminate\Database\Eloquent\Collection $chapters
@@ -67,7 +67,8 @@ class Book extends Entity implements HasDescriptionInterface, HasCoverInterface,
*/
public function chapters(): HasMany
{
return $this->hasMany(Chapter::class);
return $this->hasMany(Chapter::class)
->where('type', '=', 'chapter');
}
/**

View File

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

View File

@@ -19,7 +19,7 @@ class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInter
public float $searchFactor = 1.2;
protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
protected $fillable = ['name'];
/**

View File

@@ -13,6 +13,7 @@ use BookStack\Activity\Models\Viewable;
use BookStack\Activity\Models\Watch;
use BookStack\App\Model;
use BookStack\App\SluggableInterface;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Permissions\JointPermissionBuilder;
use BookStack\Permissions\Models\EntityPermission;
use BookStack\Permissions\Models\JointPermission;
@@ -404,6 +405,16 @@ abstract class Entity extends Model implements
app()->make(SearchIndex::class)->indexEntity(clone $this);
}
/**
* {@inheritdoc}
*/
public function refreshSlug(): string
{
$this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
return $this->slug;
}
/**
* {@inheritdoc}
*/
@@ -430,14 +441,6 @@ abstract class Entity extends Model implements
return $this->morphMany(Watch::class, 'watchable');
}
/**
* Get the related slug history for this entity.
*/
public function slugHistory(): MorphMany
{
return $this->morphMany(SlugHistory::class, 'sluggable');
}
/**
* {@inheritdoc}
*/
@@ -479,7 +482,6 @@ abstract class Entity extends Model implements
'chapter' => new Chapter(),
'book' => new Book(),
'bookshelf' => new Bookshelf(),
default => throw new \InvalidArgumentException("Invalid entity type: {$type}"),
};
}
}

View File

@@ -15,12 +15,11 @@ class EntityScope implements Scope
public function apply(Builder $builder, Model $model): void
{
$builder = $builder->where('type', '=', $model->getMorphClass());
$table = $model->getTable();
if ($model instanceof Page) {
$builder->leftJoin('entity_page_data', 'entity_page_data.page_id', '=', "{$table}.id");
$builder->leftJoin('entity_page_data', 'entity_page_data.page_id', '=', 'entities.id');
} else {
$builder->leftJoin('entity_container_data', function (JoinClause $join) use ($model, $table) {
$join->on('entity_container_data.entity_id', '=', "{$table}.id")
$builder->leftJoin('entity_container_data', function (JoinClause $join) use ($model) {
$join->on('entity_container_data.entity_id', '=', 'entities.id')
->where('entity_container_data.entity_type', '=', $model->getMorphClass());
});
}

View File

@@ -23,7 +23,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
* @property bool $draft
* @property int $revision_count
* @property string $editor
* @property Chapter|null $chapter
* @property Chapter $chapter
* @property Collection $attachments
* @property Collection $revisions
* @property PageRevision $currentRevision
@@ -124,14 +124,6 @@ class Page extends BookChild
return url('/' . implode('/', $parts));
}
/**
* Get the ID-based permalink for this page.
*/
public function getPermalink(): string
{
return url("/link/{$this->id}");
}
/**
* Get this page for JSON display.
*/

View File

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

View File

@@ -4,7 +4,6 @@ namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\EntityTable;
use BookStack\Entities\Tools\SlugHistory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Query\JoinClause;
@@ -19,7 +18,6 @@ class EntityQueries
public ChapterQueries $chapters,
public PageQueries $pages,
public PageRevisionQueries $revisions,
protected SlugHistory $slugHistory,
) {
}
@@ -33,30 +31,9 @@ class EntityQueries
$explodedId = explode(':', $identifier);
$entityType = $explodedId[0];
$entityId = intval($explodedId[1]);
$queries = $this->getQueriesForType($entityType);
return $this->findVisibleById($entityType, $entityId);
}
/**
* Find an entity by its ID.
*/
public function findVisibleById(string $type, int $id): ?Entity
{
$queries = $this->getQueriesForType($type);
return $queries->findVisibleById($id);
}
/**
* Find an entity by looking up old slugs in the slug history.
*/
public function findVisibleByOldSlugs(string $type, string $slug, string $parentSlug = ''): ?Entity
{
$id = $this->slugHistory->lookupEntityIdUsingSlugs($type, $slug, $parentSlug);
if ($id === null) {
return null;
}
return $this->findVisibleById($type, $id);
return $queries->findVisibleById($entityId);
}
/**

View File

@@ -8,15 +8,12 @@ use BookStack\Entities\Models\HasCoverInterface;
use BookStack\Entities\Models\HasDescriptionInterface;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Entities\Tools\SlugHistory;
use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater;
use BookStack\Sorting\BookSorter;
use BookStack\Uploads\ImageRepo;
use BookStack\Util\HtmlDescriptionFilter;
use BookStack\Util\HtmlToPlainText;
use Illuminate\Http\UploadedFile;
class BaseRepo
@@ -28,8 +25,6 @@ class BaseRepo
protected ReferenceStore $referenceStore,
protected PageQueries $pageQueries,
protected BookSorter $bookSorter,
protected SlugGenerator $slugGenerator,
protected SlugHistory $slugHistory,
) {
}
@@ -48,7 +43,7 @@ class BaseRepo
'updated_by' => user()->id,
'owned_by' => user()->id,
]);
$this->refreshSlug($entity);
$entity->refreshSlug();
if ($entity instanceof HasDescriptionInterface) {
$this->updateDescription($entity, $input);
@@ -83,7 +78,7 @@ class BaseRepo
$entity->updated_by = user()->id;
if ($entity->isDirty('name') || empty($entity->slug)) {
$this->refreshSlug($entity);
$entity->refreshSlug();
}
if ($entity instanceof HasDescriptionInterface) {
@@ -152,22 +147,12 @@ class BaseRepo
}
if (isset($input['description_html'])) {
$plainTextConverter = new HtmlToPlainText();
$entity->descriptionInfo()->set(
HtmlDescriptionFilter::filterFromString($input['description_html']),
$plainTextConverter->convert($input['description_html']),
html_entity_decode(strip_tags($input['description_html']))
);
} else if (isset($input['description'])) {
$entity->descriptionInfo()->set('', $input['description']);
}
}
/**
* Refresh the slug for the given entity.
*/
public function refreshSlug(Entity $entity): void
{
$this->slugHistory->recordForEntity($entity);
$this->slugGenerator->regenerateForEntity($entity);
}
}

View File

@@ -7,7 +7,6 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\ParentChanger;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\PermissionsException;
@@ -22,7 +21,6 @@ class ChapterRepo
protected BaseRepo $baseRepo,
protected EntityQueries $entityQueries,
protected TrashCan $trashCan,
protected ParentChanger $parentChanger,
) {
}
@@ -99,7 +97,7 @@ class ChapterRepo
}
return (new DatabaseTransaction(function () use ($chapter, $parent) {
$this->parentChanger->changeBook($chapter, $parent->id);
$chapter = $chapter->changeBook($parent->id);
$chapter->rebuildPermissions();
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);

View File

@@ -12,7 +12,6 @@ use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditorType;
use BookStack\Entities\Tools\ParentChanger;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\PermissionsException;
@@ -32,7 +31,6 @@ class PageRepo
protected ReferenceStore $referenceStore,
protected ReferenceUpdater $referenceUpdater,
protected TrashCan $trashCan,
protected ParentChanger $parentChanger,
) {
}
@@ -60,7 +58,7 @@ class PageRepo
$page->book_id = $parent->id;
}
$defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book->defaultTemplate()->get();
$defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book?->defaultTemplate()->get();
if ($defaultTemplate) {
$page->forceFill([
'html' => $defaultTemplate->html,
@@ -244,7 +242,7 @@ class PageRepo
}
$page->updated_by = user()->id;
$this->baseRepo->refreshSlug($page);
$page->refreshSlug();
$page->save();
$page->indexForSearch();
$this->referenceStore->updateForEntity($page);
@@ -286,7 +284,7 @@ class PageRepo
return (new DatabaseTransaction(function () use ($page, $parent) {
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
$this->parentChanger->changeBook($page, $newBookId);
$page = $page->changeBook($newBookId);
$page->rebuildPermissions();
Activity::add(ActivityType::PAGE_MOVE, $page);

View File

@@ -13,47 +13,30 @@ use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceChangeContext;
use BookStack\References\ReferenceUpdater;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService;
use Illuminate\Http\UploadedFile;
class Cloner
{
protected ReferenceChangeContext $referenceChangeContext;
public function __construct(
protected PageRepo $pageRepo,
protected ChapterRepo $chapterRepo,
protected BookRepo $bookRepo,
protected ImageService $imageService,
protected ReferenceUpdater $referenceUpdater,
) {
$this->referenceChangeContext = new ReferenceChangeContext();
}
/**
* Clone the given page into the given parent using the provided name.
*/
public function clonePage(Page $original, Entity $parent, string $newName): Page
{
$context = $this->newReferenceChangeContext();
$page = $this->createPageClone($original, $parent, $newName);
$this->referenceUpdater->changeReferencesUsingContext($context);
return $page;
}
protected function createPageClone(Page $original, Entity $parent, string $newName): Page
{
$copyPage = $this->pageRepo->getNewDraftPage($parent);
$pageData = $this->entityToInputData($original);
$pageData['name'] = $newName;
$newPage = $this->pageRepo->publishDraft($copyPage, $pageData);
$this->referenceChangeContext->add($original, $newPage);
return $newPage;
return $this->pageRepo->publishDraft($copyPage, $pageData);
}
/**
@@ -61,14 +44,6 @@ class Cloner
* Clones all child pages.
*/
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
{
$context = $this->newReferenceChangeContext();
$chapter = $this->createChapterClone($original, $parent, $newName);
$this->referenceUpdater->changeReferencesUsingContext($context);
return $chapter;
}
protected function createChapterClone(Chapter $original, Book $parent, string $newName): Chapter
{
$chapterDetails = $this->entityToInputData($original);
$chapterDetails['name'] = $newName;
@@ -78,12 +53,10 @@ class Cloner
if (userCan(Permission::PageCreate, $copyChapter)) {
/** @var Page $page */
foreach ($original->getVisiblePages() as $page) {
$this->createPageClone($page, $copyChapter, $page->name);
$this->clonePage($page, $copyChapter, $page->name);
}
}
$this->referenceChangeContext->add($original, $copyChapter);
return $copyChapter;
}
@@ -92,14 +65,6 @@ class Cloner
* Clones all child chapters and pages.
*/
public function cloneBook(Book $original, string $newName): Book
{
$context = $this->newReferenceChangeContext();
$book = $this->createBookClone($original, $newName);
$this->referenceUpdater->changeReferencesUsingContext($context);
return $book;
}
protected function createBookClone(Book $original, string $newName): Book
{
$bookDetails = $this->entityToInputData($original);
$bookDetails['name'] = $newName;
@@ -111,11 +76,11 @@ class Cloner
$directChildren = $original->getDirectVisibleChildren();
foreach ($directChildren as $child) {
if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) {
$this->createChapterClone($child, $copyBook, $child->name);
$this->cloneChapter($child, $copyBook, $child->name);
}
if ($child instanceof Page && !$child->draft && userCan(Permission::PageCreate, $copyBook)) {
$this->createPageClone($child, $copyBook, $child->name);
$this->clonePage($child, $copyBook, $child->name);
}
}
@@ -127,8 +92,6 @@ class Cloner
}
}
$this->referenceChangeContext->add($original, $copyBook);
return $copyBook;
}
@@ -192,10 +155,4 @@ class Cloner
return $tags;
}
protected function newReferenceChangeContext(): ReferenceChangeContext
{
$this->referenceChangeContext = new ReferenceChangeContext();
return $this->referenceChangeContext;
}
}

View File

@@ -6,7 +6,6 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Util\HtmlContentFilter;
use BookStack\Util\HtmlContentFilterConfig;
class EntityHtmlDescription
{
@@ -51,13 +50,7 @@ class EntityHtmlDescription
return $html;
}
$isEmpty = empty(trim(strip_tags($html)));
if ($isEmpty) {
return '<p></p>';
}
$filter = new HtmlContentFilter(new HtmlContentFilterConfig());
return $filter->filterString($html);
return HtmlContentFilter::removeScriptsFromHtmlString($html);
}
public function getPlain(): string

View File

@@ -17,8 +17,7 @@ class HierarchyTransformer
protected BookRepo $bookRepo,
protected BookshelfRepo $shelfRepo,
protected Cloner $cloner,
protected TrashCan $trashCan,
protected ParentChanger $parentChanger,
protected TrashCan $trashCan
) {
}
@@ -36,7 +35,7 @@ class HierarchyTransformer
foreach ($chapter->pages as $page) {
$page->chapter_id = 0;
$page->save();
$this->parentChanger->changeBook($page, $book->id);
$page->changeBook($book->id);
}
$this->trashCan->destroyEntity($chapter);

View File

@@ -2,7 +2,6 @@
namespace BookStack\Entities\Tools;
use BookStack\App\AppVersion;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
@@ -14,9 +13,7 @@ use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageService;
use BookStack\Users\Models\User;
use BookStack\Util\HtmlContentFilter;
use BookStack\Util\HtmlContentFilterConfig;
use BookStack\Util\HtmlDocument;
use BookStack\Util\HtmlToPlainText;
use BookStack\Util\WebSafeMimeSniffer;
use Closure;
use DOMElement;
@@ -40,14 +37,7 @@ class PageContent
public function setNewHTML(string $html, User $updater): void
{
$html = $this->extractBase64ImagesFromHtml($html, $updater);
$html = $this->formatHtml($html);
$themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_PRE_STORE, $html, $this->page);
if (is_string($themeResult)) {
$html = $themeResult;
}
$this->page->html = $html;
$this->page->html = $this->formatHtml($html);
$this->page->text = $this->toPlainText();
$this->page->markdown = '';
}
@@ -60,14 +50,7 @@ class PageContent
$markdown = $this->extractBase64ImagesFromMarkdown($markdown, $updater);
$this->page->markdown = $markdown;
$html = (new MarkdownToHtml($markdown))->convert();
$html = $this->formatHtml($html);
$themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_PRE_STORE, $html, $this->page);
if (is_string($themeResult)) {
$html = $themeResult;
}
$this->page->html = $html;
$this->page->html = $this->formatHtml($html);
$this->page->text = $this->toPlainText();
}
@@ -96,7 +79,7 @@ class PageContent
/**
* Convert all inline base64 content to uploaded image files.
* Regex is used to locate the start of data-uri definitions, then
* Regex is used to locate the start of data-uri definitions then
* manual looping over content is done to parse the whole data uri.
* Attempting to capture the whole data uri using regex can cause PHP
* PCRE limits to be hit with larger, multi-MB, files.
@@ -304,8 +287,8 @@ class PageContent
public function toPlainText(): string
{
$html = $this->render(true);
$converter = new HtmlToPlainText();
return $converter->convert($html);
return html_entity_decode(strip_tags($html));
}
/**
@@ -316,7 +299,7 @@ class PageContent
$html = $this->page->html ?? '';
if (empty($html)) {
return $this->handlePostRender('');
return $html;
}
$doc = new HtmlDocument($html);
@@ -334,36 +317,11 @@ class PageContent
$this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
}
$cacheKey = $this->getContentCacheKey($doc->getBodyInnerHtml());
$cached = cache()->get($cacheKey, null);
if ($cached !== null) {
return $this->handlePostRender($cached);
if (!config('app.allow_content_scripts')) {
HtmlContentFilter::removeScriptsFromDocument($doc);
}
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
$filter = new HtmlContentFilter($filterConfig);
$filtered = $filter->filterDocument($doc);
$cacheTime = 86400 * 7; // 1 week
cache()->put($cacheKey, $filtered, $cacheTime);
return $this->handlePostRender($filtered);
}
protected function handlePostRender(string $html): string
{
$themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_POST_RENDER, $html, $this->page);
return is_string($themeResult) ? $themeResult : $html;
}
protected function getContentCacheKey(string $html): string
{
$contentHash = md5($html);
$contentId = $this->page->id;
$contentTime = $this->page->updated_at->timestamp ?? time();
$appVersion = AppVersion::get();
$filterConfig = config('app.content_filtering') ?? '';
return "page-content-cache::{$filterConfig}::{$appVersion}::{$contentId}::{$contentTime}::{$contentHash}";
return $doc->getBodyInnerHtml();
}
/**

View File

@@ -8,8 +8,6 @@ use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
use BookStack\Permissions\Permission;
use BookStack\Util\HtmlContentFilter;
use BookStack\Util\HtmlContentFilterConfig;
class PageEditorData
{
@@ -49,7 +47,6 @@ class PageEditorData
$isDraftRevision = false;
$this->warnings = [];
$editActivity = new PageEditActivity($page);
$lastEditorId = $page->updated_by ?? user()->id;
if ($editActivity->hasActiveEditing()) {
$this->warnings[] = $editActivity->activeEditingMessage();
@@ -61,20 +58,11 @@ class PageEditorData
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
$isDraftRevision = true;
$this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
$lastEditorId = $userDraft->created_by;
}
// Get editor type and handle changes
$editorType = $this->getEditorType($page);
$this->updateContentForEditor($page, $editorType);
// Filter HTML content if required
if ($editorType->isHtmlBased() && !old('html') && $lastEditorId !== user()->id) {
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
$filter = new HtmlContentFilter($filterConfig);
$page->html = $filter->filterString($page->html);
}
return [
'page' => $page,
'book' => $page->book,

View File

@@ -1,40 +0,0 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\References\ReferenceUpdater;
class ParentChanger
{
public function __construct(
protected SlugGenerator $slugGenerator,
protected ReferenceUpdater $referenceUpdater
) {
}
/**
* Change the parent book of a chapter or page.
*/
public function changeBook(BookChild $child, int $newBookId): void
{
$oldUrl = $child->getUrl();
$child->book_id = $newBookId;
$child->unsetRelation('book');
$this->slugGenerator->regenerateForEntity($child);
$child->save();
if ($oldUrl !== $child->getUrl()) {
$this->referenceUpdater->updateEntityReferences($child, $oldUrl);
}
// Update all child pages if a chapter
if ($child instanceof Chapter) {
foreach ($child->pages()->withTrashed()->get() as $page) {
$this->changeBook($page, $newBookId);
}
}
}
}

View File

@@ -20,8 +20,8 @@ class PermissionsUpdater
*/
public function updateFromPermissionsForm(Entity $entity, Request $request): void
{
$permissions = $request->input('permissions', null);
$ownerId = $request->input('owned_by', null);
$permissions = $request->get('permissions', null);
$ownerId = $request->get('owned_by', null);
$entity->permissions()->delete();
@@ -47,7 +47,7 @@ class PermissionsUpdater
{
if (isset($data['role_permissions'])) {
$entity->permissions()->where('role_id', '!=', 0)->delete();
$rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions($data['role_permissions'], false);
$rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions($data['role_permissions'] ?? [], false);
$entity->permissions()->createMany($rolePermissionData);
}

View File

@@ -5,14 +5,12 @@ namespace BookStack\Entities\Tools;
use BookStack\App\Model;
use BookStack\App\SluggableInterface;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Users\Models\User;
use Illuminate\Support\Str;
class SlugGenerator
{
/**
* Generate a fresh slug for the given item.
* Generate a fresh slug for the given entity.
* The slug will be generated so that it doesn't conflict within the same parent item.
*/
public function generate(SluggableInterface&Model $model, string $slugSource): string
@@ -25,26 +23,6 @@ class SlugGenerator
return $slug;
}
/**
* Regenerate the slug for the given entity.
*/
public function regenerateForEntity(Entity $entity): string
{
$entity->slug = $this->generate($entity, $entity->name);
return $entity->slug;
}
/**
* Regenerate the slug for a user.
*/
public function regenerateForUser(User $user): string
{
$user->slug = $this->generate($user, $user->name);
return $user->slug;
}
/**
* Format a name as a URL slug.
*/

View File

@@ -1,97 +0,0 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\EntityTable;
use BookStack\Entities\Models\SlugHistory as SlugHistoryModel;
use BookStack\Permissions\PermissionApplicator;
use Illuminate\Support\Facades\DB;
class SlugHistory
{
public function __construct(
protected PermissionApplicator $permissions,
) {
}
/**
* Record the current slugs for the given entity.
*/
public function recordForEntity(Entity $entity): void
{
if (!$entity->id || !$entity->slug) {
return;
}
$parentSlug = null;
if ($entity instanceof BookChild) {
$parentSlug = $entity->book()->first()?->slug;
}
$latest = $this->getLatestEntryForEntity($entity);
if ($latest && $latest->slug === $entity->slug && $latest->parent_slug === $parentSlug) {
return;
}
$info = [
'sluggable_type' => $entity->getMorphClass(),
'sluggable_id' => $entity->id,
'slug' => $entity->slug,
'parent_slug' => $parentSlug,
];
$entry = new SlugHistoryModel();
$entry->forceFill($info);
$entry->save();
if ($entity instanceof Book) {
$this->recordForBookChildren($entity);
}
}
protected function recordForBookChildren(Book $book): void
{
$query = EntityTable::query()
->select(['type', 'id', 'slug', DB::raw("'{$book->slug}' as parent_slug"), DB::raw('now() as created_at'), DB::raw('now() as updated_at')])
->where('book_id', '=', $book->id)
->whereNotNull('book_id');
SlugHistoryModel::query()->insertUsing(
['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'],
$query
);
}
/**
* Find the latest visible entry for an entity which uses the given slug(s) in the history.
*/
public function lookupEntityIdUsingSlugs(string $type, string $slug, string $parentSlug = ''): ?int
{
$query = SlugHistoryModel::query()
->where('sluggable_type', '=', $type)
->where('slug', '=', $slug);
if ($parentSlug) {
$query->where('parent_slug', '=', $parentSlug);
}
$query = $this->permissions->restrictEntityRelationQuery($query, 'slug_history', 'sluggable_id', 'sluggable_type');
/** @var SlugHistoryModel|null $result */
$result = $query->orderBy('created_at', 'desc')->first();
return $result?->sluggable_id;
}
protected function getLatestEntryForEntity(Entity $entity): SlugHistoryModel|null
{
return SlugHistoryModel::query()
->where('sluggable_type', '=', $entity->getMorphClass())
->where('sluggable_id', '=', $entity->id)
->orderBy('created_at', 'desc')
->first();
}
}

View File

@@ -388,7 +388,7 @@ class TrashCan
/**
* Update entity relations to remove or update outstanding connections.
*/
protected function destroyCommonRelations(Entity $entity): void
protected function destroyCommonRelations(Entity $entity)
{
Activity::removeEntity($entity);
$entity->views()->delete();
@@ -402,7 +402,6 @@ class TrashCan
$entity->watches()->delete();
$entity->referencesTo()->delete();
$entity->referencesFrom()->delete();
$entity->slugHistory()->delete();
if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
$imageService = app()->make(ImageService::class);

View File

@@ -11,7 +11,6 @@ use BookStack\Entities\Tools\PageContent;
use BookStack\Uploads\ImageService;
use BookStack\Util\CspService;
use BookStack\Util\HtmlDocument;
use BookStack\Util\HtmlToPlainText;
use DOMElement;
use Exception;
use Throwable;
@@ -209,7 +208,7 @@ class ExportFormatter
preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
// Replace image src with base64 encoded image strings
if (count($imageTagsOutput[0]) > 0) {
if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
foreach ($imageTagsOutput[0] as $index => $imgMatch) {
$oldImgTagString = $imgMatch;
$srcString = $imageTagsOutput[2][$index];
@@ -226,7 +225,7 @@ class ExportFormatter
preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $linksOutput);
// Update relative links to be absolute, with instance url
if (count($linksOutput[0]) > 0) {
if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
foreach ($linksOutput[0] as $index => $linkMatch) {
$oldLinkString = $linkMatch;
$srcString = $linksOutput[2][$index];
@@ -243,13 +242,24 @@ class ExportFormatter
/**
* Converts the page contents into simple plain text.
* We re-generate the plain text from HTML at this point, post-page-content rendering.
* This method filters any bad looking content to provide a nice final output.
*/
public function pageToPlainText(Page $page, bool $pageRendered = false, bool $fromParent = false): string
{
$html = $pageRendered ? $page->html : (new PageContent($page))->render();
$contentText = (new HtmlToPlainText())->convert($html);
return $page->name . ($fromParent ? "\n" : "\n\n") . $contentText;
// Add proceeding spaces before tags so spaces remain between
// text within elements after stripping tags.
$html = str_replace('<', " <", $html);
$text = trim(strip_tags($html));
// Replace multiple spaces with single spaces
$text = preg_replace('/ {2,}/', ' ', $text);
// Reduce multiple horrid whitespace characters.
$text = preg_replace('/(\x0A|\xA0|\x0A|\r|\n){2,}/su', "\n\n", $text);
$text = html_entity_decode($text);
// Add title
$text = $page->name . ($fromParent ? "\n" : "\n\n") . $text;
return $text;
}
/**
@@ -257,7 +267,7 @@ class ExportFormatter
*/
public function chapterToPlainText(Chapter $chapter): string
{
$text = $chapter->name . "\n" . $chapter->descriptionInfo()->getPlain();
$text = $chapter->name . "\n" . $chapter->description;
$text = trim($text) . "\n\n";
$parts = [];
@@ -313,7 +323,7 @@ class ExportFormatter
$text .= $description . "\n\n";
}
foreach ($chapter->getVisiblePages() as $page) {
foreach ($chapter->pages as $page) {
$text .= $this->pageToMarkdown($page) . "\n\n";
}

View File

@@ -45,7 +45,7 @@ final class ZipExportAttachment extends ZipExportModel
$rules = [
'id' => ['nullable', 'int', $context->uniqueIdRule('attachment')],
'name' => ['required', 'string', 'min:1'],
'link' => ['required_without:file', 'nullable', 'string', 'max:2000', 'safe_url'],
'link' => ['required_without:file', 'nullable', 'string'],
'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()],
];

View File

@@ -58,16 +58,6 @@ class ZipExportReader
{
$this->open();
$info = $this->zip->statName('data.json');
if ($info === false) {
throw new ZipExportException(trans('errors.import_zip_cant_decode_data'));
}
$maxSize = max(intval(config()->get('app.upload_limit')), 1) * 1000000;
if ($info['size'] > $maxSize) {
throw new ZipExportException(trans('errors.import_zip_data_too_large'));
}
// Validate json data exists, including metadata
$jsonData = $this->zip->getFromName('data.json') ?: '';
$importData = json_decode($jsonData, true);
@@ -83,17 +73,6 @@ class ZipExportReader
return $this->zip->statName("files/{$fileName}") !== false;
}
public function fileWithinSizeLimit(string $fileName): bool
{
$fileInfo = $this->zip->statName("files/{$fileName}");
if ($fileInfo === false) {
return false;
}
$maxSize = max(intval(config()->get('app.upload_limit')), 1) * 1000000;
return $fileInfo['size'] <= $maxSize;
}
/**
* @return false|resource
*/

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