Compare commits

..

4 Commits

Author SHA1 Message Date
JonatanRek
25c5170da2 Fix Tests race conditions 2026-04-07 19:29:13 +02:00
JonatanRek
08481b0372 Add Tests 2026-04-07 18:45:47 +02:00
JonatanRek
b76e632f08 Fix 2026-04-07 18:42:39 +02:00
JonatanRek
9589492081 Implement Imper. 2026-04-07 18:40:13 +02:00
149 changed files with 1362 additions and 2025 deletions

View File

@@ -1,4 +0,0 @@
# These are supported funding model platforms
github: [ssddanbrown]
ko_fi: ssddanbrown

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
name: analyse-php
on:
workflow_dispatch:
push:
paths:
- '**.php'
@@ -12,16 +11,14 @@ on:
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: docker
container:
image: node:24-bullseye
runs-on: ubuntu-24.04
steps:
- uses: https://code.forgejo.org/actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup PHP
uses: https://github.com/shivammathur/setup-php@v2
uses: shivammathur/setup-php@v2
with:
php-version: 8.5
php-version: 8.3
extensions: gd, mbstring, json, curl, xml, mysql, ldap
- name: Get Composer Cache Directory
@@ -30,16 +27,14 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: https://code.forgejo.org/actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-8.5
key: ${{ runner.os }}-composer-8.3
restore-keys: ${{ runner.os }}-composer-
- name: Install composer dependencies
run: composer install --prefer-dist --no-interaction --ansi
env:
COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GH_TOKEN }}"}}'
- name: Run static analysis check
run: composer check-static

View File

@@ -1,7 +1,6 @@
name: lint-js
on:
workflow_dispatch:
push:
paths:
- '**.js'
@@ -14,11 +13,9 @@ on:
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: docker
container:
image: node:24-bullseye
runs-on: ubuntu-24.04
steps:
- uses: https://code.forgejo.org/actions/checkout@v6
- uses: actions/checkout@v4
- name: Install NPM deps
run: npm ci

View File

@@ -1,7 +1,6 @@
name: lint-php
on:
workflow_dispatch:
push:
paths:
- '**.php'
@@ -12,16 +11,14 @@ on:
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: docker
container:
image: node:24-bullseye
runs-on: ubuntu-24.04
steps:
- uses: https://code.forgejo.org/actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup PHP
uses: https://github.com/shivammathur/setup-php@v2
uses: shivammathur/setup-php@v2
with:
php-version: 8.5
php-version: 8.3
tools: phpcs
- name: Run formatting check

View File

@@ -1,7 +1,6 @@
name: test-js
on:
workflow_dispatch:
push:
paths:
- '**.js'
@@ -16,11 +15,9 @@ on:
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: docker
container:
image: node:24-bullseye
runs-on: ubuntu-24.04
steps:
- uses: https://code.forgejo.org/actions/checkout@v6
- uses: actions/checkout@v4
- name: Install NPM deps
run: npm ci

View File

@@ -1,7 +1,6 @@
name: test-migrations
on:
workflow_dispatch:
push:
paths:
- '**.php'
@@ -14,25 +13,15 @@ on:
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: docker
container:
image: node:24-bullseye
runs-on: ubuntu-24.04
strategy:
matrix:
php: ['8.2', '8.3', '8.4', '8.5']
services:
mysql:
image: docker.io/library/mariadb:12.2.2-noble
env:
MARIADB_USER: bookstack-test
MARIADB_PASSWORD: bookstack-test
MARIADB_DATABASE: bookstack-test
MARIADB_ROOT_PASSWORD: password
steps:
- uses: https://code.forgejo.org/actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup PHP
uses: https://github.com/shivammathur/setup-php@v2
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap
@@ -43,31 +32,34 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: https://code.forgejo.org/actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
restore-keys: ${{ runner.os }}-composer-
- name: Start MySQL
run: |
sudo systemctl start mysql
- name: Create database & user
run: |
mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;'
mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';"
mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
- name: Install composer dependencies
run: composer install --prefer-dist --no-interaction --ansi
env:
COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GH_TOKEN }}"}}'
- name: Start migration test
env:
DB_HOST: mysql
run: |
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
- name: Start migration:rollback test
env:
DB_HOST: mysql
run: |
php${{ matrix.php }} artisan migrate:rollback --force -n --database=mysql_testing
- name: Start migration rerun test
env:
DB_HOST: mysql
run: |
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing

