Compare commits

...

35 Commits

Author SHA1 Message Date
milnerTill
844c79ae72 improved error handling and tests added 2026-04-20 13:38:17 -03:00
milnerTill
f3c1fad50a add support to HS256 algorithm 2026-04-20 11:48:04 -03:00
Dan Brown
4f370ccddb Styles: Aligned fonts set on content and headers for exports
During review of #6069
2026-04-20 14:32:13 +01:00
Dan Brown
743a21a02f Merge branch 'fix/pdf-export-heading-fonts' of github.com:alexwoo-awso/BookStack into alexwoo-awso-fix/pdf-export-heading-fonts 2026-04-20 14:13:48 +01:00
Dan Brown
0c9fabb6de Merge pull request #6108 from BookStackApp/view_revisions_permission
Permissions: Started addition of revision-view permission
2026-04-19 16:39:29 +01:00
Dan Brown
426f9ac493 Permissions: Prevent export revision metadata view without permission 2026-04-19 16:23:16 +01:00
Dan Brown
ec0b0384a2 Permissions: Tweaks/fixed during review of revision-view-all changes 2026-04-19 16:06:31 +01:00
Dan Brown
e7e019d3d4 Permissions: Added testing coverage for revision-view-all 2026-04-19 15:56:54 +01:00
Dan Brown
1339f668eb Permissions: Added revision-view-all addition migration 2026-04-19 15:32:10 +01:00
Dan Brown
befa3a8fbb Permissions: Started addition of revision-view permission 2026-04-19 12:41:11 +01:00
Dan Brown
083fb1a600 Maintenance: Updated $request->get instance to use input 2026-04-18 20:43:27 +01:00
Dan Brown
a2bb5bdf10 Meta: Updated COC, templates, PR template for community rules
Added reference to new community rules page where sensible.
2026-04-17 21:22:04 +01:00
Dan Brown
e274a5fa4e Merge pull request #6100 from BookStackApp/wysiwyg_minimal_inline_code
WYSIWYG: Added inline code support to minimal editor
2026-04-16 11:25:19 +01:00
Dan Brown
18364d1e6e WYSIWYG: Added inline code support to minimal editor
Used for comments and descriptions.
Also updated shortcut handling that we're not registering shortcuts for
edits which can't use the related formatting types.

For #6003
2026-04-16 11:11:06 +01:00
Dan Brown
0760e677b2 Merge pull request #6095 from BookStackApp/tags_api
API:  New tag endpoints
2026-04-14 12:22:56 +01:00
Dan Brown
208629ee1f API: Some changes to tag API endpoints
- Updated tag values endpoint to use query param instead of path
  argument, so a better range of values can be provided (including those
  with slashes).
- Updated image gallery example request to align with docs use changes.
2026-04-14 12:03:29 +01:00
Dan Brown
346dc27979 API: Added testing to cover tags API endpoints 2026-04-14 11:31:34 +01:00
Dan Brown
1c1ad1d1b7 Tags API: Reviewed docs and added examples 2026-04-12 20:45:18 +01:00
Dan Brown
f14fc68b66 API: Added new tags API endpoints 2026-04-12 18:26:00 +01:00
Dan Brown
93f84a81b2 Merge pull request #6083 from BookStackApp/better_plain_text
New HTML to Plaintext handling
2026-04-12 17:01:45 +01:00
Dan Brown
4feb50e7ee Attachments: Aligned attachment validation a little more 2026-04-12 15:29:00 +01:00
Dan Brown
c7e2b487c1 Attachments: Aligned ZipExportAttachment link validation
With controller routes.
Don't consider this as a security issue, since the filtered URLs
by that validation are very likely to be blocked by browser security
or CSP, and there's a level of assumed privilege to the users that
are able to create such attachments links already.