View File

@@ -1,7 +1,6 @@
name: test-php
on:
workflow_dispatch:
push:
paths:
- '**.php'
@@ -14,25 +13,15 @@ on:
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: docker
container:
image: node:24-bullseye
runs-on: ubuntu-24.04
strategy:
matrix:
php: ['8.2', '8.3', '8.4', '8.5']
services:
mysql:
image: docker.io/library/mariadb:12.2.2-noble
env:
MARIADB_USER: bookstack-test
MARIADB_PASSWORD: bookstack-test
MARIADB_DATABASE: bookstack-test
MARIADB_ROOT_PASSWORD: password
steps:
- uses: https://code.forgejo.org/actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup PHP
uses: https://github.com/shivammathur/setup-php@v2
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp
@@ -43,25 +32,30 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: https://code.forgejo.org/actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
restore-keys: ${{ runner.os }}-composer-
- name: Start Database
run: |
sudo systemctl start mysql
- name: Setup Database
run: |
mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;'
mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED WITH mysql_native_password BY 'bookstack-test';"
mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
- name: Install composer dependencies
run: composer install --prefer-dist --no-interaction --ansi
env:
COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.GH_TOKEN }}"}}'
- name: Migrate and seed the database
env:
DB_HOST: mysql
run: |
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
- name: Run PHP tests
env:
DB_HOST: mysql
run: php${{ matrix.php }} ./vendor/bin/phpunit

1
.gitignore vendored
View File

@@ -2,7 +2,6 @@
/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->input('email'));
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->get('email'));
}
if (in_array($response, [Password::RESET_LINK_SENT, Password::INVALID_USER, Password::RESET_THROTTLED])) {
$message = trans('auth.reset_password_sent', ['email' => $request->input('email')]);
$message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
$this->showSuccessNotification($message);
return redirect('/password/email')->with('status', trans($response));

View File

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

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ class ResetPasswordController extends Controller
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise, we will parse the error and return the response.
// database. Otherwise we will parse the error and return the response.
$credentials = $request->only('email', 'password', 'password_confirmation', 'token');
$response = Password::broker()->reset($credentials, function (User $user, string $password) {
$user->password = Hash::make($password);
@@ -63,7 +63,7 @@ class ResetPasswordController extends Controller
// redirect them back to where they came from with their error message.
return $response === Password::PASSWORD_RESET
? $this->sendResetResponse()
: $this->sendResetFailedResponse($request, $response, $request->input('token'));
: $this->sendResetFailedResponse($request, $response, $request->get('token'));
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -102,12 +102,12 @@ class OidcJwtWithClaims implements ProvidesClaims
protected function validateTokenStructure(): void
{
foreach (['header', 'payload'] as $prop) {
if (empty($this->$prop)) {
if (empty($this->$prop) || !is_array($this->$prop)) {
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
}
}
if (empty($this->signature)) {
if (empty($this->signature) || !is_string($this->signature)) {
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
}
}

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|null, saml_id: string}
* @return array{external_id: string, name: string, email: string, saml_id: string}
*/
protected function getUserDetails(string $samlID, $samlAttributes): array
{
@@ -357,7 +357,7 @@ class Saml2Service
]);
}
if (empty($userDetails['email'])) {
if ($userDetails['email'] === null) {
throw new SamlException(trans('errors.saml_no_email_address'));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -68,7 +68,7 @@ return [
* Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
* Symbol, ZapfDingbats.
*/
'font_dir' => storage_path('fonts/dompdf'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
'font_dir' => storage_path('fonts/'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
/**
* The location of the DOMPDF font cache directory.
@@ -78,7 +78,7 @@ return [
*
* Note: This directory must exist and be writable by the webserver process.
*/
'font_cache' => storage_path('fonts/dompdf/cache'),
'font_cache' => storage_path('fonts/'),
/**
* The location of a temporary directory.

View File

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

View File

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

View File

@@ -213,23 +213,15 @@ class InstallModuleCommand extends Command
$redirectLocation = $resp->getHeaderLine('Location');
if ($redirectLocation) {
$redirectUrl = parse_url($redirectLocation);
$redirectOriginMatches = ($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '')
if (
($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '')
&& ($originalUrl['scheme'] ?? '') === ($redirectUrl['scheme'] ?? '')
&& ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? '');
if (!$redirectOriginMatches) {
$redirectOrigin = ($redirectUrl['scheme'] ?? '') . '://' . ($redirectUrl['host'] ?? '') . (isset($redirectUrl['port']) ? ':' . $redirectUrl['port'] : '');
$this->info("The download URL is redirecting to a different site: {$redirectOrigin}");
$shouldContinue = $this->confirm("Do you trust downloading the module from this site?");
if (!$shouldContinue) {
$this->error("Stopping module installation");
return null;
}
&& ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? '')
) {
$currentLocation = $redirectLocation;
$redirectCount++;
continue;
}
$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->input('shelf')));
$this->shelfContext->setShelfContext(intval($request->get('shelf')));
}
$this->setPageTitle($book->getShortName());
@@ -263,7 +263,7 @@ class BookController extends Controller
$this->checkOwnablePermission(Permission::BookView, $book);
$this->checkPermission(Permission::BookCreateAll);
$newName = $request->input('name') ?: $book->name;
$newName = $request->get('name') ?: $book->name;
$bookCopy = $cloner->cloneBook($book, $newName);
$this->showSuccessNotification(trans('entities.books_copy_success'));

View File

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

View File

@@ -94,7 +94,7 @@ class BookshelfController extends Controller
'tags' => ['array'],
]);
$bookIds = explode(',', $request->input('books', ''));
$bookIds = explode(',', $request->get('books', ''));
$shelf = $this->shelfRepo->create($validated, $bookIds);
return redirect($shelf->getUrl());
@@ -196,7 +196,7 @@ class BookshelfController extends Controller
unset($validated['image']);
}
$bookIds = explode(',', $request->input('books', ''));
$bookIds = explode(',', $request->get('books', ''));
$shelf = $this->shelfRepo->update($shelf, $validated, $bookIds);
return redirect($shelf->getUrl());

View File

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

View File

@@ -203,7 +203,7 @@ class ChapterController extends Controller
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
$entitySelection = $request->input('entity_selection', null);
$entitySelection = $request->get('entity_selection', null);
if ($entitySelection === null || $entitySelection === '') {
return redirect($chapter->getUrl());
}
@@ -248,7 +248,7 @@ class ChapterController extends Controller
{
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$entitySelection = $request->input('entity_selection') ?: null;
$entitySelection = $request->get('entity_selection') ?: null;
$newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent();
if (!$newParentBook instanceof Book) {
@@ -259,7 +259,7 @@ class ChapterController extends Controller
$this->checkOwnablePermission(Permission::ChapterCreate, $newParentBook);
$newName = $request->input('name') ?: $chapter->name;
$newName = $request->get('name') ?: $chapter->name;
$chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName);
$this->showSuccessNotification(trans('entities.chapters_copy_success'));

View File

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

View File

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

View File

@@ -34,7 +34,6 @@ class PageRevisionController extends Controller
*/
public function index(Request $request, string $bookSlug, string $pageSlug)
{
$this->checkPermission(Permission::RevisionViewAll);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([
'id' => trans('entities.pages_revisions_sort_number')
@@ -66,8 +65,6 @@ class PageRevisionController extends Controller
*/
public function show(string $bookSlug, string $pageSlug, int $revisionId)
{
$this->checkPermission(Permission::RevisionViewAll);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
/** @var ?PageRevision $revision */
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
@@ -97,8 +94,6 @@ class PageRevisionController extends Controller
*/
public function changes(string $bookSlug, string $pageSlug, int $revisionId)
{
$this->checkPermission(Permission::RevisionViewAll);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
/** @var ?PageRevision $revision */
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
@@ -134,7 +129,6 @@ class PageRevisionController extends Controller
*/
public function restore(string $bookSlug, string $pageSlug, int $revisionId)
{
$this->checkPermission(Permission::RevisionViewAll);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission(Permission::PageUpdate, $page);
@@ -150,7 +144,6 @@ class PageRevisionController extends Controller
*/
public function destroy(string $bookSlug, string $pageSlug, int $revId)
{
$this->checkPermission(Permission::RevisionViewAll);
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
$this->checkOwnablePermission(Permission::PageDelete, $page);

View File

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

View File

@@ -17,7 +17,7 @@ use Illuminate\Support\Collection;
*
* @property string $description
* @property string $description_html
* @property ?int $image_id
* @property int $image_id
* @property ?int $default_template_id
* @property ?int $sort_rule_id
* @property \Illuminate\Database\Eloquent\Collection $chapters

View File

@@ -479,7 +479,6 @@ abstract class Entity extends Model implements
'chapter' => new Chapter(),
'book' => new Book(),
'bookshelf' => new Bookshelf(),
default => throw new \InvalidArgumentException("Invalid entity type: {$type}"),
};
}
}

View File

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

View File

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

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,7 +16,6 @@ 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;
@@ -304,8 +303,8 @@ class PageContent
public function toPlainText(): string
{
$html = $this->render(true);
$converter = new HtmlToPlainText();
return $converter->convert($html);
return html_entity_decode(strip_tags($html));
}
/**
@@ -360,7 +359,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->input('permissions', null);
$ownerId = $request->input('owned_by', null);
$permissions = $request->get('permissions', null);
$ownerId = $request->get('owned_by', null);
$entity->permissions()->delete();
@@ -47,7 +47,7 @@ class PermissionsUpdater
{
if (isset($data['role_permissions'])) {
$entity->permissions()->where('role_id', '!=', 0)->delete();
$rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions($data['role_permissions'], false);
$rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions($data['role_permissions'] ?? [], false);
$entity->permissions()->createMany($rolePermissionData);
}

View File

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

View File

@@ -4,8 +4,6 @@ namespace BookStack\Exports;
use BookStack\Exceptions\PdfExportException;
use Dompdf\Dompdf;
use FontLib\Font;
use Illuminate\Support\Str;
use Knp\Snappy\Pdf as SnappyPdf;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;
@@ -62,65 +60,12 @@ class PdfGenerator
$domPdf = new Dompdf($options);
$domPdf->setBasePath(base_path('public'));
$fontMetrics = $domPdf->getFontMetrics();
$userFontfamilies = $this->getUserDomPdfFontFamilies();
foreach ($userFontfamilies as $fontFamily => $fonts) {
try {
$fontMetrics->setFontFamily($fontFamily, $fonts);
} catch (\Exception $exception) {
$expectedPath = storage_path('fonts/dompdf');
throw new PdfExportException("Failed to create required font data in {$expectedPath}, Ensure all content in this location is writable by the web server");
}
}
$domPdf->loadHTML($this->convertEntities($html));
$domPdf->render();
return (string) $domPdf->output();
}
/**
* @return array<string, array<string, string>>
*/
protected function getUserDomPdfFontFamilies(): array
{
$fontStore = storage_path('fonts/dompdf');
if (!is_dir($fontStore)) {
return [];
}
$fontFamilies = [];
$fontFiles = glob($fontStore . DIRECTORY_SEPARATOR . '*.ttf');
foreach ($fontFiles as $fontFile) {
$fontFileName = basename($fontFile, '.ttf');
$expectedUfm = $fontStore . DIRECTORY_SEPARATOR . $fontFileName . '.ufm';
if (!file_exists($expectedUfm)) {
$font = Font::load($fontFile);
$font->parse();
try {
$font->saveAdobeFontMetrics($expectedUfm);
} catch (\Exception $exception) {
throw new PdfExportException("Failed to create required font data at $expectedUfm, Ensure this location is writable by the web server");
}
}
$nameParts = explode('-', $fontFileName);
if (count($nameParts) === 1 || $nameParts[1] === 'Regular') {
$nameParts[1] = 'Normal';
}
$family = trim(strtolower(preg_replace('/([A-Z])/', ' $1', $nameParts[0])));
$variation = Str::snake($nameParts[1]);
if (!isset($fontFamilies[$family])) {
$fontFamilies[$family] = [];
}
$fontFamilies[$family][$variation] = $fontStore . DIRECTORY_SEPARATOR . $fontFileName;
}
return $fontFamilies;
}
/**
* @throws PdfExportException
*/

View File

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

View File

@@ -82,8 +82,10 @@ class ZipImportRunner
$entity = $this->importBook($exportModel, $reader);
} else if ($exportModel instanceof ZipExportChapter) {
$entity = $this->importChapter($exportModel, $parent, $reader);
} else {
} else if ($exportModel instanceof ZipExportPage) {
$entity = $this->importPage($exportModel, $parent, $reader);
} else {
throw new ZipImportException(['No importable data found in import data.']);
}
$this->references->replaceReferences();
@@ -130,7 +132,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()) {
@@ -149,7 +151,7 @@ class ZipImportRunner
foreach ($children as $child) {
if ($child instanceof ZipExportChapter) {
$this->importChapter($child, $book, $reader);
} else {
} else if ($child instanceof ZipExportPage) {
$this->importPage($child, $book, $reader);
}
}
@@ -164,7 +166,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;
@@ -197,7 +199,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);
@@ -300,7 +302,7 @@ class ZipImportRunner
array_push($chapters, ...$exportModel->chapters);
} else if ($exportModel instanceof ZipExportChapter) {
$chapters[] = $exportModel;
} else {
} else if ($exportModel instanceof ZipExportPage) {
$pages[] = $exportModel;
}

View File

@@ -68,6 +68,10 @@ 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,14 +20,10 @@ 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 = [], array $filterableFields = []): JsonResponse
protected function apiListingResponse(Builder $query, array $fields, array $modifiers = []): 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()->can($permission)) {
if (!user() || !user()->can($permission)) {
$this->showPermissionError();
}
}

View File

@@ -33,6 +33,7 @@ class Kernel extends HttpKernel
\BookStack\Http\Middleware\StartSessionExtended::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\BookStack\Http\Middleware\VerifyCsrfToken::class,
\BookStack\Http\Middleware\Impersonate::class,
\BookStack\Http\Middleware\CheckEmailConfirmed::class,
\BookStack\Http\Middleware\RunThemeActions::class,
\BookStack\Http\Middleware\Localization::class,

View File

@@ -0,0 +1,32 @@
<?php
namespace BookStack\Http\Middleware;
use BookStack\Permissions\Permission;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class Impersonate
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$impersonateId = session('impersonate', null);
if (empty($impersonateId)) {
return $next($request);
}
$realUser = auth()->user();
if ($realUser && $realUser->can(Permission::UsersManage)) {
Auth::onceUsingId($impersonateId);
}
return $next($request);
}
}

View File

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

View File

@@ -118,8 +118,6 @@ 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->input('query') ?? '');
$page = intval($request->input('page', '0')) ?: 1;
$count = min(intval($request->input('count', '0')) ?: 20, 100);
$options = SearchOptions::fromString($request->get('query') ?? '');
$page = intval($request->get('page', '0')) ?: 1;
$count = min(intval($request->get('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->input('page', '0')) ?: 1;
$page = intval($request->get('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->input('term', '');
$term = $request->get('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->input('term', '');
$term = $request->get('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->input('types')) : ['page', 'chapter', 'book'];
$searchTerm = $request->input('term', false);
$permission = $request->input('permission', 'view');
$entityTypes = $request->filled('types') ? explode(',', $request->get('types')) : ['page', 'chapter', 'book'];
$searchTerm = $request->get('term', false);
$permission = $request->get('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->input('term', false);
$searchTerm = $request->get('term', false);
if ($searchTerm !== false) {
$searchOptions = SearchOptions::fromString($searchTerm);
@@ -119,7 +119,7 @@ class SearchController extends Controller
*/
public function searchSuggestions(Request $request)
{
$searchTerm = $request->input('term', '');
$searchTerm = $request->get('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->input('entity_type', null);
$id = $request->input('entity_id', null);
$type = $request->get('entity_type', null);
$id = $request->get('entity_id', null);
$entities = $siblingFetcher->fetch($type, $id);

View File

@@ -51,7 +51,7 @@ class SearchOptions
}
if ($request->has('term')) {
return static::fromString($request->input('term'));
return static::fromString($request->get('term'));
}
$instance = new SearchOptions();
@@ -121,11 +121,13 @@ class SearchOptions
foreach ($patterns as $termType => $pattern) {
$matches = [];
preg_match_all($pattern, $searchString, $matches);
foreach ($matches[1] as $index => $value) {
$negated = str_starts_with($matches[0][$index], '-');
$terms[$termType][] = $constructors[$termType]($value, $negated);
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);
}
$searchString = preg_replace($pattern, '', $searchString);
}
// Unescape exacts and backslash escapes
@@ -259,7 +261,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 && $filter->value !== 'me') {
if (in_array($filter->getKey(), $userFilters, true) && $filter->value !== null && $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->input('app_icon_reset')) {
if ($request->get('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->input('app_logo_reset')) {
if ($request->get('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->input('ignore_revisions', 'false') === 'true');
$checkRevisions = !($request->get('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->input('sort-tree'));
$sortMap = BookSortMap::fromJson($request->get('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->input('auto-sort')) ?: null;
$sortSetId = intval($request->get('auto-sort')) ?: null;
if ($sortSetId && SortRule::query()->find($sortSetId) === null) {
$sortSetId = null;
}

View File

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

View File

@@ -51,14 +51,7 @@ class ThemeModuleManager
}
$folderPath = $this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName;
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()}");
}
$zip->extractTo($folderPath);
$module = $this->loadFromFolder($folderName);
if (!$module) {

View File

@@ -2,7 +2,6 @@
namespace BookStack\Theming;
use BookStack\Util\FilePathNormalizer;
use ZipArchive;
readonly class ThemeModuleZip
@@ -16,46 +15,7 @@ readonly class ThemeModuleZip
{
$zip = new ZipArchive();
$zip->open($this->path);
$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->extractTo($destinationPath);
$zip->close();
}
@@ -71,8 +31,7 @@ readonly class ThemeModuleZip
throw new ThemeModuleException("Unable to open zip file at {$this->path}");
}
$prefix = $this->getZipContentPrefix($zip);
$moduleJsonText = $zip->getFromName("{$prefix}bookstack-module.json");
$moduleJsonText = $zip->getFromName('bookstack-module.json');
$zip->close();
if ($moduleJsonText === false) {
@@ -136,20 +95,4 @@ 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->input('uploaded_to');
$pageId = $request->get('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->input('uploaded_to');
$pageId = $request->get('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->input('uploaded_to');
$pageId = $request->get('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->input('attachment_edit_name'),
'link' => $request->input('attachment_edit_url'),
'name' => $request->get('attachment_edit_name'),
'link' => $request->get('attachment_edit_url'),
]);
return view('attachments.manager-edit-form', [
@@ -141,7 +141,7 @@ class AttachmentController extends Controller
*/
public function attachLink(Request $request)
{
$pageId = $request->input('attachment_link_uploaded_to');
$pageId = $request->get('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->input('attachment_link_name');
$link = $request->input('attachment_link_url');
$attachmentName = $request->get('attachment_link_name');
$link = $request->get('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->input('order');
$attachmentOrder = $request->get('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->input('open') === 'true') {
if ($request->get('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->input('page', 1);
$searchTerm = $request->input('search', null);
$uploadedToFilter = $request->input('uploaded_to', null);
$parentTypeFilter = $request->input('filter_type', null);
$page = $request->get('page', 1);
$searchTerm = $request->get('search', null);
$uploadedToFilter = $request->get('uploaded_to', null);
$parentTypeFilter = $request->get('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->input('image');
$imageBase64Data = $request->get('image');
try {
$uploadedTo = $request->input('uploaded_to', 0);
$uploadedTo = $request->get('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->input('page', 1);
$searchTerm = $request->input('search', null);
$uploadedToFilter = $request->input('uploaded_to', null);
$parentTypeFilter = $request->input('filter_type', null);
$page = $request->get('page', 1);
$searchTerm = $request->get('search', null);
$uploadedToFilter = $request->get('uploaded_to', null);
$parentTypeFilter = $request->get('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->input('uploaded_to', 0);
$uploadedTo = $request->get('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 {
} else if ($filterType === 'book') {
$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 && str_starts_with($url, 'http'));
} while ($responseCount < 3 && $isRedirect && is_string($url) && 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->input('copy_from'));
$role = Role::query()->find($request->get('copy_from'));
}
if ($role) {
@@ -150,7 +150,7 @@ class RoleController extends Controller
$this->checkPermission(Permission::UserRolesManage);
try {
$migrateRoleId = intval($request->input('migrate_role_id') ?: "0");
$migrateRoleId = intval($request->get('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->input('enabled') === 'true';
$providedShortcuts = $request->input('shortcut', []);
$enabled = $request->get('enabled') === 'true';
$providedShortcuts = $request->get('shortcut', []);
$shortcuts = new UserShortcutMap($providedShortcuts);
setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson());
@@ -218,7 +218,7 @@ class UserAccountController extends Controller
{
$this->preventAccessInDemoMode();
$requestNewOwnerId = intval($request->input('new_owner_id')) ?: null;
$requestNewOwnerId = intval($request->get('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->input('migrate_ownership_id', null);
$newOwnerId = $request->get('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->input('send_invite', 'false') === 'true');
$sendInvite = ($request->get('send_invite', 'false') === 'true');
$externalAuth = $authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'oidc';
$passwordRequired = ($authMethod === 'standard' && !$sendInvite);
@@ -191,6 +191,36 @@ class UserController extends Controller
return view('users.delete', ['user' => $user]);
}
/**
* Start impersonating the specified user.
*/
public function impersonate(int $id)
{
$this->checkPermission(Permission::UsersManage);
$user = $this->userRepo->getById($id);
if ($user->isGuest() || $user->id === user()->id) {
$this->showErrorNotification(trans('errors.users_cannot_impersonate'));
return redirect("/settings/users/{$id}");
}
session(['impersonate' => $user->id]);
return redirect('/');
}
/**
* Stop impersonating and return to user edit page.
*/
public function stopImpersonate()
{
$userId = session('impersonate');
session()->forget('impersonate');
return redirect("/settings/users/{$userId}");
}
/**
* Remove the specified user from storage.
*
@@ -202,7 +232,7 @@ class UserController extends Controller
$this->checkPermission(Permission::UsersManage);
$user = $this->userRepo->getById($id);
$newOwnerId = intval($request->input('new_owner_id')) ?: null;
$newOwnerId = intval($request->get('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->input('view');
$view = $request->get('view');
if (!in_array($view, ['grid', 'list'])) {
$view = 'list';
}
@@ -44,8 +44,8 @@ class UserPreferencesController extends Controller
return $this->redirectToRequest($request);
}
$sort = substr($request->input('sort') ?: 'name', 0, 50);
$order = $request->input('order') === 'desc' ? 'desc' : 'asc';
$sort = substr($request->get('sort') ?: 'name', 0, 50);
$order = $request->get('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->input('expand', 'false');
$newState = $request->get('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->input('search', '');
$search = $request->get('search', '');
$query = User::query()
->orderBy('name', 'asc')
->take(20);
@@ -58,7 +58,7 @@ class UserSearchController extends Controller
$this->showPermissionError();
}
$search = $request->input('search', '');
$search = $request->get('search', '');
$query = User::query()
->orderBy('name', 'asc')
->take(20);

View File

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

View File

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

View File

@@ -1,47 +0,0 @@
<?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->input('search', '');
$search = $request->get('search', '');
$sort = setting()->getForCurrentUser($typeKey . '_sort', '');
$order = setting()->getForCurrentUser($typeKey . '_sort_order', $sortDescDefault ? 'desc' : 'asc');

View File

@@ -1,67 +0,0 @@
<?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

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

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