Closes #6093
2026-04-12 15:17:31 +01:00
Dan Brown
4e3fa4822f Sort Rules: Added creation hints to sort rule selection
To help direct/indicate how rules can be created.
For #5967
2026-04-12 14:31:40 +01:00
Dan Brown
684a94c419 Theme Modules: Prevented zip-slip in new module extraction method
Updated the new (development only) approach which could result in
zip-slip causing trouble. This adds path normalisation, and testing to
cover.
2026-04-11 18:49:34 +01:00
Dan Brown
c3c8577f05 Merge pull request #6094 from BookStackApp/module_command_updates
Install Module Command Updates
2026-04-11 17:38:34 +01:00
Dan Brown
5fbaab4740 Theme modules: Allowed cross-origin redirects on download
With a prompt to the user to confirm they trust the origin.
For #6066
Added tests to cover.
2026-04-11 17:23:11 +01:00
Dan Brown
3d9d5fef51 Theme Modules: Updated install command to handle nested folder
Theme module ZIPs will now support their files being in a single nested
directory within a ZIP, to support common ZIP structure approaches.
Added test to cover.
For #6066
2026-04-11 15:04:53 +01:00
Dan Brown
5e78dc6ed5 Maintenance: Updated PHPStan to Level 4 (#6085) 2026-04-08 21:03:20 +01:00
Dan Brown
c33853ed84 Maintenance: Updated NPM packages (#6090)
* Maintenance: Updated NPM packages

Includes typescript update to 6. Needed to update some typescript config
to align with actual module environment used and built by esbuild.

* Maintenance: Fixed testing issues after NPM dep version changes

* Maintenance: Updated JS test workflow step version

* Maintenance: Updated approach used for TS config in jest config
2026-04-08 21:02:20 +01:00
Dan Brown
e033578fea Updated translator & dependency attribution before release v26.03.3 2026-04-05 22:43:15 +01:00
Dan Brown
a7dd998ac9 Updated translations with latest Crowdin changes (#6067) 2026-04-05 22:29:00 +01:00
Dan Brown
b9d650785a Deps: Updated PHP package versions 2026-04-05 22:28:27 +01:00
Dan Brown
abed4eae0c Exports: Updated plaintext export to use new converter 2026-04-05 17:51:19 +01:00
Dan Brown
c7d3775bb9 Plain text: Created a new HTML to plain text converter
To centralise logic to be more consistent, and to have smarter logic
which avoids just following newline format from input, preventing
smushing HTML elements (like list elements) next to eachother
2026-04-05 00:05:10 +01:00
ololukaszuk
0b659671fe Fix PDF heading font fallback for export 2026-03-25 15:23:15 +01:00
148 changed files with 2452 additions and 1513 deletions

View File

@@ -1,84 +1,2 @@
# 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
Please find our community rules on our website here:
https://www.bookstackapp.com/about/community-rules/

View File

@@ -56,3 +56,13 @@ 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

11
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,11 @@
## 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
HrCalmar :: Danish
Calle Calmar (HrCalmar) :: Danish
Avishay Rapp (AvishayRapp) :: Hebrew
matthias4217 :: French
Berke BOYLU2 (berkeboylu2) :: Turkish
@@ -534,3 +534,6 @@ 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@v4
- uses: actions/checkout@v6
- name: Install NPM deps
run: npm ci

1
.gitignore vendored
View File

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

View File

@@ -45,11 +45,11 @@ class ForgotPasswordController extends Controller
);
if ($response === Password::RESET_LINK_SENT) {
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->get('email'));
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->input('email'));
}
if (in_array($response, [Password::RESET_LINK_SENT, Password::INVALID_USER, Password::RESET_THROTTLED])) {
$message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
$message = trans('auth.reset_password_sent', ['email' => $request->input('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->get('prevent_auto_init') === 'true';
$preventInitiation = $request->input('prevent_auto_init') === 'true';
if ($request->has('email')) {
session()->flashInput([
'email' => $request->get('email'),
'password' => (config('app.env') === 'demo') ? $request->get('password', '') : '',
'email' => $request->input('email'),
'password' => (config('app.env') === 'demo') ? $request->input('password', '') : '',
]);
}
@@ -62,7 +62,7 @@ class LoginController extends Controller
public function login(Request $request)
{
$this->validateLogin($request);
$username = $request->get($this->username());
$username = $request->input($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->get('code'), $codes);
$updatedCodes = $codeService->removeInputCodeFromSet($request->input('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->get('method');
$desiredMethod = $request->input('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

@@ -55,6 +55,7 @@ class OidcController extends Controller
}
try {
$this->throwIfAuthorizationError($request);
$this->oidcService->processAuthorizeResponse($request->query('code'));
} catch (OidcException $oidcException) {
$this->showErrorNotification($oidcException->getMessage());
@@ -72,4 +73,23 @@ class OidcController extends Controller
{
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,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->get('token'));
: $this->sendResetFailedResponse($request, $response, $request->input('token'));
}
/**

View File

@@ -78,7 +78,7 @@ class Saml2Controller extends Controller
*/
public function startAcs(Request $request)
{
$samlResponse = $request->get('SAMLResponse', null);
$samlResponse = $request->input('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->get('id', null);
$acsId = $request->input('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->get('error_description'),
'error' => $request->input('error_description'),
]), '/login');
}

View File

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

View File

@@ -5,6 +5,7 @@ namespace BookStack\Access;
use BookStack\Access\Notifications\ConfirmEmailNotification;
use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Users\Models\User;
use Exception;
class EmailConfirmationService extends UserTokenService
{
@@ -16,6 +17,7 @@ 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'] ?? false);
$this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember']);
}
/**

View File

@@ -48,17 +48,16 @@ class MfaValue extends Model
}
/**
* Easily get the decrypted MFA value for the given user and method.
* 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 ? $mfaVal->getValue() : null;
return $mfaVal?->getValue();
}
/**

View File

@@ -0,0 +1,27 @@
<?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(string $clientId): bool
public function validate(OidcProviderSettings $settings): bool
{
parent::validateCommonTokenDetails($clientId);
$this->validateTokenClaims($clientId);
parent::validateCommonTokenDetails($settings);
$this->validateTokenClaims($settings->clientId);
return true;
}

View File

@@ -9,10 +9,7 @@ use phpseclib3\Math\BigInteger;
class OidcJwtSigningKey
{
/**
* @var PublicKey
*/
protected $key;
protected PublicKey $key;
/**
* Can be created either from a JWK parameter array or local file path to load a certificate from.
@@ -20,15 +17,13 @@ class OidcJwtSigningKey
* 'file:///var/www/cert.pem'
* ['kty' => 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'].
*
* @param array|string $jwkOrKeyPath
*
* @throws OidcInvalidKeyException
*/
public function __construct($jwkOrKeyPath)
public function __construct(array|string $jwkOrKeyPath)
{
if (is_array($jwkOrKeyPath)) {
$this->loadFromJwkArray($jwkOrKeyPath);
} elseif (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) {
} elseif (str_starts_with($jwkOrKeyPath, 'file://')) {
$this->loadFromPath($jwkOrKeyPath);
} else {
throw new OidcInvalidKeyException('Unexpected type of key value provided');
@@ -38,7 +33,7 @@ class OidcJwtSigningKey
/**
* @throws OidcInvalidKeyException
*/
protected function loadFromPath(string $path)
protected function loadFromPath(string $path): void
{
try {
$key = PublicKeyLoader::load(
@@ -58,7 +53,7 @@ class OidcJwtSigningKey
/**
* @throws OidcInvalidKeyException
*/
protected function loadFromJwkArray(array $jwk)
protected function loadFromJwkArray(array $jwk): void
{
// 'alg' is optional for a JWK, but we will still attempt to validate if
// it exists otherwise presume it will be compatible.
@@ -82,7 +77,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([

View File

@@ -9,7 +9,9 @@ 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[]
*/
@@ -59,11 +61,11 @@ class OidcJwtWithClaims implements ProvidesClaims
*
* @throws OidcInvalidTokenException
*/
public function validateCommonTokenDetails(string $clientId): bool
public function validateCommonTokenDetails(OidcProviderSettings $settings): bool
{
$this->validateTokenStructure();
$this->validateTokenSignature();
$this->validateCommonClaims($clientId);
$this->validateTokenSignature($settings);
$this->validateCommonClaims($settings->clientId);
return true;
}
@@ -117,31 +119,42 @@ class OidcJwtWithClaims implements ProvidesClaims
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenSignature(): void
{
if ($this->header['alg'] !== 'RS256') {
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
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']}");
}
$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 = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
$required = ['tokenEndpoint', 'authorizationEndpoint'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");

View File

@@ -14,7 +14,6 @@ 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;
/**
@@ -140,7 +139,7 @@ class OidcService
'redirectUri' => url('/oidc/callback'),
], [
'httpClient' => $this->http->buildClient(5),
'optionProvider' => new HttpBasicAuthOptionProvider(),
'optionProvider' => new OidcHttpBasicWithClientIdOptionProvider(),
]);
foreach ($this->getAdditionalScopes() as $scope) {
@@ -199,7 +198,7 @@ class OidcService
}
try {
$idToken->validate($settings->clientId);
$idToken->validate($settings);
} 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

@@ -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, saml_id: string}
* @return array{external_id: string, name: string, email: string|null, saml_id: string}
*/
protected function getUserDetails(string $samlID, $samlAttributes): array
{
@@ -357,7 +357,7 @@ class Saml2Service
]);
}
if ($userDetails['email'] === null) {
if (empty($userDetails['email'])) {
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 !== null && $socialAccount->user->id === $currentUser->id) {
if ($isLoggedIn && $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 !== null && $socialAccount->user->id != $currentUser->id) {
if ($isLoggedIn && $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->get('sort', 'activity_date');
$order = $request->get('order', 'desc');
$sort = $request->input('sort', 'activity_date');
$order = $request->input('order', 'desc');
$listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([
'created_at' => trans('settings.audit_table_date'),
'type' => trans('settings.audit_table_event'),
]);
$filters = [
'event' => $request->get('event', ''),
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
'user' => $request->get('user', ''),
'ip' => $request->get('ip', ''),
'event' => $request->input('event', ''),
'date_from' => $request->input('date_from', ''),
'date_to' => $request->input('date_to', ''),
'user' => $request->input('user', ''),
'ip' => $request->input('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->get('page', 1));
$page = intval($request->input('page', 1));
$favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount));
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;

View File

@@ -0,0 +1,68 @@
<?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->get('name', '');
$nameFilter = $request->input('name', '');
$tags = $this->tagRepo
->queryWithTotals($listOptions, $nameFilter)
->queryWithTotalsForList($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->get('search', '');
$searchTerm = $request->input('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->get('search', '');
$tagName = $request->get('name', '');
$searchTerm = $request->input('search', '');
$tagName = $request->input('name', '');
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
return response()->json($suggestions);

View File

@@ -9,6 +9,7 @@ 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;
@@ -87,6 +88,12 @@ class Comment extends Model implements Loggable, OwnableInterface
return $filter->filterString($this->html ?? '');
}
public function getPlainText(): string
{
$converter = new HtmlToPlainText();
return $converter->convert($this->html ?? '');
}
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id')

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

View File

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

View File

@@ -15,14 +15,14 @@ use BookStack\Users\Models\User;
class NotificationManager
{
/**
* @var class-string<NotificationHandler>[]
* @var array<string, class-string<NotificationHandler>[]>
*/
protected array $handlers = [];
protected array $handlersByActivity = [];
public function handle(Activity $activity, string|Loggable $detail, User $user): void
{
$activityType = $activity->type;
$handlersToRun = $this->handlers[$activityType] ?? [];
$handlersToRun = $this->handlersByActivity[$activityType] ?? [];
foreach ($handlersToRun as $handlerClass) {
/** @var NotificationHandler $handler */
$handler = new $handlerClass();
@@ -35,12 +35,12 @@ class NotificationManager
*/
public function registerHandler(string $activityType, string $handlerClass): void
{
if (!isset($this->handlers[$activityType])) {
$this->handlers[$activityType] = [];
if (!isset($this->handlersByActivity[$activityType])) {
$this->handlersByActivity[$activityType] = [];
}
if (!in_array($handlerClass, $this->handlers[$activityType])) {
$this->handlers[$activityType][] = $handlerClass;
if (!in_array($handlerClass, $this->handlersByActivity[$activityType])) {
$this->handlersByActivity[$activityType][] = $handlerClass;
}
}

View File

@@ -18,9 +18,10 @@ class TagRepo
}
/**
* Start a query against all tags in the system.
* Start a query against all tags in the system, with total counts for their usage,
* suitable for a system interface list with listing options.
*/
public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder
public function queryWithTotalsForList(SimpleListOptions $listOptions, string $nameFilter): Builder
{
$searchTerm = $listOptions->getSearch();
$sort = $listOptions->getSort();
@@ -28,17 +29,34 @@ 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('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'),
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'),
])
->orderBy($sort, $listOptions->getOrder())
->whereHas('entity');
if ($nameFilter) {
@@ -57,7 +75,7 @@ class TagRepo
});
}
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
return $query;
}
/**

View File

@@ -17,7 +17,14 @@ use ReflectionMethod;
class ApiDocsGenerator
{
/**
* @var array<string, ReflectionClass>
*/
protected array $reflectionClasses = [];
/**
* @var array<string, ApiController>
*/
protected array $controllerClasses = [];
/**
@@ -107,7 +114,6 @@ 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);
@@ -153,7 +159,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);
}
@@ -189,11 +195,12 @@ class ApiDocsGenerator
protected function getFlatApiRoutes(): Collection
{
return collect(Route::getRoutes()->getRoutes())->filter(function ($route) {
return strpos($route->uri, 'api/') === 0;
return str_starts_with($route->uri, 'api/');
})->map(function ($route) {
[$controller, $controllerMethod] = explode('@', $route->action['uses']);
$baseModelName = explode('.', explode('/', $route->uri)[1])[0];
$shortName = $baseModelName . '-' . $controllerMethod;
$controllerMethodKebab = Str::kebab($controllerMethod);
$shortName = $baseModelName . '-' . $controllerMethodKebab;
return [
'name' => $shortName,
@@ -201,7 +208,7 @@ class ApiDocsGenerator
'method' => $route->methods[0],
'controller' => $controller,
'controller_method' => $controllerMethod,
'controller_method_kebab' => Str::kebab($controllerMethod),
'controller_method_kebab' => $controllerMethodKebab,
'base_model' => $baseModelName,
];
});

View File

@@ -74,18 +74,21 @@ 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->book) {
if ($entity instanceof BookChild && $entity->relationLoaded('book') && $entity->getRelationValue('book')) {
return $entity->book->only(['id', 'name', 'slug']);
}
return null;
});
$this->withField('chapter', function (Entity $entity) {
if ($entity instanceof Page && $entity->chapter) {
if ($entity instanceof Page && $entity->relationLoaded('chapter') && $entity->getRelationValue('chapter')) {
return $entity->chapter->only(['id', 'name', 'slug']);
}
return null;

View File

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

View File

@@ -18,6 +18,13 @@ 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>
*/
@@ -54,7 +61,7 @@ class ListingResponseBuilder
{
$filteredQuery = $this->filterQuery($this->query);
$total = $filteredQuery->count();
$total = $filteredQuery->getCountForPagination();
$data = $this->fetchData($filteredQuery)->each(function ($model) {
foreach ($this->resultModifiers as $modifier) {
$modifier($model);
@@ -77,6 +84,14 @@ 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.
*/
@@ -94,7 +109,7 @@ class ListingResponseBuilder
protected function filterQuery(Builder $query): Builder
{
$query = clone $query;
$requestFilters = $this->request->get('filter', []);
$requestFilters = $this->request->input('filter', []);
if (!is_array($requestFilters)) {
return $query;
}
@@ -114,10 +129,11 @@ class ListingResponseBuilder
protected function requestFilterToQueryFilter($fieldKey, $value): ?array
{
$splitKey = explode(':', $fieldKey);
$field = $splitKey[0];
$field = strtolower($splitKey[0]);
$filterOperator = $splitKey[1] ?? 'eq';
if (!in_array($field, $this->fields)) {
$filterFields = $this->filterableFields ?? $this->fields;
if (!in_array($field, $filterFields)) {
return null;
}
@@ -140,8 +156,8 @@ class ListingResponseBuilder
$defaultSortName = $this->fields[0];
$direction = 'asc';
$sort = $this->request->get('sort', '');
if (strpos($sort, '-') === 0) {
$sort = $this->request->input('sort', '');
if (str_starts_with($sort, '-')) {
$direction = 'desc';
}
@@ -160,9 +176,9 @@ class ListingResponseBuilder
protected function countAndOffsetQuery(Builder $query): Builder
{
$query = clone $query;
$offset = max(0, $this->request->get('offset', 0));
$offset = max(0, $this->request->input('offset', 0));
$maxCount = config('api.max_item_count');
$count = $this->request->get('count', config('api.default_item_count'));
$count = $this->request->input('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->get('name'),
'name' => $request->input('name'),
'token_id' => Str::random(32),
'secret' => Hash::make($secret),
'user_id' => $user->id,
'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(),
'expires_at' => $request->input('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->get('name'),
'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(),
'name' => $request->input('name'),
'expires_at' => $request->input('expires_at') ?: ApiToken::defaultExpiry(),
])->save();
$this->logActivity(ActivityType::API_TOKEN_UPDATE, $token);

View File

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

View File

@@ -32,6 +32,7 @@ class CopyShelfPermissionsCommand extends Command
{
$shelfSlug = $this->option('slug');
$cascadeAll = $this->option('all');
$noInteraction = boolval($this->option('no-interaction'));
$shelves = null;
if (!$cascadeAll && !$shelfSlug) {
@@ -41,14 +42,16 @@ class CopyShelfPermissionsCommand extends Command
}
if ($cascadeAll) {
$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 (!$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?',
);
if (!$continue && !$this->hasOption('no-interaction')) {
return 0;
if (!$continue) {
return 0;
}
}
$shelves = $queries->start()->get(['id']);

View File

@@ -213,15 +213,23 @@ class InstallModuleCommand extends Command
$redirectLocation = $resp->getHeaderLine('Location');
if ($redirectLocation) {
$redirectUrl = parse_url($redirectLocation);
if (
($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '')
$redirectOriginMatches = ($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '')
&& ($originalUrl['scheme'] ?? '') === ($redirectUrl['scheme'] ?? '')
&& ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? '')
) {
$currentLocation = $redirectLocation;
$redirectCount++;
continue;
&& ($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;
}
}

View File

@@ -144,7 +144,7 @@ class BookController extends Controller
View::incrementFor($book);
if ($request->has('shelf')) {
$this->shelfContext->setShelfContext(intval($request->get('shelf')));
$this->shelfContext->setShelfContext(intval($request->input('shelf')));
}
$this->setPageTitle($book->getShortName());
@@ -263,7 +263,7 @@ class BookController extends Controller
$this->checkOwnablePermission(Permission::BookView, $book);
$this->checkPermission(Permission::BookCreateAll);
$newName = $request->get('name') ?: $book->name;
$newName = $request->input('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->get('books', []);
$bookIds = $request->input('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->get('books', null);
$bookIds = $request->input('books', null);
$shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);

View File

@@ -94,7 +94,7 @@ class BookshelfController extends Controller
'tags' => ['array'],
]);
$bookIds = explode(',', $request->get('books', ''));
$bookIds = explode(',', $request->input('books', ''));
$shelf = $this->shelfRepo->create($validated, $bookIds);
return redirect($shelf->getUrl());
@@ -196,7 +196,7 @@ class BookshelfController extends Controller
unset($validated['image']);
}
$bookIds = explode(',', $request->get('books', ''));
$bookIds = explode(',', $request->input('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->get('book_id');
$bookId = $request->input('book_id');
$book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId));
$this->checkOwnablePermission(Permission::ChapterCreate, $book);

View File

@@ -203,7 +203,7 @@ class ChapterController extends Controller
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
$entitySelection = $request->get('entity_selection', null);
$entitySelection = $request->input('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') {
return redirect($chapter->getUrl());
}
@@ -248,7 +248,7 @@ class ChapterController extends Controller
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$entitySelection = $request->get('entity_selection') ?: null;
$entitySelection = $request->input('entity_selection') ?: null;
$newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent();
if (!$newParentBook instanceof Book) {
@@ -259,7 +259,7 @@ class ChapterController extends Controller
$this->checkOwnablePermission(Permission::ChapterCreate, $newParentBook);
$newName = $request->get('name') ?: $chapter->name;
$newName = $request->input('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->get('chapter_id')));
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id')));
} else {
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('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->get('chapter_id')));
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id')));
} elseif ($request->has('book_id')) {
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('book_id')));
}
if ($parent && !$parent->matches($page->getParent())) {

View File

@@ -88,7 +88,7 @@ class PageController extends Controller
$page = $this->pageRepo->getNewDraftPage($parent);
$this->pageRepo->publishDraft($page, [
'name' => $request->get('name'),
'name' => $request->input('name'),
]);
return redirect($page->getUrl('/edit'));
@@ -408,7 +408,7 @@ class PageController extends Controller
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$this->checkOwnablePermission(Permission::PageDelete, $page);
$entitySelection = $request->get('entity_selection', null);
$entitySelection = $request->input('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') {
return redirect($page->getUrl());
}
@@ -453,7 +453,7 @@ class PageController extends Controller
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission(Permission::PageView, $page);
$entitySelection = $request->get('entity_selection') ?: null;
$entitySelection = $request->input('entity_selection') ?: null;
$newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent();
if (!$newParent instanceof Book && !$newParent instanceof Chapter) {
@@ -464,7 +464,7 @@ class PageController extends Controller
$this->checkOwnablePermission(Permission::PageCreate, $newParent);
$newName = $request->get('name') ?: $page->name;
$newName = $request->input('name') ?: $page->name;
$pageCopy = $cloner->clonePage($page, $newParent, $newName);
$this->showSuccessNotification(trans('entities.pages_copy_success'));

View File

@@ -34,6 +34,7 @@ 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')
@@ -65,6 +66,8 @@ 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();
@@ -94,6 +97,8 @@ 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();
@@ -129,6 +134,7 @@ 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);
@@ -144,6 +150,7 @@ 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->get('page', 1);
$search = $request->get('search', '');
$page = $request->input('page', 1);
$search = $request->input('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

View File

@@ -479,6 +479,7 @@ 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

@@ -23,7 +23,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
* @property bool $draft
* @property int $revision_count
* @property string $editor
* @property Chapter $chapter
* @property Chapter|null $chapter
* @property Collection $attachments
* @property Collection $revisions
* @property PageRevision $currentRevision

View File

@@ -16,6 +16,7 @@ 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
@@ -151,9 +152,10 @@ class BaseRepo
}
if (isset($input['description_html'])) {
$plainTextConverter = new HtmlToPlainText();
$entity->descriptionInfo()->set(
HtmlDescriptionFilter::filterFromString($input['description_html']),
html_entity_decode(strip_tags($input['description_html']))
$plainTextConverter->convert($input['description_html']),
);
} else if (isset($input['description'])) {
$entity->descriptionInfo()->set('', $input['description']);

View File

@@ -60,7 +60,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,

View File

@@ -16,6 +16,7 @@ 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;
@@ -303,8 +304,8 @@ class PageContent
public function toPlainText(): string
{
$html = $this->render(true);
return html_entity_decode(strip_tags($html));
$converter = new HtmlToPlainText();
return $converter->convert($html);
}
/**
@@ -359,7 +360,7 @@ class PageContent
{
$contentHash = md5($html);
$contentId = $this->page->id;
$contentTime = $this->page->updated_at?->timestamp ?? time();
$contentTime = $this->page->updated_at->timestamp ?? time();
$appVersion = AppVersion::get();
$filterConfig = config('app.content_filtering') ?? '';
return "page-content-cache::{$filterConfig}::{$appVersion}::{$contentId}::{$contentTime}::{$contentHash}";

View File

@@ -20,8 +20,8 @@ class PermissionsUpdater
*/
public function updateFromPermissionsForm(Entity $entity, Request $request): void
{
$permissions = $request->get('permissions', null);
$ownerId = $request->get('owned_by', null);
$permissions = $request->input('permissions', null);
$ownerId = $request->input('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

@@ -11,6 +11,7 @@ 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;
@@ -208,7 +209,7 @@ class ExportFormatter
preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
// Replace image src with base64 encoded image strings
if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
if (count($imageTagsOutput[0]) > 0) {
foreach ($imageTagsOutput[0] as $index => $imgMatch) {
$oldImgTagString = $imgMatch;
$srcString = $imageTagsOutput[2][$index];
@@ -225,7 +226,7 @@ class ExportFormatter
preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $linksOutput);
// Update relative links to be absolute, with instance url
if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
if (count($linksOutput[0]) > 0) {
foreach ($linksOutput[0] as $index => $linkMatch) {
$oldLinkString = $linkMatch;
$srcString = $linksOutput[2][$index];
@@ -242,24 +243,13 @@ class ExportFormatter
/**
* Converts the page contents into simple plain text.
* This method filters any bad looking content to provide a nice final output.
* We re-generate the plain text from HTML at this point, post-page-content rendering.
*/
public function pageToPlainText(Page $page, bool $pageRendered = false, bool $fromParent = false): string
{
$html = $pageRendered ? $page->html : (new PageContent($page))->render();
// 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;
$contentText = (new HtmlToPlainText())->convert($html);
return $page->name . ($fromParent ? "\n" : "\n\n") . $contentText;
}
/**
@@ -267,7 +257,7 @@ class ExportFormatter
*/
public function chapterToPlainText(Chapter $chapter): string
{
$text = $chapter->name . "\n" . $chapter->description;
$text = $chapter->name . "\n" . $chapter->descriptionInfo()->getPlain();
$text = trim($text) . "\n\n";
$parts = [];

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'],
'link' => ['required_without:file', 'nullable', 'string', 'max:2000', 'safe_url'],
'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()],
];

View File

@@ -82,10 +82,8 @@ class ZipImportRunner
$entity = $this->importBook($exportModel, $reader);
} else if ($exportModel instanceof ZipExportChapter) {
$entity = $this->importChapter($exportModel, $parent, $reader);
} else if ($exportModel instanceof ZipExportPage) {
$entity = $this->importPage($exportModel, $parent, $reader);
} else {
throw new ZipImportException(['No importable data found in import data.']);
$entity = $this->importPage($exportModel, $parent, $reader);
}
$this->references->replaceReferences();
@@ -132,7 +130,7 @@ class ZipImportRunner
'name' => $exportBook->name,
'description_html' => $exportBook->description_html ?? '',
'image' => $exportBook->cover ? $this->zipFileToUploadedFile($exportBook->cover, $reader) : null,
'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []),
'tags' => $this->exportTagsToInputArray($exportBook->tags),
]);
if ($book->coverInfo()->getImage()) {
@@ -151,7 +149,7 @@ class ZipImportRunner
foreach ($children as $child) {
if ($child instanceof ZipExportChapter) {
$this->importChapter($child, $book, $reader);
} else if ($child instanceof ZipExportPage) {
} else {
$this->importPage($child, $book, $reader);
}
}
@@ -166,7 +164,7 @@ class ZipImportRunner
$chapter = $this->chapterRepo->create([
'name' => $exportChapter->name,
'description_html' => $exportChapter->description_html ?? '',
'tags' => $this->exportTagsToInputArray($exportChapter->tags ?? []),
'tags' => $this->exportTagsToInputArray($exportChapter->tags),
], $parent);
$exportPages = $exportChapter->pages;
@@ -199,7 +197,7 @@ class ZipImportRunner
'name' => $exportPage->name,
'markdown' => $exportPage->markdown ?? '',
'html' => $exportPage->html ?? '',
'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []),
'tags' => $this->exportTagsToInputArray($exportPage->tags),
]);
$this->references->addPage($page, $exportPage);
@@ -302,7 +300,7 @@ class ZipImportRunner
array_push($chapters, ...$exportModel->chapters);
} else if ($exportModel instanceof ZipExportChapter) {
$chapters[] = $exportModel;
} else if ($exportModel instanceof ZipExportPage) {
} else {
$pages[] = $exportModel;
}

View File

@@ -68,10 +68,6 @@ class ZipReferenceParser
$matches = [];
preg_match_all($referenceRegex, $content, $matches);
if (count($matches) < 3) {
return $content;
}
for ($i = 0; $i < count($matches[0]); $i++) {
$referenceText = $matches[0][$i];
$type = strtolower($matches[1][$i]);

View File

@@ -20,10 +20,14 @@ abstract class ApiController extends Controller
* Provide a paginated listing JSON response in a standard format
* taking into account any pagination parameters passed by the user.
*/
protected function apiListingResponse(Builder $query, array $fields, array $modifiers = []): JsonResponse
protected function apiListingResponse(Builder $query, array $fields, array $modifiers = [], array $filterableFields = []): JsonResponse
{
$listing = new ListingResponseBuilder($query, request(), $fields);
if (count($filterableFields) > 0) {
$listing->setFilterableFields($filterableFields);
}
foreach ($modifiers as $modifier) {
$listing->modifyResults($modifier);
}

View File

@@ -62,7 +62,7 @@ abstract class Controller extends BaseController
*/
protected function checkPermission(string|Permission $permission): void
{
if (!user() || !user()->can($permission)) {
if (!user()->can($permission)) {
$this->showPermissionError();
}
}

View File

@@ -61,8 +61,7 @@ class JointPermissionBuilder
return;
}
/** @var BookChild $entity */
if ($entity->book) {
if ($entity instanceof BookChild) {
$entities[] = $entity->book;
}

View File

@@ -118,6 +118,8 @@ enum Permission: string
case PageViewAll = 'page-view-all';
case PageViewOwn = 'page-view-own';
case RevisionViewAll = 'revision-view-all';
/**
* Get the generic permissions which may be queried for entities.
*/

View File

@@ -40,9 +40,9 @@ class SearchApiController extends ApiController
{
$this->validate($request, $this->rules['all']);
$options = SearchOptions::fromString($request->get('query') ?? '');
$page = intval($request->get('page', '0')) ?: 1;
$count = min(intval($request->get('count', '0')) ?: 20, 100);
$options = SearchOptions::fromString($request->input('query') ?? '');
$page = intval($request->input('page', '0')) ?: 1;
$count = min(intval($request->input('count', '0')) ?: 20, 100);
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
$this->resultsFormatter->format($results['results']->all(), $options);

View File

@@ -24,7 +24,7 @@ class SearchController extends Controller
{
$searchOpts = SearchOptions::fromRequest($request);
$fullSearchString = $searchOpts->toString();
$page = intval($request->get('page', '0')) ?: 1;
$page = intval($request->input('page', '0')) ?: 1;
$count = setting()->getInteger('lists-page-count-search', 18, 1, 1000);
$results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, $count);
@@ -49,7 +49,7 @@ class SearchController extends Controller
*/
public function searchBook(Request $request, int $bookId)
{
$term = $request->get('term', '');
$term = $request->input('term', '');
$results = $this->searchRunner->searchBook($bookId, $term);
return view('entities.list', ['entities' => $results]);
@@ -60,7 +60,7 @@ class SearchController extends Controller
*/
public function searchChapter(Request $request, int $chapterId)
{
$term = $request->get('term', '');
$term = $request->input('term', '');
$results = $this->searchRunner->searchChapter($chapterId, $term);
return view('entities.list', ['entities' => $results]);
@@ -72,9 +72,9 @@ class SearchController extends Controller
*/
public function searchForSelector(Request $request, QueryPopular $queryPopular)
{
$entityTypes = $request->filled('types') ? explode(',', $request->get('types')) : ['page', 'chapter', 'book'];
$searchTerm = $request->get('term', false);
$permission = $request->get('permission', 'view');
$entityTypes = $request->filled('types') ? explode(',', $request->input('types')) : ['page', 'chapter', 'book'];
$searchTerm = $request->input('term', false);
$permission = $request->input('permission', 'view');
// Search for entities otherwise show most popular
if ($searchTerm !== false) {
@@ -93,7 +93,7 @@ class SearchController extends Controller
*/
public function templatesForSelector(Request $request)
{
$searchTerm = $request->get('term', false);
$searchTerm = $request->input('term', false);
if ($searchTerm !== false) {
$searchOptions = SearchOptions::fromString($searchTerm);
@@ -119,7 +119,7 @@ class SearchController extends Controller
*/
public function searchSuggestions(Request $request)
{
$searchTerm = $request->get('term', '');
$searchTerm = $request->input('term', '');
$entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 5)['results'];
foreach ($entities as $entity) {
@@ -136,8 +136,8 @@ class SearchController extends Controller
*/
public function searchSiblings(Request $request, SiblingFetcher $siblingFetcher)
{
$type = $request->get('entity_type', null);
$id = $request->get('entity_id', null);
$type = $request->input('entity_type', null);
$id = $request->input('entity_id', null);
$entities = $siblingFetcher->fetch($type, $id);

View File

@@ -51,7 +51,7 @@ class SearchOptions
}
if ($request->has('term')) {
return static::fromString($request->get('term'));
return static::fromString($request->input('term'));
}
$instance = new SearchOptions();
@@ -121,13 +121,11 @@ class SearchOptions
foreach ($patterns as $termType => $pattern) {
$matches = [];
preg_match_all($pattern, $searchString, $matches);
if (count($matches) > 0) {
foreach ($matches[1] as $index => $value) {
$negated = str_starts_with($matches[0][$index], '-');
$terms[$termType][] = $constructors[$termType]($value, $negated);
}
$searchString = preg_replace($pattern, '', $searchString);
foreach ($matches[1] as $index => $value) {
$negated = str_starts_with($matches[0][$index], '-');
$terms[$termType][] = $constructors[$termType]($value, $negated);
}
$searchString = preg_replace($pattern, '', $searchString);
}
// Unescape exacts and backslash escapes
@@ -261,7 +259,7 @@ class SearchOptions
$userFilters = ['updated_by', 'created_by', 'owned_by'];
$unsupportedFilters = ['is_template', 'sort_by'];
foreach ($this->filters->all() as $filter) {
if (in_array($filter->getKey(), $userFilters, true) && $filter->value !== null && $filter->value !== 'me') {
if (in_array($filter->getKey(), $userFilters, true) && $filter->value && $filter->value !== 'me') {
$options[] = $filter;
} else if (in_array($filter->getKey(), $unsupportedFilters, true)) {
$options[] = $filter;

View File

@@ -44,7 +44,7 @@ class AppSettingsStore
}
// Clear icon image if requested
if ($request->get('app_icon_reset')) {
if ($request->input('app_icon_reset')) {
$this->destroyExistingSettingImage('app-icon');
setting()->remove('app-icon');
foreach ($sizes as $size) {
@@ -67,7 +67,7 @@ class AppSettingsStore
}
// Clear logo image if requested
if ($request->get('app_logo_reset')) {
if ($request->input('app_logo_reset')) {
$this->destroyExistingSettingImage('app-logo');
setting()->remove('app-logo');
}

View File

@@ -38,7 +38,7 @@ class MaintenanceController extends Controller
$this->checkPermission(Permission::SettingsManage);
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'cleanup-images');
$checkRevisions = !($request->get('ignore_revisions', 'false') === 'true');
$checkRevisions = !($request->input('ignore_revisions', 'false') === 'true');
$dryRun = !($request->has('confirm'));
$imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun);

View File

@@ -58,7 +58,7 @@ class BookSortController extends Controller
// Sort via map
if ($request->filled('sort-tree')) {
(new DatabaseTransaction(function () use ($book, $request, $sorter, &$loggedActivityForBook) {
$sortMap = BookSortMap::fromJson($request->get('sort-tree'));
$sortMap = BookSortMap::fromJson($request->input('sort-tree'));
$booksInvolved = $sorter->sortUsingMap($sortMap);
// Add activity for involved books.
@@ -72,7 +72,7 @@ class BookSortController extends Controller
}
if ($request->filled('auto-sort')) {
$sortSetId = intval($request->get('auto-sort')) ?: null;
$sortSetId = intval($request->input('auto-sort')) ?: null;
if ($sortSetId && SortRule::query()->find($sortSetId) === null) {
$sortSetId = null;
}

View File

@@ -125,9 +125,8 @@ class BookSorter
*/
protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
{
/** @var BookChild $model */
$model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
if (!$model) {
if (!($model instanceof BookChild)) {
return;
}

View File

@@ -51,7 +51,14 @@ class ThemeModuleManager
}
$folderPath = $this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName;
$zip->extractTo($folderPath);
try {
$zip->extractTo($folderPath);
} catch (ThemeModuleException $exception) {
if (is_dir($folderPath)) {
$this->deleteDirectoryRecursively($folderPath);
}
throw new ThemeModuleException("Failed to load extract files from module ZIP with error: {$exception->getMessage()}");
}
$module = $this->loadFromFolder($folderName);
if (!$module) {

View File

@@ -2,6 +2,7 @@
namespace BookStack\Theming;
use BookStack\Util\FilePathNormalizer;
use ZipArchive;
readonly class ThemeModuleZip
@@ -15,7 +16,46 @@ readonly class ThemeModuleZip
{
$zip = new ZipArchive();
$zip->open($this->path);
$zip->extractTo($destinationPath);
$prefix = $this->getZipContentPrefix($zip);
for ($i = 0; $i < $zip->numFiles; $i++) {
$name = $zip->getNameIndex($i);
$entryIsDir = str_ends_with($name, "/");
if ($entryIsDir) {
continue;
}
$stream = $zip->getStreamIndex($i);
if ($prefix) {
if (!str_starts_with($name, $prefix) || $name === $prefix) {
continue;
}
$name = str_replace($prefix, '', $name);
}
try {
$targetPath = $destinationPath . DIRECTORY_SEPARATOR . FilePathNormalizer::normalize($name);
} catch (\Exception $exception) {
throw new ThemeModuleException("Bad file path found in module ZIP file: {$name}");
}
$targetPathDir = dirname($targetPath);
if (!is_dir($targetPathDir)) {
$dirCreated = mkdir($targetPathDir, 0777, true);
if (!$dirCreated) {
throw new ThemeModuleException("Failed to create directory {$targetPathDir} when extracting module files");
}
}
$targetFile = fopen($targetPath, 'w');
$written = stream_copy_to_stream($stream, $targetFile);
if (!$written) {
throw new ThemeModuleException("Failed to write to {$targetPath} when extracting module files");
}
fclose($targetFile);
}
$zip->close();
}
@@ -31,7 +71,8 @@ readonly class ThemeModuleZip
throw new ThemeModuleException("Unable to open zip file at {$this->path}");
}
$moduleJsonText = $zip->getFromName('bookstack-module.json');
$prefix = $this->getZipContentPrefix($zip);
$moduleJsonText = $zip->getFromName("{$prefix}bookstack-module.json");
$zip->close();
if ($moduleJsonText === false) {
@@ -95,4 +136,20 @@ readonly class ThemeModuleZip
return $totalSize;
}
protected function getZipContentPrefix(ZipArchive $zip): string
{
$index = $zip->locateName('bookstack-module.json', ZipArchive::FL_NODIR);
if ($index === false) {
return '';
}
$location = $zip->getNameIndex($index);
$pathParts = explode('/', $location);
if (count($pathParts) !== 2) {
return '';
}
return $pathParts[0] . '/';
}
}

View File

@@ -50,7 +50,7 @@ class AttachmentApiController extends ApiController
$this->checkPermission(Permission::AttachmentCreateAll);
$requestData = $this->validate($request, $this->rules()['create']);
$pageId = $request->get('uploaded_to');
$pageId = $request->input('uploaded_to');
$page = $this->pageQueries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
@@ -134,7 +134,7 @@ class AttachmentApiController extends ApiController
$page = $attachment->page;
if ($requestData['uploaded_to'] ?? false) {
$pageId = $request->get('uploaded_to');
$pageId = $request->input('uploaded_to');
$page = $this->pageQueries->findVisibleByIdOrFail($pageId);
$attachment->uploaded_to = $requestData['uploaded_to'];
}

View File

@@ -39,7 +39,7 @@ class AttachmentController extends Controller
'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()),
]);
$pageId = $request->get('uploaded_to');
$pageId = $request->input('uploaded_to');
$page = $this->pageQueries->findVisibleByIdOrFail($pageId);
$this->checkPermission(Permission::AttachmentCreateAll);
@@ -125,8 +125,8 @@ class AttachmentController extends Controller
$this->checkOwnablePermission(Permission::AttachmentUpdate, $attachment);
$attachment = $this->attachmentService->updateFile($attachment, [
'name' => $request->get('attachment_edit_name'),
'link' => $request->get('attachment_edit_url'),
'name' => $request->input('attachment_edit_name'),
'link' => $request->input('attachment_edit_url'),
]);
return view('attachments.manager-edit-form', [
@@ -141,7 +141,7 @@ class AttachmentController extends Controller
*/
public function attachLink(Request $request)
{
$pageId = $request->get('attachment_link_uploaded_to');
$pageId = $request->input('attachment_link_uploaded_to');
try {
$this->validate($request, [
@@ -161,8 +161,8 @@ class AttachmentController extends Controller
$this->checkPermission(Permission::AttachmentCreateAll);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$attachmentName = $request->get('attachment_link_name');
$link = $request->get('attachment_link_url');
$attachmentName = $request->input('attachment_link_name');
$link = $request->input('attachment_link_url');
$this->attachmentService->saveNewFromLink($attachmentName, $link, intval($pageId));
return view('attachments.manager-link-form', [
@@ -198,7 +198,7 @@ class AttachmentController extends Controller
$page = $this->pageQueries->findVisibleByIdOrFail($pageId);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
$attachmentOrder = $request->get('order');
$attachmentOrder = $request->input('order');
$this->attachmentService->updateFileOrderWithinPage($attachmentOrder, $pageId);
return response()->json(['message' => trans('entities.attachments_order_updated')]);
@@ -231,7 +231,7 @@ class AttachmentController extends Controller
$attachmentStream = $this->attachmentService->streamAttachmentFromStorage($attachment);
$attachmentSize = $this->attachmentService->getAttachmentFileSize($attachment);
if ($request->get('open') === 'true') {
if ($request->input('open') === 'true') {
return $this->download()->streamedInline($attachmentStream, $fileName, $attachmentSize);
}

View File

@@ -24,10 +24,10 @@ class DrawioImageController extends Controller
*/
public function list(Request $request, ImageResizer $resizer)
{
$page = $request->get('page', 1);
$searchTerm = $request->get('search', null);
$uploadedToFilter = $request->get('uploaded_to', null);
$parentTypeFilter = $request->get('filter_type', null);
$page = $request->input('page', 1);
$searchTerm = $request->input('search', null);
$uploadedToFilter = $request->input('uploaded_to', null);
$parentTypeFilter = $request->input('filter_type', null);
$imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
$viewData = [
@@ -59,10 +59,10 @@ class DrawioImageController extends Controller
]);
$this->checkPermission(Permission::ImageCreateAll);
$imageBase64Data = $request->get('image');
$imageBase64Data = $request->input('image');
try {
$uploadedTo = $request->get('uploaded_to', 0);
$uploadedTo = $request->input('uploaded_to', 0);
$image = $this->imageRepo->saveDrawing($imageBase64Data, $uploadedTo);
} catch (ImageUploadException $e) {
return response($e->getMessage(), 500);

View File

@@ -24,10 +24,10 @@ class GalleryImageController extends Controller
*/
public function list(Request $request, ImageResizer $resizer)
{
$page = $request->get('page', 1);
$searchTerm = $request->get('search', null);
$uploadedToFilter = $request->get('uploaded_to', null);
$parentTypeFilter = $request->get('filter_type', null);
$page = $request->input('page', 1);
$searchTerm = $request->input('search', null);
$uploadedToFilter = $request->input('uploaded_to', null);
$parentTypeFilter = $request->input('filter_type', null);
$imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 30, $uploadedToFilter, $searchTerm);
$viewData = [
@@ -69,7 +69,7 @@ class GalleryImageController extends Controller
try {
$imageUpload = $request->file('file');
$uploadedTo = $request->get('uploaded_to', 0);
$uploadedTo = $request->input('uploaded_to', 0);
$image = $this->imageRepo->saveNew($imageUpload, 'gallery', $uploadedTo);
} catch (ImageUploadException $e) {
return response($e->getMessage(), 500);

View File

@@ -91,7 +91,7 @@ class ImageRepo
$parentFilter = function (Builder $query) use ($filterType, $contextPage) {
if ($filterType === 'page') {
$query->where('uploaded_to', '=', $contextPage->id);
} else if ($filterType === 'book') {
} else {
$validPageIds = $contextPage->book->pages()
->scopes('visible')
->pluck('id')

View File

@@ -148,7 +148,7 @@ class UserAvatars
$responseCount++;
$isRedirect = ($response->getStatusCode() === 301 || $response->getStatusCode() === 302);
$url = $response->getHeader('Location')[0] ?? '';
} while ($responseCount < 3 && $isRedirect && is_string($url) && str_starts_with($url, 'http'));
} while ($responseCount < 3 && $isRedirect && str_starts_with($url, 'http'));
if ($responseCount === 3) {
throw new HttpFetchException("Failed to fetch image, max redirect limit of 3 tries reached. Last fetched URL: {$url}");

View File

@@ -55,7 +55,7 @@ class RoleController extends Controller
/** @var ?Role $role */
$role = null;
if ($request->has('copy_from')) {
$role = Role::query()->find($request->get('copy_from'));
$role = Role::query()->find($request->input('copy_from'));
}
if ($role) {
@@ -150,7 +150,7 @@ class RoleController extends Controller
$this->checkPermission(Permission::UserRolesManage);
try {
$migrateRoleId = intval($request->get('migrate_role_id') ?: "0");
$migrateRoleId = intval($request->input('migrate_role_id') ?: "0");
$this->permissionsRepo->deleteRole($id, $migrateRoleId);
} catch (PermissionsException $e) {
$this->showErrorNotification($e->getMessage());

View File

@@ -106,8 +106,8 @@ class UserAccountController extends Controller
*/
public function updateShortcuts(Request $request)
{
$enabled = $request->get('enabled') === 'true';
$providedShortcuts = $request->get('shortcut', []);
$enabled = $request->input('enabled') === 'true';
$providedShortcuts = $request->input('shortcut', []);
$shortcuts = new UserShortcutMap($providedShortcuts);
setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson());
@@ -218,7 +218,7 @@ class UserAccountController extends Controller
{
$this->preventAccessInDemoMode();
$requestNewOwnerId = intval($request->get('new_owner_id')) ?: null;
$requestNewOwnerId = intval($request->input('new_owner_id')) ?: null;
$newOwnerId = userCan(Permission::UsersManage) ? $requestNewOwnerId : null;
$this->userRepo->destroy(user(), $newOwnerId);

View File

@@ -141,7 +141,7 @@ class UserApiController extends ApiController
public function delete(Request $request, string $id)
{
$user = $this->userRepo->getById($id);
$newOwnerId = $request->get('migrate_ownership_id', null);
$newOwnerId = $request->input('migrate_ownership_id', null);
$this->userRepo->destroy($user, $newOwnerId);

View File

@@ -77,7 +77,7 @@ class UserController extends Controller
$this->checkPermission(Permission::UsersManage);
$authMethod = config('auth.method');
$sendInvite = ($request->get('send_invite', 'false') === 'true');
$sendInvite = ($request->input('send_invite', 'false') === 'true');
$externalAuth = $authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'oidc';
$passwordRequired = ($authMethod === 'standard' && !$sendInvite);
@@ -202,7 +202,7 @@ class UserController extends Controller
$this->checkPermission(Permission::UsersManage);
$user = $this->userRepo->getById($id);
$newOwnerId = intval($request->get('new_owner_id')) ?: null;
$newOwnerId = intval($request->input('new_owner_id')) ?: null;
$this->userRepo->destroy($user, $newOwnerId);

View File

@@ -23,7 +23,7 @@ class UserPreferencesController extends Controller
return $this->redirectToRequest($request);
}
$view = $request->get('view');
$view = $request->input('view');
if (!in_array($view, ['grid', 'list'])) {
$view = 'list';
}
@@ -44,8 +44,8 @@ class UserPreferencesController extends Controller
return $this->redirectToRequest($request);
}
$sort = substr($request->get('sort') ?: 'name', 0, 50);
$order = $request->get('order') === 'desc' ? 'desc' : 'asc';
$sort = substr($request->input('sort') ?: 'name', 0, 50);
$order = $request->input('order') === 'desc' ? 'desc' : 'asc';
$sortKey = $type . '_sort';
$orderKey = $type . '_sort_order';
@@ -76,7 +76,7 @@ class UserPreferencesController extends Controller
return response('Invalid key', 500);
}
$newState = $request->get('expand', 'false');
$newState = $request->input('expand', 'false');
setting()->putForCurrentUser('section_expansion#' . $type, $newState);
return response('', 204);

View File

@@ -26,7 +26,7 @@ class UserSearchController extends Controller
$this->showPermissionError();
}
$search = $request->get('search', '');
$search = $request->input('search', '');
$query = User::query()
->orderBy('name', 'asc')
->take(20);
@@ -58,7 +58,7 @@ class UserSearchController extends Controller
$this->showPermissionError();
}
$search = $request->get('search', '');
$search = $request->input('search', '');
$query = User::query()
->orderBy('name', 'asc')
->take(20);

View File

@@ -222,8 +222,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
public function getAvatar(int $size = 50): string
{
$default = url('/user_avatar.png');
$imageId = $this->image_id;
if ($imageId === 0 || $imageId === '0' || $imageId === null) {
if ($this->image_id === 0) {
return $default;
}

View File

@@ -27,6 +27,7 @@ class HtmlDescriptionFilter
'span' => [],
'em' => [],
'br' => [],
'code' => [],
];
public static function filterFromString(string $html): string

View File

@@ -0,0 +1,47 @@
<?php
namespace BookStack\Util;
class HtmlToPlainText
{
/**
* Inline tags types where the content should not be put on a new line.
*/
protected array $inlineTags = [
'a', 'b', 'i', 'u', 'strong', 'em', 'small', 'sup', 'sub', 'span', 'div',
];
/**
* Convert the provided HTML to relatively clean plain text.
*/
public function convert(string $html): string
{
$doc = new HtmlDocument($html);
$text = $this->nodeToText($doc->getBody());
// Remove repeated newlines
$text = preg_replace('/\n+/', "\n", $text);
// Remove leading/trailing whitespace
$text = trim($text);
return $text;
}
protected function nodeToText(\DOMNode $node): string
{
if ($node->nodeType === XML_TEXT_NODE) {
return $node->textContent;
}
$text = '';
if (!in_array($node->nodeName, $this->inlineTags)) {
$text .= "\n";
}
foreach ($node->childNodes as $childNode) {
$text .= $this->nodeToText($childNode);
}
return $text;
}
}

View File

@@ -30,7 +30,7 @@ class SimpleListOptions
*/
public static function fromRequest(Request $request, string $typeKey, bool $sortDescDefault = false): self
{
$search = $request->get('search', '');
$search = $request->input('search', '');
$sort = setting()->getForCurrentUser($typeKey . '_sort', '');
$order = setting()->getForCurrentUser($typeKey . '_sort_order', $sortDescDefault ? 'desc' : 'asc');

341
composer.lock generated
View File

@@ -62,16 +62,16 @@
},
{
"name": "aws/aws-sdk-php",
"version": "3.373.7",
"version": "3.376.3",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "4402bd10f913e66b7271f44466be8d5ba6c9146e"
"reference": "2081f8db174df4bb8842aed3b7b513590ee9d219"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/4402bd10f913e66b7271f44466be8d5ba6c9146e",
"reference": "4402bd10f913e66b7271f44466be8d5ba6c9146e",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2081f8db174df4bb8842aed3b7b513590ee9d219",
"reference": "2081f8db174df4bb8842aed3b7b513590ee9d219",
"shasum": ""
},
"require": {
@@ -153,22 +153,22 @@
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.373.7"
"source": "https://github.com/aws/aws-sdk-php/tree/3.376.3"
},
"time": "2026-03-20T18:14:19+00:00"
"time": "2026-04-03T18:07:33+00:00"
},
{
"name": "bacon/bacon-qr-code",
"version": "v3.0.4",
"version": "v3.1.1",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "3feed0e212b8412cc5d2612706744789b0615824"
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/3feed0e212b8412cc5d2612706744789b0615824",
"reference": "3feed0e212b8412cc5d2612706744789b0615824",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
"shasum": ""
},
"require": {
@@ -208,9 +208,9 @@
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.4"
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1"
},
"time": "2026-03-16T01:01:30+00:00"
"time": "2026-04-05T21:06:35+00:00"
},
{
"name": "brick/math",
@@ -982,16 +982,16 @@
},
{
"name": "firebase/php-jwt",
"version": "v7.0.3",
"version": "v7.0.5",
"source": {
"type": "git",
"url": "https://github.com/firebase/php-jwt.git",
"reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e"
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e",
"reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e",
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"shasum": ""
},
"require": {
@@ -999,6 +999,7 @@
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpfastcache/phpfastcache": "^9.2",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
@@ -1039,9 +1040,9 @@
],
"support": {
"issues": "https://github.com/firebase/php-jwt/issues",
"source": "https://github.com/firebase/php-jwt/tree/v7.0.3"
"source": "https://github.com/firebase/php-jwt/tree/v7.0.5"
},
"time": "2026-02-25T22:16:40+00:00"
"time": "2026-04-01T20:38:03+00:00"
},
{
"name": "fruitcake/php-cors",
@@ -1801,16 +1802,16 @@
},
{
"name": "laravel/framework",
"version": "v12.55.1",
"version": "v12.56.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "6d9185a248d101b07eecaf8fd60b18129545fd33"
"reference": "dac16d424b59debb2273910dde88eb7050a2a709"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/6d9185a248d101b07eecaf8fd60b18129545fd33",
"reference": "6d9185a248d101b07eecaf8fd60b18129545fd33",
"url": "https://api.github.com/repos/laravel/framework/zipball/dac16d424b59debb2273910dde88eb7050a2a709",
"reference": "dac16d424b59debb2273910dde88eb7050a2a709",
"shasum": ""
},
"require": {
@@ -2019,20 +2020,20 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2026-03-18T14:28:59+00:00"
"time": "2026-03-26T14:51:54+00:00"
},
{
"name": "laravel/prompts",
"version": "v0.3.15",
"version": "v0.3.16",
"source": {
"type": "git",
"url": "https://github.com/laravel/prompts.git",
"reference": "4bb8107ec97651fd3f17f897d6489dbc4d8fb999"
"reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/prompts/zipball/4bb8107ec97651fd3f17f897d6489dbc4d8fb999",
"reference": "4bb8107ec97651fd3f17f897d6489dbc4d8fb999",
"url": "https://api.github.com/repos/laravel/prompts/zipball/11e7d5f93803a2190b00e145142cb00a33d17ad2",
"reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2",
"shasum": ""
},
"require": {
@@ -2076,9 +2077,9 @@
"description": "Add beautiful and user-friendly forms to your command-line applications.",
"support": {
"issues": "https://github.com/laravel/prompts/issues",
"source": "https://github.com/laravel/prompts/tree/v0.3.15"
"source": "https://github.com/laravel/prompts/tree/v0.3.16"
},
"time": "2026-03-17T13:45:17+00:00"
"time": "2026-03-23T14:35:33+00:00"
},
{
"name": "laravel/serializable-closure",
@@ -2143,16 +2144,16 @@
},
{
"name": "laravel/socialite",
"version": "v5.25.0",
"version": "v5.26.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/socialite.git",
"reference": "231f572e1a37c9ca1fb8085e9fb8608285beafb3"
"reference": "db6ec2ee967b7f06412c3a0cf1daaf072f4752a4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/socialite/zipball/231f572e1a37c9ca1fb8085e9fb8608285beafb3",
"reference": "231f572e1a37c9ca1fb8085e9fb8608285beafb3",
"url": "https://api.github.com/repos/laravel/socialite/zipball/db6ec2ee967b7f06412c3a0cf1daaf072f4752a4",
"reference": "db6ec2ee967b7f06412c3a0cf1daaf072f4752a4",
"shasum": ""
},
"require": {
@@ -2211,7 +2212,7 @@
"issues": "https://github.com/laravel/socialite/issues",
"source": "https://github.com/laravel/socialite"
},
"time": "2026-02-27T13:56:35+00:00"
"time": "2026-03-29T14:50:53+00:00"
},
{
"name": "laravel/tinker",
@@ -2470,16 +2471,16 @@
},
{
"name": "league/flysystem",
"version": "3.32.0",
"version": "3.33.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem.git",
"reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725"
"reference": "570b8871e0ce693764434b29154c54b434905350"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725",
"reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725",
"url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350",
"reference": "570b8871e0ce693764434b29154c54b434905350",
"shasum": ""
},
"require": {
@@ -2547,9 +2548,9 @@
],
"support": {
"issues": "https://github.com/thephpleague/flysystem/issues",
"source": "https://github.com/thephpleague/flysystem/tree/3.32.0"
"source": "https://github.com/thephpleague/flysystem/tree/3.33.0"
},
"time": "2026-02-25T17:01:41+00:00"
"time": "2026-03-25T07:59:30+00:00"
},
{
"name": "league/flysystem-aws-s3-v3",
@@ -4664,16 +4665,16 @@
},
{
"name": "psy/psysh",
"version": "v0.12.21",
"version": "v0.12.22",
"source": {
"type": "git",
"url": "https://github.com/bobthecow/psysh.git",
"reference": "4821fab5b7cd8c49a673a9fd5754dc9162bb9e97"
"reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/4821fab5b7cd8c49a673a9fd5754dc9162bb9e97",
"reference": "4821fab5b7cd8c49a673a9fd5754dc9162bb9e97",
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/3be75d5b9244936dd4ac62ade2bfb004d13acf0f",
"reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f",
"shasum": ""
},
"require": {
@@ -4737,9 +4738,9 @@
],
"support": {
"issues": "https://github.com/bobthecow/psysh/issues",
"source": "https://github.com/bobthecow/psysh/tree/v0.12.21"
"source": "https://github.com/bobthecow/psysh/tree/v0.12.22"
},
"time": "2026-03-06T21:21:28+00:00"
"time": "2026-03-22T23:03:24+00:00"
},
{
"name": "ralouphie/getallheaders",
@@ -5431,16 +5432,16 @@
},
{
"name": "symfony/clock",
"version": "v7.4.0",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/clock.git",
"reference": "9169f24776edde469914c1e7a1442a50f7a4e110"
"reference": "674fa3b98e21531dd040e613479f5f6fa8f32111"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110",
"reference": "9169f24776edde469914c1e7a1442a50f7a4e110",
"url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111",
"reference": "674fa3b98e21531dd040e613479f5f6fa8f32111",
"shasum": ""
},
"require": {
@@ -5485,7 +5486,7 @@
"time"
],
"support": {
"source": "https://github.com/symfony/clock/tree/v7.4.0"
"source": "https://github.com/symfony/clock/tree/v7.4.8"
},
"funding": [
{
@@ -5505,20 +5506,20 @@
"type": "tidelift"
}
],
"time": "2025-11-12T15:39:26+00:00"
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/console",
"version": "v7.4.7",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d"
"reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d",
"reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d",
"url": "https://api.github.com/repos/symfony/console/zipball/1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707",
"reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707",
"shasum": ""
},
"require": {
@@ -5583,7 +5584,7 @@
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v7.4.7"
"source": "https://github.com/symfony/console/tree/v7.4.8"
},
"funding": [
{
@@ -5603,20 +5604,20 @@
"type": "tidelift"
}
],
"time": "2026-03-06T14:06:20+00:00"
"time": "2026-03-30T13:54:39+00:00"
},
{
"name": "symfony/css-selector",
"version": "v7.4.6",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/css-selector.git",
"reference": "2e7c52c647b406e2107dd867db424a4dbac91864"
"reference": "b055f228a4178a1d6774909903905e3475f3eac8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/css-selector/zipball/2e7c52c647b406e2107dd867db424a4dbac91864",
"reference": "2e7c52c647b406e2107dd867db424a4dbac91864",
"url": "https://api.github.com/repos/symfony/css-selector/zipball/b055f228a4178a1d6774909903905e3475f3eac8",
"reference": "b055f228a4178a1d6774909903905e3475f3eac8",
"shasum": ""
},
"require": {
@@ -5652,7 +5653,7 @@
"description": "Converts CSS selectors to XPath expressions",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/css-selector/tree/v7.4.6"
"source": "https://github.com/symfony/css-selector/tree/v7.4.8"
},
"funding": [
{
@@ -5672,7 +5673,7 @@
"type": "tidelift"
}
],
"time": "2026-02-17T07:53:42+00:00"
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/deprecation-contracts",
@@ -5743,16 +5744,16 @@
},
{
"name": "symfony/error-handler",
"version": "v7.4.4",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/error-handler.git",
"reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8"
"reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8",
"reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa",
"reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa",
"shasum": ""
},
"require": {
@@ -5801,7 +5802,7 @@
"description": "Provides tools to manage errors and ease debugging PHP code",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/error-handler/tree/v7.4.4"
"source": "https://github.com/symfony/error-handler/tree/v7.4.8"
},
"funding": [
{
@@ -5821,20 +5822,20 @@
"type": "tidelift"
}
],
"time": "2026-01-20T16:42:42+00:00"
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/event-dispatcher",
"version": "v7.4.4",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
"reference": "dc2c0eba1af673e736bb851d747d266108aea746"
"reference": "f57b899fa736fd71121168ef268f23c206083f0a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/dc2c0eba1af673e736bb851d747d266108aea746",
"reference": "dc2c0eba1af673e736bb851d747d266108aea746",
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f57b899fa736fd71121168ef268f23c206083f0a",
"reference": "f57b899fa736fd71121168ef268f23c206083f0a",
"shasum": ""
},
"require": {
@@ -5886,7 +5887,7 @@
"description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/event-dispatcher/tree/v7.4.4"
"source": "https://github.com/symfony/event-dispatcher/tree/v7.4.8"
},
"funding": [
{
@@ -5906,7 +5907,7 @@
"type": "tidelift"
}
],
"time": "2026-01-05T11:45:34+00:00"
"time": "2026-03-30T13:54:39+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
@@ -5986,16 +5987,16 @@
},
{
"name": "symfony/filesystem",
"version": "v7.4.6",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
"reference": "3ebc794fa5315e59fd122561623c2e2e4280538e"
"reference": "58b9790d12f9670b7f53a1c1738febd3108970a5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e",
"reference": "3ebc794fa5315e59fd122561623c2e2e4280538e",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/58b9790d12f9670b7f53a1c1738febd3108970a5",
"reference": "58b9790d12f9670b7f53a1c1738febd3108970a5",
"shasum": ""
},
"require": {
@@ -6032,7 +6033,7 @@
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/filesystem/tree/v7.4.6"
"source": "https://github.com/symfony/filesystem/tree/v7.4.8"
},
"funding": [
{
@@ -6052,20 +6053,20 @@
"type": "tidelift"
}
],
"time": "2026-02-25T16:50:00+00:00"
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/finder",
"version": "v7.4.6",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
"reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf"
"reference": "e0be088d22278583a82da281886e8c3592fbf149"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf",
"reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf",
"url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149",
"reference": "e0be088d22278583a82da281886e8c3592fbf149",
"shasum": ""
},
"require": {
@@ -6100,7 +6101,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/finder/tree/v7.4.6"
"source": "https://github.com/symfony/finder/tree/v7.4.8"
},
"funding": [
{
@@ -6120,20 +6121,20 @@
"type": "tidelift"
}
],
"time": "2026-01-29T09:40:50+00:00"
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/http-foundation",
"version": "v7.4.7",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
"reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81"
"reference": "9381209597ec66c25be154cbf2289076e64d1eab"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81",
"reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab",
"reference": "9381209597ec66c25be154cbf2289076e64d1eab",
"shasum": ""
},
"require": {
@@ -6182,7 +6183,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-foundation/tree/v7.4.7"
"source": "https://github.com/symfony/http-foundation/tree/v7.4.8"
},
"funding": [
{
@@ -6202,20 +6203,20 @@
"type": "tidelift"
}
],
"time": "2026-03-06T13:15:18+00:00"
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/http-kernel",
"version": "v7.4.7",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
"reference": "3b3fcf386c809be990c922e10e4c620d6367cab1"
"reference": "017e76ad089bac281553389269e259e155935e1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1",
"reference": "3b3fcf386c809be990c922e10e4c620d6367cab1",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/017e76ad089bac281553389269e259e155935e1a",
"reference": "017e76ad089bac281553389269e259e155935e1a",
"shasum": ""
},
"require": {
@@ -6301,7 +6302,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-kernel/tree/v7.4.7"
"source": "https://github.com/symfony/http-kernel/tree/v7.4.8"
},
"funding": [
{
@@ -6321,20 +6322,20 @@
"type": "tidelift"
}
],
"time": "2026-03-06T16:33:18+00:00"
"time": "2026-03-31T20:57:01+00:00"
},
{
"name": "symfony/mailer",
"version": "v7.4.6",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/mailer.git",
"reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9"
"reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9",
"reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9",
"url": "https://api.github.com/repos/symfony/mailer/zipball/f6ea532250b476bfc1b56699b388a1bdbf168f62",
"reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62",
"shasum": ""
},
"require": {
@@ -6385,7 +6386,7 @@
"description": "Helps sending emails",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/mailer/tree/v7.4.6"
"source": "https://github.com/symfony/mailer/tree/v7.4.8"
},
"funding": [
{
@@ -6405,20 +6406,20 @@
"type": "tidelift"
}
],
"time": "2026-02-25T16:50:00+00:00"
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/mime",
"version": "v7.4.7",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1"
"reference": "6df02f99998081032da3407a8d6c4e1dcb5d4379"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1",
"reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1",
"url": "https://api.github.com/repos/symfony/mime/zipball/6df02f99998081032da3407a8d6c4e1dcb5d4379",
"reference": "6df02f99998081032da3407a8d6c4e1dcb5d4379",
"shasum": ""
},
"require": {
@@ -6474,7 +6475,7 @@
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v7.4.7"
"source": "https://github.com/symfony/mime/tree/v7.4.8"
},
"funding": [
{
@@ -6494,7 +6495,7 @@
"type": "tidelift"
}
],
"time": "2026-03-05T15:24:09+00:00"
"time": "2026-03-30T14:11:46+00:00"
},
{
"name": "symfony/polyfill-ctype",
@@ -7327,16 +7328,16 @@
},
{
"name": "symfony/process",
"version": "v7.4.5",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
"reference": "608476f4604102976d687c483ac63a79ba18cc97"
"reference": "60f19cd3badc8de688421e21e4305eba50f8089a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97",
"reference": "608476f4604102976d687c483ac63a79ba18cc97",
"url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a",
"reference": "60f19cd3badc8de688421e21e4305eba50f8089a",
"shasum": ""
},
"require": {
@@ -7368,7 +7369,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/process/tree/v7.4.5"
"source": "https://github.com/symfony/process/tree/v7.4.8"
},
"funding": [
{
@@ -7388,20 +7389,20 @@
"type": "tidelift"
}
],
"time": "2026-01-26T15:07:59+00:00"
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/routing",
"version": "v7.4.6",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/routing.git",
"reference": "238d749c56b804b31a9bf3e26519d93b65a60938"
"reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938",
"reference": "238d749c56b804b31a9bf3e26519d93b65a60938",
"url": "https://api.github.com/repos/symfony/routing/zipball/9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b",
"reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b",
"shasum": ""
},
"require": {
@@ -7453,7 +7454,7 @@
"url"
],
"support": {
"source": "https://github.com/symfony/routing/tree/v7.4.6"
"source": "https://github.com/symfony/routing/tree/v7.4.8"
},
"funding": [
{
@@ -7473,7 +7474,7 @@
"type": "tidelift"
}
],
"time": "2026-02-25T16:50:00+00:00"
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/service-contracts",
@@ -7564,16 +7565,16 @@
},
{
"name": "symfony/string",
"version": "v7.4.6",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
"reference": "9f209231affa85aa930a5e46e6eb03381424b30b"
"reference": "114ac57257d75df748eda23dd003878080b8e688"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/string/zipball/9f209231affa85aa930a5e46e6eb03381424b30b",
"reference": "9f209231affa85aa930a5e46e6eb03381424b30b",
"url": "https://api.github.com/repos/symfony/string/zipball/114ac57257d75df748eda23dd003878080b8e688",
"reference": "114ac57257d75df748eda23dd003878080b8e688",
"shasum": ""
},
"require": {
@@ -7631,7 +7632,7 @@
"utf8"
],
"support": {
"source": "https://github.com/symfony/string/tree/v7.4.6"
"source": "https://github.com/symfony/string/tree/v7.4.8"
},
"funding": [
{
@@ -7651,20 +7652,20 @@
"type": "tidelift"
}
],
"time": "2026-02-09T09:33:46+00:00"
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/translation",
"version": "v7.4.6",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation.git",
"reference": "1888cf064399868af3784b9e043240f1d89d25ce"
"reference": "33600f8489485425bfcddd0d983391038d3422e7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/translation/zipball/1888cf064399868af3784b9e043240f1d89d25ce",
"reference": "1888cf064399868af3784b9e043240f1d89d25ce",
"url": "https://api.github.com/repos/symfony/translation/zipball/33600f8489485425bfcddd0d983391038d3422e7",
"reference": "33600f8489485425bfcddd0d983391038d3422e7",
"shasum": ""
},
"require": {
@@ -7731,7 +7732,7 @@
"description": "Provides tools to internationalize your application",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/translation/tree/v7.4.6"
"source": "https://github.com/symfony/translation/tree/v7.4.8"
},
"funding": [
{
@@ -7751,7 +7752,7 @@
"type": "tidelift"
}
],
"time": "2026-02-17T07:53:42+00:00"
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/translation-contracts",
@@ -7837,16 +7838,16 @@
},
{
"name": "symfony/uid",
"version": "v7.4.4",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/uid.git",
"reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36"
"reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36",
"reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36",
"url": "https://api.github.com/repos/symfony/uid/zipball/6883ebdf7bf6a12b37519dbc0df62b0222401b56",
"reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56",
"shasum": ""
},
"require": {
@@ -7891,7 +7892,7 @@
"uuid"
],
"support": {
"source": "https://github.com/symfony/uid/tree/v7.4.4"
"source": "https://github.com/symfony/uid/tree/v7.4.8"
},
"funding": [
{
@@ -7911,20 +7912,20 @@
"type": "tidelift"
}
],
"time": "2026-01-03T23:30:35+00:00"
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/var-dumper",
"version": "v7.4.6",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-dumper.git",
"reference": "045321c440ac18347b136c63d2e9bf28a2dc0291"
"reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291",
"reference": "045321c440ac18347b136c63d2e9bf28a2dc0291",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd",
"reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd",
"shasum": ""
},
"require": {
@@ -7978,7 +7979,7 @@
"dump"
],
"support": {
"source": "https://github.com/symfony/var-dumper/tree/v7.4.6"
"source": "https://github.com/symfony/var-dumper/tree/v7.4.8"
},
"funding": [
{
@@ -7998,7 +7999,7 @@
"type": "tidelift"
}
],
"time": "2026-02-15T10:53:20+00:00"
"time": "2026-03-30T13:44:50+00:00"
},
{
"name": "thecodingmachine/safe",
@@ -8955,23 +8956,23 @@
},
{
"name": "nunomaduro/collision",
"version": "v8.9.1",
"version": "v8.9.2",
"source": {
"type": "git",
"url": "https://github.com/nunomaduro/collision.git",
"reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935"
"reference": "6eb16883e74fd725ac64dbe81544c961ab448ba5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935",
"reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935",
"url": "https://api.github.com/repos/nunomaduro/collision/zipball/6eb16883e74fd725ac64dbe81544c961ab448ba5",
"reference": "6eb16883e74fd725ac64dbe81544c961ab448ba5",
"shasum": ""
},
"require": {
"filp/whoops": "^2.18.4",
"nunomaduro/termwind": "^2.4.0",
"php": "^8.2.0",
"symfony/console": "^7.4.4 || ^8.0.4"
"symfony/console": "^7.4.8 || ^8.0.4"
},
"conflict": {
"laravel/framework": "<11.48.0 || >=14.0.0",
@@ -8979,12 +8980,12 @@
},
"require-dev": {
"brianium/paratest": "^7.8.5",
"larastan/larastan": "^3.9.2",
"laravel/framework": "^11.48.0 || ^12.52.0",
"laravel/pint": "^1.27.1",
"orchestra/testbench-core": "^9.12.0 || ^10.9.0",
"pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0",
"sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0"
"larastan/larastan": "^3.9.3",
"laravel/framework": "^11.48.0 || ^12.56.0 || ^13.2.0",
"laravel/pint": "^1.29.0",
"orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.0.0",
"pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0",
"sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.0.0"
},
"type": "library",
"extra": {
@@ -9047,7 +9048,7 @@
"type": "patreon"
}
],
"time": "2026-02-17T17:33:08+00:00"
"time": "2026-03-31T21:51:27+00:00"
},
{
"name": "phar-io/manifest",
@@ -9169,11 +9170,11 @@
},
{
"name": "phpstan/phpstan",
"version": "2.1.42",
"version": "2.1.46",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/1279e1ce86ba768f0780c9d889852b4e02ff40d0",
"reference": "1279e1ce86ba768f0780c9d889852b4e02ff40d0",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25",
"reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25",
"shasum": ""
},
"require": {
@@ -9218,7 +9219,7 @@
"type": "github"
}
],
"time": "2026-03-17T14:58:32+00:00"
"time": "2026-04-01T09:25:14+00:00"
},
{
"name": "phpunit/php-code-coverage",
@@ -10841,16 +10842,16 @@
},
{
"name": "symfony/dom-crawler",
"version": "v7.4.6",
"version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/dom-crawler.git",
"reference": "487ba8fa43da9a8e6503fe939b45ecd96875410e"
"reference": "2918e7c2ba964defca1f5b69c6f74886529e2dc8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/487ba8fa43da9a8e6503fe939b45ecd96875410e",
"reference": "487ba8fa43da9a8e6503fe939b45ecd96875410e",
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/2918e7c2ba964defca1f5b69c6f74886529e2dc8",
"reference": "2918e7c2ba964defca1f5b69c6f74886529e2dc8",
"shasum": ""
},
"require": {
@@ -10889,7 +10890,7 @@
"description": "Eases DOM navigation for HTML and XML documents",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/dom-crawler/tree/v7.4.6"
"source": "https://github.com/symfony/dom-crawler/tree/v7.4.8"
},
"funding": [
{
@@ -10909,7 +10910,7 @@
"type": "tidelift"
}
],
"time": "2026-02-17T07:53:42+00:00"
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "theseer/tokenizer",

View File

@@ -0,0 +1,67 @@
<?php
use Carbon\Carbon;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Create new revision-view-all permission
$permissionId = DB::table('role_permissions')->insertGetId([
'name' => 'revision-view-all',
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
// Get ids of page view permissions
$pageViewPermissions = DB::table('role_permissions')
->whereIn('name', [
'page-view-own',
'page-view-all',
])->get();
if ($pageViewPermissions->count() === 0) {
return;
}
// Get role ids which have page view permission
$applicableRoleIds = DB::table('permission_role')
->whereIn('permission_id', $pageViewPermissions->pluck('id'))
->pluck('role_id')
->unique()
->all();
// Assign the new permission to relevant roles
$newPermissionRoles = array_values(array_map(function (int $roleId) use ($permissionId) {
return [
'role_id' => $roleId,
'permission_id' => $permissionId,
];
}, $applicableRoleIds));
DB::table('permission_role')->insert($newPermissionRoles);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Get the permission to remove
$revisionViewPermission = DB::table('role_permissions')
->where('name', '=', 'revision-view-all')
->first();
if (!$revisionViewPermission) {
return;
}
// Remove the permission, and its use on roles, from the database
DB::table('permission_role')->where('permission_id', '=', $revisionViewPermission->id)->delete();
DB::table('role_permissions')->where('id', '=', $revisionViewPermission->id)->delete();
}
};

View File

@@ -0,0 +1 @@
GET /api/tags/values-for-name?name=Category

View File

@@ -0,0 +1,32 @@
{
"data": [
{
"name": "Category",
"values": 8,
"usages": 184,
"page_count": 3,
"chapter_count": 8,
"book_count": 171,
"shelf_count": 2
},
{
"name": "Review Due",
"values": 2,
"usages": 2,
"page_count": 1,
"chapter_count": 0,
"book_count": 1,
"shelf_count": 0
},
{
"name": "Type",
"values": 2,
"usages": 2,
"page_count": 0,
"chapter_count": 1,
"book_count": 1,
"shelf_count": 0
}
],
"total": 3
}

View File

@@ -0,0 +1,32 @@
{
"data": [
{
"name": "Category",
"value": "Cool Stuff",
"usages": 3,
"page_count": 1,
"chapter_count": 0,
"book_count": 2,
"shelf_count": 0
},
{
"name": "Category",
"value": "Top Content",
"usages": 168,
"page_count": 0,
"chapter_count": 3,
"book_count": 165,
"shelf_count": 0
},
{
"name": "Category",
"value": "Learning",
"usages": 2,
"page_count": 0,
"chapter_count": 0,
"book_count": 0,
"shelf_count": 2
}
],
"total": 3
}

View File

@@ -66,6 +66,7 @@ Here are some general best practices when it comes to creating modules:
### Distribution Format
Modules are expected to be distributed as a compressed ZIP file, where the ZIP contents follow that of a module folder.
Contents may optionally be placed within a nested folder inside the ZIP.
BookStack provides a `php artisan bookstack:install-module` command which allows modules to be installed from these ZIP files, either from a local path or from a web URL.
Currently, there's a hardcoded total filesize limit of 50MB for module contents installed via this method.

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