Compare commits

..

1 Commits

Author SHA1 Message Date
Dan Brown
a9f5e98ba9 Drawings: Added class to extract drawio data from png files 2025-06-19 17:23:56 +01:00
1103 changed files with 11193 additions and 26980 deletions

View File

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

View File

@@ -36,14 +36,10 @@ APP_LANG=en
# APP_LANG will be used if such a header is not provided. # APP_LANG will be used if such a header is not provided.
APP_AUTO_LANG_PUBLIC=true APP_AUTO_LANG_PUBLIC=true
# Application timezones # Application timezone
# The first option is used to determine what timezone is used for date storage. # Used where dates are displayed such as on exported content.
# Leaving that as "UTC" is advised.
# The second option is used to set the timezone which will be used for date
# formatting and display. This defaults to the "APP_TIMEZONE" value.
# Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php # Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
APP_TIMEZONE=UTC APP_TIMEZONE=UTC
APP_DISPLAY_TIMEZONE=UTC
# Application theme # Application theme
# Used to specific a themes/<APP_THEME> folder where BookStack UI # Used to specific a themes/<APP_THEME> folder where BookStack UI
@@ -351,25 +347,10 @@ EXPORT_PDF_COMMAND_TIMEOUT=15
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections. # Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
WKHTMLTOPDF=false WKHTMLTOPDF=false
# Allow JavaScript, and other potentiall dangerous content in page content. # Allow <script> tags in page content
# This also removes CSP-level JavaScript control.
# Note, if set to 'true' the page editor may still escape scripts. # Note, if set to 'true' the page editor may still escape scripts.
# DEPRECATED: Use 'APP_CONTENT_FILTERING' instead as detailed below. Activiting this option
# effectively sets APP_CONTENT_FILTERING='' (No filtering)
ALLOW_CONTENT_SCRIPTS=false ALLOW_CONTENT_SCRIPTS=false
# Control the behaviour of content filtering, primarily used for page content.
# This setting is a string of characters which represent different available filters:
# - j - Filter out JavaScript and unknown binary data based content
# - h - Filter out unexpected, and potentially dangerous, HTML elements
# - f - Filter out unexpected form elements
# - a - Run content through a more complex allowlist filter
# This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
# Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
# Note: The default value will always be the most-strict, so it's advised to leave this unset in your own configuration
# to ensure you are always using the full range of filters.
APP_CONTENT_FILTERING="jfha"
# Indicate if robots/crawlers should crawl your instance. # Indicate if robots/crawlers should crawl your instance.
# Can be 'true', 'false' or 'null'. # Can be 'true', 'false' or 'null'.
# The behaviour of the default 'null' option will depend on the 'app-public' admin setting. # The behaviour of the default 'null' option will depend on the 'app-public' admin setting.

View File

@@ -1,2 +1,84 @@
Please find our community rules on our website here: # Contributor Covenant Code of Conduct
https://www.bookstackapp.com/about/community-rules/
## 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. description: Add any other context or screenshots about the feature request here.
validations: validations:
required: false 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

@@ -177,7 +177,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish Michał Stelmach (stelmach-web) :: Polish
arniom :: French arniom :: French
REMOVED_USER :: French; German; Dutch; Portuguese, Brazilian; Portuguese; Turkish; REMOVED_USER :: French; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
林祖年 (contagion) :: Chinese Traditional 林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@@ -222,7 +222,7 @@ SmokingCrop :: Dutch
Maciej Lebiest (Szwendacz) :: Polish Maciej Lebiest (Szwendacz) :: Polish
DiscordDigital :: German; German Informal DiscordDigital :: German; German Informal
Gábor Marton (dodver) :: Hungarian Gábor Marton (dodver) :: Hungarian
Jakob Åsell (Jasell) :: Swedish Jasell :: Swedish
Ghost_chu (ghostchu) :: Chinese Simplified Ghost_chu (ghostchu) :: Chinese Simplified
Ravid Shachar (ravidshachar) :: Hebrew Ravid Shachar (ravidshachar) :: Hebrew
Helga Guchshenskaya (guchshenskaya) :: Russian Helga Guchshenskaya (guchshenskaya) :: Russian
@@ -438,13 +438,13 @@ javadataherian :: Persian
Ludo-code :: French Ludo-code :: French
hollsten :: Swedish hollsten :: Swedish
Ngoc Lan Phung (lanpncz) :: Vietnamese Ngoc Lan Phung (lanpncz) :: Vietnamese
Worive :: Catalan; French Worive :: Catalan
Илья Скаба (skabailya) :: Russian Илья Скаба (skabailya) :: Russian
Irjan Olsen (Irch) :: Norwegian Bokmal Irjan Olsen (Irch) :: Norwegian Bokmal
Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic) Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic)
Red (RedVortex) :: Hebrew Red (RedVortex) :: Hebrew
xgrug :: Chinese Simplified xgrug :: Chinese Simplified
Calle Calmar (HrCalmar) :: Danish HrCalmar :: Danish
Avishay Rapp (AvishayRapp) :: Hebrew Avishay Rapp (AvishayRapp) :: Hebrew
matthias4217 :: French matthias4217 :: French
Berke BOYLU2 (berkeboylu2) :: Turkish Berke BOYLU2 (berkeboylu2) :: Turkish
@@ -489,51 +489,3 @@ Hari (muhhari) :: Indonesian
仙君御 (xjy) :: Chinese Simplified 仙君御 (xjy) :: Chinese Simplified
TapioM :: Finnish TapioM :: Finnish
lingb58 :: Chinese Traditional lingb58 :: Chinese Traditional
Angel Pandey (angel-pandey) :: Nepali
Supriya Shrestha (supriyashrestha) :: Nepali
gprabhat :: Nepali
CellCat :: Chinese Simplified
Al Desrahim (aldesrahim) :: Indonesian
ahmad abbaspour (deshneh.dar.diss) :: Persian
Erjon K. (ekr) :: Albanian
LiZerui (iamzrli) :: Chinese Traditional
Ticker (ticker.com) :: Hebrew
CrazyComputer :: Chinese Simplified
Firr (FirrV) :: Russian
João Faro (FaroJoaoFaro) :: Portuguese
Danilo dos Santos Barbosa (bozochegou) :: Portuguese, Brazilian
Chris (furesoft) :: German
Silvia Isern (eiendragon) :: Catalan
Dennis Kron Pedersen (ahjdp) :: Danish
iamwhoiamwhoami :: Swedish
Grogui :: French
MrCharlesIII :: Arabic
David Olsen (dawin) :: Danish
ltnzr :: French
Frank Holler (holler.frank) :: German; German Informal
Korab Arifi (korabidev) :: Albanian
Petr Husák (petrhusak) :: Czech
Bernardo Maia (bernardo.bmaia2) :: Portuguese, Brazilian
Amr (amr3k) :: Arabic
Tahsin Ahmed (tahsinahmed2012) :: Bengali
bojan_che :: Serbian (Cyrillic)
setiawan setiawan (culture.setiawan) :: Indonesian
Donald Mac Kenzie (kiuman) :: Norwegian Bokmal
Gabriel Silver (GabrielBSilver) :: Hebrew
Tomas Darius Davainis (Tomasdd) :: Lithuanian
CriedHero :: Chinese Simplified
Henrik (henrik2105) :: Norwegian Bokmal
FoW (fofwisdom) :: Korean
serinf-lauza :: French
Diyan Nikolaev (nikolaev.diyan) :: Bulgarian
Shadluk Avan (quldosh) :: Uzbek
Marci (MartonPoto) :: Hungarian
Michał Sadurski (wheeskeey) :: Polish
JanDziaslo :: Polish
Charllys Fernandes (CharllysFernandes) :: Portuguese, Brazilian
Ilgiz Zigangirov (inov8) :: Russian
Max Israelsson (Blezie) :: Swedish
Skiddybison5924 (chris-devel0per) :: German
Veyilla Nightwhisper (Veyilla) :: German
João Barbosa (hypeedd) :: Portuguese
Abcdefg Hijklmn (collatek) :: Korean

View File

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

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

@@ -45,11 +45,11 @@ class ForgotPasswordController extends Controller
); );
if ($response === Password::RESET_LINK_SENT) { 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])) { 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); $this->showSuccessNotification($message);
return redirect('/password/email')->with('status', trans($response)); return redirect('/password/email')->with('status', trans($response));

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ class ResetPasswordController extends Controller
// Here we will attempt to reset the user's password. If it is successful we // 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 // 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'); $credentials = $request->only('email', 'password', 'password_confirmation', 'token');
$response = Password::broker()->reset($credentials, function (User $user, string $password) { $response = Password::broker()->reset($credentials, function (User $user, string $password) {
$user->password = Hash::make($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. // redirect them back to where they came from with their error message.
return $response === Password::PASSWORD_RESET return $response === Password::PASSWORD_RESET
? $this->sendResetResponse() ? $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) public function startAcs(Request $request)
{ {
$samlResponse = $request->input('SAMLResponse', null); $samlResponse = $request->get('SAMLResponse', null);
if (empty($samlResponse)) { if (empty($samlResponse)) {
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')])); $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
@@ -100,7 +100,7 @@ class Saml2Controller extends Controller
*/ */
public function processAcs(Request $request) public function processAcs(Request $request)
{ {
$acsId = $request->input('id', null); $acsId = $request->get('id', null);
$cacheKey = 'saml2_acs:' . $acsId; $cacheKey = 'saml2_acs:' . $acsId;
$samlResponse = null; $samlResponse = null;

View File

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

View File

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

View File

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

View File

@@ -2,18 +2,33 @@
namespace BookStack\Access; namespace BookStack\Access;
use BookStack\Users\Models\User;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider; use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Database\Eloquent\Model;
class ExternalBaseUserProvider implements UserProvider class ExternalBaseUserProvider implements UserProvider
{ {
public function __construct(
protected string $model
) {
}
/**
* Create a new instance of the model.
*/
public function createModel(): Model
{
$class = '\\' . ltrim($this->model, '\\');
return new $class();
}
/** /**
* Retrieve a user by their unique identifier. * Retrieve a user by their unique identifier.
*/ */
public function retrieveById(mixed $identifier): ?Authenticatable public function retrieveById(mixed $identifier): ?Authenticatable
{ {
return User::query()->find($identifier); return $this->createModel()->newQuery()->find($identifier);
} }
/** /**
@@ -44,7 +59,10 @@ class ExternalBaseUserProvider implements UserProvider
*/ */
public function retrieveByCredentials(array $credentials): ?Authenticatable public function retrieveByCredentials(array $credentials): ?Authenticatable
{ {
return User::query() // Search current user base by looking up a uid
$model = $this->createModel();
return $model->newQuery()
->where('external_auth_id', $credentials['external_auth_id']) ->where('external_auth_id', $credentials['external_auth_id'])
->first(); ->first();
} }

View File

@@ -3,18 +3,23 @@
namespace BookStack\Access\Guards; namespace BookStack\Access\Guards;
/** /**
* External Auth Session Guard. * Saml2 Session Guard.
* *
* The login process for external auth (SAML2/OIDC) is async in nature, meaning it does not fit very well * The saml2 login process is async in nature meaning it does not fit very well
* into the default laravel 'Guard' auth flow. Instead, most of the logic is done via the relevant * into the default laravel 'Guard' auth flow. Instead most of the logic is done
* controller and services. This class provides a safer, thin version of SessionGuard. * via the Saml2 controller & Saml2Service. This class provides a safer, thin
* version of SessionGuard.
*/ */
class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
{ {
/** /**
* Validate a user's credentials. * Validate a user's credentials.
*
* @param array $credentials
*
* @return bool
*/ */
public function validate(array $credentials = []): bool public function validate(array $credentials = [])
{ {
return false; return false;
} }
@@ -22,9 +27,12 @@ class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
/** /**
* Attempt to authenticate a user using the given credentials. * Attempt to authenticate a user using the given credentials.
* *
* @param array $credentials
* @param bool $remember * @param bool $remember
*
* @return bool
*/ */
public function attempt(array $credentials = [], $remember = false): bool public function attempt(array $credentials = [], $remember = false)
{ {
return false; return false;
} }

View File

@@ -4,7 +4,7 @@ namespace BookStack\Access\Guards;
use BookStack\Access\RegistrationService; use BookStack\Access\RegistrationService;
use Illuminate\Auth\GuardHelpers; use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\StatefulGuard; use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Contracts\Auth\UserProvider; use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Session\Session; use Illuminate\Contracts\Session\Session;
@@ -24,31 +24,43 @@ class ExternalBaseSessionGuard implements StatefulGuard
* The name of the Guard. Typically "session". * The name of the Guard. Typically "session".
* *
* Corresponds to guard name in authentication configuration. * Corresponds to guard name in authentication configuration.
*
* @var string
*/ */
protected readonly string $name; protected $name;
/** /**
* The user we last attempted to retrieve. * The user we last attempted to retrieve.
*
* @var \Illuminate\Contracts\Auth\Authenticatable
*/ */
protected Authenticatable|null $lastAttempted; protected $lastAttempted;
/** /**
* The session used by the guard. * The session used by the guard.
*
* @var \Illuminate\Contracts\Session\Session
*/ */
protected Session $session; protected $session;
/** /**
* Indicates if the logout method has been called. * Indicates if the logout method has been called.
*
* @var bool
*/ */
protected bool $loggedOut = false; protected $loggedOut = false;
/** /**
* Service to handle common registration actions. * Service to handle common registration actions.
*
* @var RegistrationService
*/ */
protected RegistrationService $registrationService; protected $registrationService;
/** /**
* Create a new authentication guard. * Create a new authentication guard.
*
* @return void
*/ */
public function __construct(string $name, UserProvider $provider, Session $session, RegistrationService $registrationService) public function __construct(string $name, UserProvider $provider, Session $session, RegistrationService $registrationService)
{ {
@@ -60,11 +72,13 @@ class ExternalBaseSessionGuard implements StatefulGuard
/** /**
* Get the currently authenticated user. * Get the currently authenticated user.
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/ */
public function user(): Authenticatable|null public function user()
{ {
if ($this->loggedOut) { if ($this->loggedOut) {
return null; return;
} }
// If we've already retrieved the user for the current request we can just // If we've already retrieved the user for the current request we can just
@@ -87,11 +101,13 @@ class ExternalBaseSessionGuard implements StatefulGuard
/** /**
* Get the ID for the currently authenticated user. * Get the ID for the currently authenticated user.
*
* @return int|null
*/ */
public function id(): int|null public function id()
{ {
if ($this->loggedOut) { if ($this->loggedOut) {
return null; return;
} }
return $this->user() return $this->user()
@@ -101,8 +117,12 @@ class ExternalBaseSessionGuard implements StatefulGuard
/** /**
* Log a user into the application without sessions or cookies. * Log a user into the application without sessions or cookies.
*
* @param array $credentials
*
* @return bool
*/ */
public function once(array $credentials = []): bool public function once(array $credentials = [])
{ {
if ($this->validate($credentials)) { if ($this->validate($credentials)) {
$this->setUser($this->lastAttempted); $this->setUser($this->lastAttempted);
@@ -115,8 +135,12 @@ class ExternalBaseSessionGuard implements StatefulGuard
/** /**
* Log the given user ID into the application without sessions or cookies. * Log the given user ID into the application without sessions or cookies.
*
* @param mixed $id
*
* @return \Illuminate\Contracts\Auth\Authenticatable|false
*/ */
public function onceUsingId($id): Authenticatable|false public function onceUsingId($id)
{ {
if (!is_null($user = $this->provider->retrieveById($id))) { if (!is_null($user = $this->provider->retrieveById($id))) {
$this->setUser($user); $this->setUser($user);
@@ -129,26 +153,38 @@ class ExternalBaseSessionGuard implements StatefulGuard
/** /**
* Validate a user's credentials. * Validate a user's credentials.
*
* @param array $credentials
*
* @return bool
*/ */
public function validate(array $credentials = []): bool public function validate(array $credentials = [])
{ {
return false; return false;
} }
/** /**
* Attempt to authenticate a user using the given credentials. * Attempt to authenticate a user using the given credentials.
* @param bool $remember *
* @param array $credentials
* @param bool $remember
*
* @return bool
*/ */
public function attempt(array $credentials = [], $remember = false): bool public function attempt(array $credentials = [], $remember = false)
{ {
return false; return false;
} }
/** /**
* Log the given user ID into the application. * Log the given user ID into the application.
*
* @param mixed $id
* @param bool $remember * @param bool $remember
*
* @return \Illuminate\Contracts\Auth\Authenticatable|false
*/ */
public function loginUsingId(mixed $id, $remember = false): Authenticatable|false public function loginUsingId($id, $remember = false)
{ {
// Always return false as to disable this method, // Always return false as to disable this method,
// Logins should route through LoginService. // Logins should route through LoginService.
@@ -158,9 +194,12 @@ class ExternalBaseSessionGuard implements StatefulGuard
/** /**
* Log a user into the application. * Log a user into the application.
* *
* @param bool $remember * @param \Illuminate\Contracts\Auth\Authenticatable $user
* @param bool $remember
*
* @return void
*/ */
public function login(Authenticatable $user, $remember = false): void public function login(AuthenticatableContract $user, $remember = false)
{ {
$this->updateSession($user->getAuthIdentifier()); $this->updateSession($user->getAuthIdentifier());
@@ -169,8 +208,12 @@ class ExternalBaseSessionGuard implements StatefulGuard
/** /**
* Update the session with the given ID. * Update the session with the given ID.
*
* @param string $id
*
* @return void
*/ */
protected function updateSession(string|int $id): void protected function updateSession($id)
{ {
$this->session->put($this->getName(), $id); $this->session->put($this->getName(), $id);
@@ -179,8 +222,10 @@ class ExternalBaseSessionGuard implements StatefulGuard
/** /**
* Log the user out of the application. * Log the user out of the application.
*
* @return void
*/ */
public function logout(): void public function logout()
{ {
$this->clearUserDataFromStorage(); $this->clearUserDataFromStorage();
@@ -194,48 +239,62 @@ class ExternalBaseSessionGuard implements StatefulGuard
/** /**
* Remove the user data from the session and cookies. * Remove the user data from the session and cookies.
*
* @return void
*/ */
protected function clearUserDataFromStorage(): void protected function clearUserDataFromStorage()
{ {
$this->session->remove($this->getName()); $this->session->remove($this->getName());
} }
/** /**
* Get the last user we attempted to authenticate. * Get the last user we attempted to authenticate.
*
* @return \Illuminate\Contracts\Auth\Authenticatable
*/ */
public function getLastAttempted(): Authenticatable public function getLastAttempted()
{ {
return $this->lastAttempted; return $this->lastAttempted;
} }
/** /**
* Get a unique identifier for the auth session value. * Get a unique identifier for the auth session value.
*
* @return string
*/ */
public function getName(): string public function getName()
{ {
return 'login_' . $this->name . '_' . sha1(static::class); return 'login_' . $this->name . '_' . sha1(static::class);
} }
/** /**
* Determine if the user was authenticated via "remember me" cookie. * Determine if the user was authenticated via "remember me" cookie.
*
* @return bool
*/ */
public function viaRemember(): bool public function viaRemember()
{ {
return false; return false;
} }
/** /**
* Return the currently cached user. * Return the currently cached user.
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/ */
public function getUser(): Authenticatable|null public function getUser()
{ {
return $this->user; return $this->user;
} }
/** /**
* Set the current user. * Set the current user.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
*
* @return $this
*/ */
public function setUser(Authenticatable $user): self public function setUser(AuthenticatableContract $user)
{ {
$this->user = $user; $this->user = $user;

View File

@@ -35,9 +35,13 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
/** /**
* Validate a user's credentials. * Validate a user's credentials.
* *
* @param array $credentials
*
* @throws LdapException * @throws LdapException
*
* @return bool
*/ */
public function validate(array $credentials = []): bool public function validate(array $credentials = [])
{ {
$userDetails = $this->ldapService->getUserDetails($credentials['username']); $userDetails = $this->ldapService->getUserDetails($credentials['username']);
@@ -53,13 +57,16 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
/** /**
* Attempt to authenticate a user using the given credentials. * Attempt to authenticate a user using the given credentials.
* *
* @param array $credentials
* @param bool $remember * @param bool $remember
* *
* @throws LdapException * @throws LdapException*@throws \BookStack\Exceptions\JsonDebugException
* @throws LoginAttemptException * @throws LoginAttemptException
* @throws JsonDebugException * @throws JsonDebugException
*
* @return bool
*/ */
public function attempt(array $credentials = [], $remember = false): bool public function attempt(array $credentials = [], $remember = false)
{ {
$username = $credentials['username']; $username = $credentials['username'];
$userDetails = $this->ldapService->getUserDetails($username); $userDetails = $this->ldapService->getUserDetails($username);

View File

@@ -9,7 +9,6 @@ use BookStack\Exceptions\LoginAttemptInvalidUserException;
use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
use BookStack\Facades\Theme; use BookStack\Facades\Theme;
use BookStack\Permissions\Permission;
use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Exception; use Exception;
@@ -51,7 +50,7 @@ class LoginService
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user); Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user);
// Authenticate on all session guards if a likely admin // Authenticate on all session guards if a likely admin
if ($user->can(Permission::UsersManage) && $user->can(Permission::UserRolesManage)) { if ($user->can('users-manage') && $user->can('user-roles-manage')) {
$guards = ['standard', 'ldap', 'saml2', 'oidc']; $guards = ['standard', 'ldap', 'saml2', 'oidc'];
foreach ($guards as $guard) { foreach ($guards as $guard) {
auth($guard)->login($user); auth($guard)->login($user);
@@ -71,7 +70,7 @@ class LoginService
} }
$lastLoginDetails = $this->getLastLoginAttemptDetails(); $lastLoginDetails = $this->getLastLoginAttemptDetails();
$this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember']); $this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember'] ?? false);
} }
/** /**
@@ -96,7 +95,7 @@ class LoginService
{ {
$value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY); $value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
if (!$value) { if (!$value) {
return ['user_id' => null, 'method' => null, 'remember' => false]; return ['user_id' => null, 'method' => null];
} }
[$id, $method, $remember, $time] = explode(':', $value); [$id, $method, $remember, $time] = explode(':', $value);
@@ -104,18 +103,18 @@ class LoginService
if ($time < $hourAgo) { if ($time < $hourAgo) {
$this->clearLastLoginAttempted(); $this->clearLastLoginAttempted();
return ['user_id' => null, 'method' => null, 'remember' => false]; return ['user_id' => null, 'method' => null];
} }
return ['user_id' => $id, 'method' => $method, 'remember' => boolval($remember)]; return ['user_id' => $id, 'method' => $method, 'remember' => boolval($remember)];
} }
/** /**
* Set the last login-attempted user. * Set the last login attempted user.
* Must be only used when credentials are correct and a login could be * Must be only used when credentials are correct and a login could be
* achieved, but a secondary factor has stopped the login. * achieved but a secondary factor has stopped the login.
*/ */
protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember): void protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember)
{ {
session()->put( session()->put(
self::LAST_LOGIN_ATTEMPTED_SESSION_KEY, self::LAST_LOGIN_ATTEMPTED_SESSION_KEY,

View File

@@ -11,6 +11,7 @@ class MfaSession
*/ */
public function isRequiredForUser(User $user): bool public function isRequiredForUser(User $user): bool
{ {
// TODO - Test both these cases
return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user); return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
} }

View File

@@ -4,7 +4,6 @@ namespace BookStack\Access\Mfa;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** /**
@@ -17,8 +16,6 @@ use Illuminate\Database\Eloquent\Model;
*/ */
class MfaValue extends Model class MfaValue extends Model
{ {
use HasFactory;
protected static $unguarded = true; protected static $unguarded = true;
const METHOD_TOTP = 'totp'; const METHOD_TOTP = 'totp';
@@ -48,16 +45,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 public static function getValueForUser(User $user, string $method): ?string
{ {
/** @var MfaValue $mfaVal */
$mfaVal = static::query() $mfaVal = static::query()
->where('user_id', '=', $user->id) ->where('user_id', '=', $user->id)
->where('method', '=', $method) ->where('method', '=', $method)
->first(); ->first();
return $mfaVal?->getValue(); return $mfaVal ? $mfaVal->getValue() : null;
} }
/** /**

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,9 +9,7 @@ class OidcJwtWithClaims implements ProvidesClaims
protected string $signature; protected string $signature;
protected string $issuer; protected string $issuer;
protected array $tokenParts = []; protected array $tokenParts = [];
protected array $acceptedSignatures = [self::hs256Signature, self::rs256Signature];
private const hs256Signature = 'HS256'
, rs256Signature = 'RS256';
/** /**
* @var array[]|string[] * @var array[]|string[]
*/ */
@@ -61,11 +59,11 @@ class OidcJwtWithClaims implements ProvidesClaims
* *
* @throws OidcInvalidTokenException * @throws OidcInvalidTokenException
*/ */
public function validateCommonTokenDetails(OidcProviderSettings $settings): bool public function validateCommonTokenDetails(string $clientId): bool
{ {
$this->validateTokenStructure(); $this->validateTokenStructure();
$this->validateTokenSignature($settings); $this->validateTokenSignature();
$this->validateCommonClaims($settings->clientId); $this->validateCommonClaims($clientId);
return true; return true;
} }
@@ -119,42 +117,31 @@ class OidcJwtWithClaims implements ProvidesClaims
* *
* @throws OidcInvalidTokenException * @throws OidcInvalidTokenException
*/ */
protected function validateTokenSignature(OidcProviderSettings $settings): void { protected function validateTokenSignature(): void
$validSignatures = implode(', ',$this->acceptedSignatures); {
switch ($this->header['alg']) { if ($this->header['alg'] !== 'RS256') {
case self::rs256Signature: throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
$parsedKeys = array_map(function ($key) {
try {
return new OidcJwtSigningKey($key);
} catch (OidcInvalidKeyException $e) {
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
}
}, $this->keys);
$parsedKeys = array_filter($parsedKeys);
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
/** @var OidcJwtSigningKey $parsedKey */
foreach ($parsedKeys as $parsedKey) {
if ($parsedKey->verify($contentToSign, $this->signature)) {
return;
}
}
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
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(); $this->validateInitial();
$required = ['tokenEndpoint', 'authorizationEndpoint']; $required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
foreach ($required as $prop) { foreach ($required as $prop) {
if (empty($this->$prop)) { if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value"); throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");

View File

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

View File

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

View File

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

View File

@@ -51,7 +51,7 @@ class Saml2Service
* Returns the SAML2 request ID, and the URL to redirect the user to. * Returns the SAML2 request ID, and the URL to redirect the user to.
* *
* @throws Error * @throws Error
* @return array{url: string, id: ?string} * @returns array{url: string, id: ?string}
*/ */
public function logout(User $user): array public function logout(User $user): array
{ {
@@ -266,7 +266,7 @@ class Saml2Service
/** /**
* Extract the details of a user from a SAML response. * 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 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')); throw new SamlException(trans('errors.saml_no_email_address'));
} }

View File

@@ -5,23 +5,18 @@ namespace BookStack\Access;
use BookStack\Activity\Models\Loggable; use BookStack\Activity\Models\Loggable;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
* Class SocialAccount.
*
* @property string $driver * @property string $driver
* @property User $user * @property User $user
*/ */
class SocialAccount extends Model implements Loggable class SocialAccount extends Model implements Loggable
{ {
use HasFactory; protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps'];
protected $fillable = ['user_id', 'driver', 'driver_id']; public function user()
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }

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. // 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])); session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver]));
return redirect('/my-account/auth#social_accounts'); return redirect('/my-account/auth#social_accounts');
} }
// When a user is logged in, A social account exists but the users do not match. // 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])); session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver]));
return redirect('/my-account/auth#social_accounts'); return redirect('/my-account/auth#social_accounts');

View File

@@ -55,7 +55,7 @@ class SocialDriverManager
/** /**
* Gets the names of the active social drivers, keyed by driver id. * Gets the names of the active social drivers, keyed by driver id.
* @return array<string, string> * @returns array<string, string>
*/ */
public function getActive(): array public function getActive(): array
{ {

View File

@@ -11,7 +11,6 @@ use BookStack\Entities\Tools\MixedEntityListLoader;
use BookStack\Permissions\PermissionApplicator; use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
class ActivityQueries class ActivityQueries
@@ -68,7 +67,6 @@ class ActivityQueries
$activity = $query->orderBy('created_at', 'desc') $activity = $query->orderBy('created_at', 'desc')
->with(['loggable' => function (Relation $query) { ->with(['loggable' => function (Relation $query) {
/** @var MorphTo<Entity, Activity> $query */
$query->withTrashed(); $query->withTrashed();
}, 'user.avatar']) }, 'user.avatar'])
->skip($count * ($page - 1)) ->skip($count * ($page - 1))

View File

@@ -4,11 +4,10 @@ namespace BookStack\Activity;
use BookStack\Activity\Models\Comment; use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PrettyException;
use BookStack\Facades\Activity as ActivityService; use BookStack\Facades\Activity as ActivityService;
use BookStack\Util\HtmlDescriptionFilter; use BookStack\Util\HtmlDescriptionFilter;
use Illuminate\Database\Eloquent\Builder;
class CommentRepo class CommentRepo
{ {
@@ -20,46 +19,11 @@ class CommentRepo
return Comment::query()->findOrFail($id); return Comment::query()->findOrFail($id);
} }
/**
* Get a comment by ID, ensuring it is visible to the user based upon access to the page
* which the comment is attached to.
*/
public function getVisibleById(int $id): Comment
{
return $this->getQueryForVisible()->findOrFail($id);
}
/**
* Start a query for comments visible to the user.
* @return Builder<Comment>
*/
public function getQueryForVisible(): Builder
{
return Comment::query()->scopes('visible');
}
/** /**
* Create a new comment on an entity. * Create a new comment on an entity.
*/ */
public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment
{ {
// Prevent comments being added to draft pages
if ($entity instanceof Page && $entity->draft) {
throw new \Exception(trans('errors.cannot_add_comment_to_draft'));
}
// Validate parent ID
if ($parentId !== null) {
$parentCommentExists = Comment::query()
->where('commentable_id', '=', $entity->id)
->where('commentable_type', '=', $entity->getMorphClass())
->where('local_id', '=', $parentId)
->exists();
if (!$parentCommentExists) {
$parentId = null;
}
}
$userId = user()->id; $userId = user()->id;
$comment = new Comment(); $comment = new Comment();
@@ -74,7 +38,6 @@ class CommentRepo
ActivityService::add(ActivityType::COMMENT_CREATE, $comment); ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
ActivityService::add(ActivityType::COMMENTED_ON, $entity); ActivityService::add(ActivityType::COMMENTED_ON, $entity);
$comment->refresh()->unsetRelations();
return $comment; return $comment;
} }
@@ -96,7 +59,7 @@ class CommentRepo
/** /**
* Archive an existing comment. * Archive an existing comment.
*/ */
public function archive(Comment $comment, bool $log = true): Comment public function archive(Comment $comment): Comment
{ {
if ($comment->parent_id) { if ($comment->parent_id) {
throw new NotifyException('Only top-level comments can be archived.', '/', 400); throw new NotifyException('Only top-level comments can be archived.', '/', 400);
@@ -105,9 +68,7 @@ class CommentRepo
$comment->archived = true; $comment->archived = true;
$comment->save(); $comment->save();
if ($log) { ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
}
return $comment; return $comment;
} }
@@ -115,7 +76,7 @@ class CommentRepo
/** /**
* Un-archive an existing comment. * Un-archive an existing comment.
*/ */
public function unarchive(Comment $comment, bool $log = true): Comment public function unarchive(Comment $comment): Comment
{ {
if ($comment->parent_id) { if ($comment->parent_id) {
throw new NotifyException('Only top-level comments can be un-archived.', '/', 400); throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
@@ -124,9 +85,7 @@ class CommentRepo
$comment->archived = false; $comment->archived = false;
$comment->save(); $comment->save();
if ($log) { ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
}
return $comment; return $comment;
} }

View File

@@ -4,7 +4,6 @@ namespace BookStack\Activity\Controllers;
use BookStack\Activity\Models\Activity; use BookStack\Activity\Models\Activity;
use BookStack\Http\ApiController; use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
class AuditLogApiController extends ApiController class AuditLogApiController extends ApiController
{ {
@@ -17,8 +16,8 @@ class AuditLogApiController extends ApiController
*/ */
public function list() public function list()
{ {
$this->checkPermission(Permission::SettingsManage); $this->checkPermission('settings-manage');
$this->checkPermission(Permission::UsersManage); $this->checkPermission('users-manage');
$query = Activity::query()->with(['user']); $query = Activity::query()->with(['user']);

View File

@@ -5,7 +5,6 @@ namespace BookStack\Activity\Controllers;
use BookStack\Activity\ActivityType; use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity; use BookStack\Activity\Models\Activity;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Sorting\SortUrl; use BookStack\Sorting\SortUrl;
use BookStack\Util\SimpleListOptions; use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -14,22 +13,22 @@ class AuditLogController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
$this->checkPermission(Permission::SettingsManage); $this->checkPermission('settings-manage');
$this->checkPermission(Permission::UsersManage); $this->checkPermission('users-manage');
$sort = $request->input('sort', 'activity_date'); $sort = $request->get('sort', 'activity_date');
$order = $request->input('order', 'desc'); $order = $request->get('order', 'desc');
$listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([ $listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([
'created_at' => trans('settings.audit_table_date'), 'created_at' => trans('settings.audit_table_date'),
'type' => trans('settings.audit_table_event'), 'type' => trans('settings.audit_table_event'),
]); ]);
$filters = [ $filters = [
'event' => $request->input('event', ''), 'event' => $request->get('event', ''),
'date_from' => $request->input('date_from', ''), 'date_from' => $request->get('date_from', ''),
'date_to' => $request->input('date_to', ''), 'date_to' => $request->get('date_to', ''),
'user' => $request->input('user', ''), 'user' => $request->get('user', ''),
'ip' => $request->input('ip', ''), 'ip' => $request->get('ip', ''),
]; ];
$query = Activity::query() $query = Activity::query()

View File

@@ -1,148 +0,0 @@
<?php
declare(strict_types=1);
namespace BookStack\Activity\Controllers;
use BookStack\Activity\CommentRepo;
use BookStack\Activity\Models\Comment;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Http\ApiController;
use BookStack\Permissions\Permission;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* The comment data model has a 'local_id' property, which is a unique integer ID
* scoped to the page which the comment is on. The 'parent_id' is used for replies
* and refers to the 'local_id' of the parent comment on the same page, not the main
* globally unique 'id'.
*
* If you want to get all comments for a page in a tree-like structure, as reflected in
* the UI, then that is provided on pages-read API responses.
*/
class CommentApiController extends ApiController
{
protected array $rules = [
'create' => [
'page_id' => ['required', 'integer'],
'reply_to' => ['nullable', 'integer'],
'html' => ['required', 'string'],
'content_ref' => ['string'],
],
'update' => [
'html' => ['string'],
'archived' => ['boolean'],
]
];
public function __construct(
protected CommentRepo $commentRepo,
protected PageQueries $pageQueries,
) {
}
/**
* Get a listing of comments visible to the user.
*/
public function list(): JsonResponse
{
$query = $this->commentRepo->getQueryForVisible();
return $this->apiListingResponse($query, [
'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at'
]);
}
/**
* Create a new comment on a page.
* If commenting as a reply to an existing comment, the 'reply_to' parameter
* should be provided, set to the 'local_id' of the comment being replied to.
*/
public function create(Request $request): JsonResponse
{
$this->checkPermission(Permission::CommentCreateAll);
$input = $this->validate($request, $this->rules()['create']);
$page = $this->pageQueries->findVisibleByIdOrFail($input['page_id']);
$comment = $this->commentRepo->create(
$page,
$input['html'],
$input['reply_to'] ?? null,
$input['content_ref'] ?? '',
);
return response()->json($comment);
}
/**
* Read the details of a single comment, along with its direct replies.
*/
public function read(string $id): JsonResponse
{
$comment = $this->commentRepo->getVisibleById(intval($id));
$comment->load('createdBy', 'updatedBy');
$replies = $this->commentRepo->getQueryForVisible()
->where('parent_id', '=', $comment->local_id)
->where('commentable_id', '=', $comment->commentable_id)
->where('commentable_type', '=', $comment->commentable_type)
->get();
/** @var Comment[] $toProcess */
$toProcess = [$comment, ...$replies];
foreach ($toProcess as $commentToProcess) {
$commentToProcess->setAttribute('html', $commentToProcess->safeHtml());
$commentToProcess->makeVisible('html');
}
$comment->setRelation('replies', $replies);
return response()->json($comment);
}
/**
* Update the content or archived status of an existing comment.
*
* Only provide a new archived status if needing to actively change the archive state.
* Only top-level comments (non-replies) can be archived or unarchived.
*/
public function update(Request $request, string $id): JsonResponse
{
$comment = $this->commentRepo->getVisibleById(intval($id));
$this->checkOwnablePermission(Permission::CommentUpdate, $comment);
$input = $this->validate($request, $this->rules()['update']);
$hasHtml = isset($input['html']);
if (isset($input['archived'])) {
if ($input['archived']) {
$this->commentRepo->archive($comment, !$hasHtml);
} else {
$this->commentRepo->unarchive($comment, !$hasHtml);
}
}
if ($hasHtml) {
$comment = $this->commentRepo->update($comment, $input['html']);
}
return response()->json($comment);
}
/**
* Delete a single comment from the system.
*/
public function delete(string $id): Response
{
$comment = $this->commentRepo->getVisibleById(intval($id));
$this->checkOwnablePermission(Permission::CommentDelete, $comment);
$this->commentRepo->delete($comment);
return response('', 204);
}
}

View File

@@ -7,7 +7,6 @@ use BookStack\Activity\Tools\CommentTree;
use BookStack\Activity\Tools\CommentTreeNode; use BookStack\Activity\Tools\CommentTreeNode;
use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Queries\PageQueries;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -22,7 +21,7 @@ class CommentController extends Controller
/** /**
* Save a new comment for a Page. * Save a new comment for a Page.
* *
* @throws ValidationException|\Exception * @throws ValidationException
*/ */
public function savePageComment(Request $request, int $pageId) public function savePageComment(Request $request, int $pageId)
{ {
@@ -37,8 +36,13 @@ class CommentController extends Controller
return response('Not found', 404); return response('Not found', 404);
} }
// Prevent adding comments to draft pages
if ($page->draft) {
return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400);
}
// Create a new comment. // Create a new comment.
$this->checkPermission(Permission::CommentCreateAll); $this->checkPermission('comment-create-all');
$contentRef = $input['content_ref'] ?? ''; $contentRef = $input['content_ref'] ?? '';
$comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef); $comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef);
@@ -60,8 +64,8 @@ class CommentController extends Controller
]); ]);
$comment = $this->commentRepo->getById($commentId); $comment = $this->commentRepo->getById($commentId);
$this->checkOwnablePermission(Permission::PageView, $comment->entity); $this->checkOwnablePermission('page-view', $comment->entity);
$this->checkOwnablePermission(Permission::CommentUpdate, $comment); $this->checkOwnablePermission('comment-update', $comment);
$comment = $this->commentRepo->update($comment, $input['html']); $comment = $this->commentRepo->update($comment, $input['html']);
@@ -77,8 +81,8 @@ class CommentController extends Controller
public function archive(int $id) public function archive(int $id)
{ {
$comment = $this->commentRepo->getById($id); $comment = $this->commentRepo->getById($id);
$this->checkOwnablePermission(Permission::PageView, $comment->entity); $this->checkOwnablePermission('page-view', $comment->entity);
if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) { if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
$this->showPermissionError(); $this->showPermissionError();
} }
@@ -97,8 +101,8 @@ class CommentController extends Controller
public function unarchive(int $id) public function unarchive(int $id)
{ {
$comment = $this->commentRepo->getById($id); $comment = $this->commentRepo->getById($id);
$this->checkOwnablePermission(Permission::PageView, $comment->entity); $this->checkOwnablePermission('page-view', $comment->entity);
if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) { if (!userCan('comment-update', $comment) && !userCan('comment-delete', $comment)) {
$this->showPermissionError(); $this->showPermissionError();
} }
@@ -117,7 +121,7 @@ class CommentController extends Controller
public function destroy(int $id) public function destroy(int $id)
{ {
$comment = $this->commentRepo->getById($id); $comment = $this->commentRepo->getById($id);
$this->checkOwnablePermission(Permission::CommentDelete, $comment); $this->checkOwnablePermission('comment-delete', $comment);
$this->commentRepo->delete($comment); $this->commentRepo->delete($comment);

View File

@@ -20,7 +20,7 @@ class FavouriteController extends Controller
public function index(Request $request, QueryTopFavourites $topFavourites) public function index(Request $request, QueryTopFavourites $topFavourites)
{ {
$viewCount = 20; $viewCount = 20;
$page = intval($request->input('page', 1)); $page = intval($request->get('page', 1));
$favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount)); $favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount));
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null; $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'), 'usages' => trans('entities.tags_usages'),
]); ]);
$nameFilter = $request->input('name', ''); $nameFilter = $request->get('name', '');
$tags = $this->tagRepo $tags = $this->tagRepo
->queryWithTotalsForList($listOptions, $nameFilter) ->queryWithTotals($listOptions, $nameFilter)
->paginate(50) ->paginate(50)
->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [ ->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [
'name' => $nameFilter, 'name' => $nameFilter,
@@ -46,7 +46,7 @@ class TagController extends Controller
*/ */
public function getNameSuggestions(Request $request) public function getNameSuggestions(Request $request)
{ {
$searchTerm = $request->input('search', ''); $searchTerm = $request->get('search', '');
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm); $suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
return response()->json($suggestions); return response()->json($suggestions);
@@ -57,8 +57,8 @@ class TagController extends Controller
*/ */
public function getValueSuggestions(Request $request) public function getValueSuggestions(Request $request)
{ {
$searchTerm = $request->input('search', ''); $searchTerm = $request->get('search', '');
$tagName = $request->input('name', ''); $tagName = $request->get('name', '');
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName); $suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
return response()->json($suggestions); return response()->json($suggestions);

View File

@@ -5,14 +5,13 @@ namespace BookStack\Activity\Controllers;
use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Tools\MixedEntityRequestHelper; use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class WatchController extends Controller class WatchController extends Controller
{ {
public function update(Request $request, MixedEntityRequestHelper $entityHelper) public function update(Request $request, MixedEntityRequestHelper $entityHelper)
{ {
$this->checkPermission(Permission::ReceiveNotifications); $this->checkPermission('receive-notifications');
$this->preventGuestAccess(); $this->preventGuestAccess();
$requestData = $this->validate($request, array_merge([ $requestData = $this->validate($request, array_merge([

View File

@@ -6,7 +6,6 @@ use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Webhook; use BookStack\Activity\Models\Webhook;
use BookStack\Activity\Queries\WebhooksAllPaginatedAndSorted; use BookStack\Activity\Queries\WebhooksAllPaginatedAndSorted;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Util\SimpleListOptions; use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -15,7 +14,7 @@ class WebhookController extends Controller
public function __construct() public function __construct()
{ {
$this->middleware([ $this->middleware([
Permission::SettingsManage->middleware() 'can:settings-manage',
]); ]);
} }

View File

@@ -6,7 +6,6 @@ use BookStack\App\Model;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\Models\JointPermission;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -25,8 +24,6 @@ use Illuminate\Support\Str;
*/ */
class Activity extends Model class Activity extends Model
{ {
use HasFactory;
/** /**
* Get the loggable model related to this activity. * Get the loggable model related to this activity.
* Currently only used for entities (previously entity_[id/type] columns). * Currently only used for entities (previously entity_[id/type] columns).

View File

@@ -3,70 +3,48 @@
namespace BookStack\Activity\Models; namespace BookStack\Activity\Models;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\Permissions\Models\JointPermission;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\HasCreatorAndUpdater; use BookStack\Users\Models\HasCreatorAndUpdater;
use BookStack\Users\Models\OwnableInterface;
use BookStack\Util\HtmlContentFilter; 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\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
/** /**
* @property int $id * @property int $id
* @property string $text - Deprecated & now unused (#4821)
* @property string $html * @property string $html
* @property int|null $parent_id - Relates to local_id, not id * @property int|null $parent_id - Relates to local_id, not id
* @property int $local_id * @property int $local_id
* @property string $commentable_type * @property string $entity_type
* @property int $commentable_id * @property int $entity_id
* @property int $created_by
* @property int $updated_by
* @property string $content_ref * @property string $content_ref
* @property bool $archived * @property bool $archived
*/ */
class Comment extends Model implements Loggable, OwnableInterface class Comment extends Model implements Loggable
{ {
use HasFactory; use HasFactory;
use HasCreatorAndUpdater; use HasCreatorAndUpdater;
protected $fillable = ['parent_id']; protected $fillable = ['parent_id'];
protected $hidden = ['html'];
protected $casts = [
'archived' => 'boolean',
];
/** /**
* Get the entity that this comment belongs to. * Get the entity that this comment belongs to.
*/ */
public function entity(): MorphTo public function entity(): MorphTo
{ {
// We specifically define null here to avoid the different name (commentable) return $this->morphTo('entity');
// being used by Laravel eager loading instead of the method name, which it was doing
// in some scenarios like when deserialized when going through the queue system.
// So we instead specify the type and id column names to use.
// Related to:
// https://github.com/laravel/framework/pull/24815
// https://github.com/laravel/framework/issues/27342
// https://github.com/laravel/framework/issues/47953
// (and probably more)
// Ultimately, we could just align the method name to 'commentable' but that would be a potential
// breaking change and not really worthwhile in a patch due to the risk of creating extra problems.
return $this->morphTo(null, 'commentable_type', 'commentable_id');
} }
/** /**
* Get the parent comment this is in reply to (if existing). * Get the parent comment this is in reply to (if existing).
* @return BelongsTo<Comment, $this>
*/ */
public function parent(): BelongsTo public function parent(): BelongsTo
{ {
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent') return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
->where('commentable_type', '=', $this->commentable_type) ->where('entity_type', '=', $this->entity_type)
->where('commentable_id', '=', $this->commentable_id); ->where('entity_id', '=', $this->entity_id);
} }
/** /**
@@ -79,34 +57,11 @@ class Comment extends Model implements Loggable, OwnableInterface
public function logDescriptor(): string public function logDescriptor(): string
{ {
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->commentable_type} (ID: {$this->commentable_id})"; return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
} }
public function safeHtml(): string public function safeHtml(): string
{ {
$filter = new HtmlContentFilter(new HtmlContentFilterConfig()); return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
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')
->whereColumn('joint_permissions.entity_type', '=', 'comments.commentable_type');
}
/**
* Scope the query to just the comments visible to the user based upon the
* user visibility of what has been commented on.
*/
public function scopeVisible(Builder $query): Builder
{
return app()->make(PermissionApplicator::class)
->restrictEntityRelationQuery($query, 'comments', 'commentable_id', 'commentable_type');
} }
} }

View File

@@ -4,14 +4,11 @@ namespace BookStack\Activity\Models;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\Models\JointPermission;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
class Favourite extends Model class Favourite extends Model
{ {
use HasFactory;
protected $fillable = ['user_id']; protected $fillable = ['user_id'];
/** /**

View File

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

View File

@@ -12,8 +12,6 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
* @property int $id * @property int $id
* @property string $name * @property string $name
* @property string $value * @property string $value
* @property int $entity_id
* @property string $entity_type
* @property int $order * @property int $order
*/ */
class Tag extends Model class Tag extends Model

View File

@@ -5,7 +5,6 @@ namespace BookStack\Activity\Models;
use BookStack\Activity\WatchLevels; use BookStack\Activity\WatchLevels;
use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\Models\JointPermission;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
@@ -21,8 +20,6 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
*/ */
class Watch extends Model class Watch extends Model
{ {
use HasFactory;
protected $guarded = []; protected $guarded = [];
public function watchable(): MorphTo public function watchable(): MorphTo

View File

@@ -5,7 +5,6 @@ namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\Models\Loggable; use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Messages\BaseActivityNotification; use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Permissions\Permission;
use BookStack\Permissions\PermissionApplicator; use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -20,7 +19,6 @@ abstract class BaseNotificationHandler implements NotificationHandler
{ {
$users = User::query()->whereIn('id', array_unique($userIds))->get(); $users = User::query()->whereIn('id', array_unique($userIds))->get();
/** @var User $user */
foreach ($users as $user) { foreach ($users as $user) {
// Prevent sending to the user that initiated the activity // Prevent sending to the user that initiated the activity
if ($user->id === $initiator->id) { if ($user->id === $initiator->id) {
@@ -28,7 +26,7 @@ abstract class BaseNotificationHandler implements NotificationHandler
} }
// Prevent sending of the user does not have notification permissions // Prevent sending of the user does not have notification permissions
if (!$user->can(Permission::ReceiveNotifications)) { if (!$user->can('receive-notifications')) {
continue; continue;
} }

View File

@@ -27,7 +27,7 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
$watcherIds = $watchers->getWatcherUserIds(); $watcherIds = $watchers->getWatcherUserIds();
// Page owner if user preferences allow // Page owner if user preferences allow
if ($page->owned_by && !$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) { if (!$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
$userNotificationPrefs = new UserNotificationPreferences($page->ownedBy); $userNotificationPrefs = new UserNotificationPreferences($page->ownedBy);
if ($userNotificationPrefs->notifyOnOwnPageComments()) { if ($userNotificationPrefs->notifyOnOwnPageComments()) {
$watcherIds[] = $page->owned_by; $watcherIds[] = $page->owned_by;
@@ -36,7 +36,7 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
// Parent comment creator if preferences allow // Parent comment creator if preferences allow
$parentComment = $detail->parent()->first(); $parentComment = $detail->parent()->first();
if ($parentComment && $parentComment->created_by && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) { if ($parentComment && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
$parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy); $parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy);
if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) { if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) {
$watcherIds[] = $parentComment->created_by; $watcherIds[] = $parentComment->created_by;

View File

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

View File

@@ -20,8 +20,7 @@ class PageUpdateNotificationHandler extends BaseNotificationHandler
throw new \InvalidArgumentException("Detail for page update notifications must be a page"); throw new \InvalidArgumentException("Detail for page update notifications must be a page");
} }
// Get the last update from activity // Get last update from activity
/** @var ?Activity $lastUpdate */
$lastUpdate = $detail->activity() $lastUpdate = $detail->activity()
->where('type', '=', ActivityType::PAGE_UPDATE) ->where('type', '=', ActivityType::PAGE_UPDATE)
->where('id', '!=', $activity->id) ->where('id', '!=', $activity->id)
@@ -39,8 +38,8 @@ class PageUpdateNotificationHandler extends BaseNotificationHandler
$watchers = new EntityWatchers($detail, WatchLevels::UPDATES); $watchers = new EntityWatchers($detail, WatchLevels::UPDATES);
$watcherIds = $watchers->getWatcherUserIds(); $watcherIds = $watchers->getWatcherUserIds();
// Add the page owner if preferences allow // Add page owner if preferences allow
if ($detail->owned_by && !$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) { if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy); $userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
if ($userNotificationPrefs->notifyOnOwnPageChanges()) { if ($userNotificationPrefs->notifyOnOwnPageChanges()) {
$watcherIds[] = $detail->owned_by; $watcherIds[] = $detail->owned_by;

View File

@@ -24,7 +24,7 @@ class CommentCreationNotification extends BaseActivityNotification
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page), $locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable), $locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
$locale->trans('notifications.detail_commenter') => $this->user->name, $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) return $this->newMailMessage($locale)

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,6 @@ namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Comment; use BookStack\Activity\Models\Comment;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Permissions\Permission;
class CommentTree class CommentTree
{ {
@@ -13,11 +12,6 @@ class CommentTree
* @var CommentTreeNode[] * @var CommentTreeNode[]
*/ */
protected array $tree; protected array $tree;
/**
* A linear array of loaded comments.
* @var Comment[]
*/
protected array $comments; protected array $comments;
public function __construct( public function __construct(
@@ -44,7 +38,7 @@ class CommentTree
public function getActive(): array public function getActive(): array
{ {
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived)); return array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived);
} }
public function activeThreadCount(): int public function activeThreadCount(): int
@@ -54,7 +48,7 @@ class CommentTree
public function getArchived(): array public function getArchived(): array
{ {
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived)); return array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived);
} }
public function archivedThreadCount(): int public function archivedThreadCount(): int
@@ -76,7 +70,7 @@ class CommentTree
public function canUpdateAny(): bool public function canUpdateAny(): bool
{ {
foreach ($this->comments as $comment) { foreach ($this->comments as $comment) {
if (userCan(Permission::CommentUpdate, $comment)) { if (userCan('comment-update', $comment)) {
return true; return true;
} }
} }
@@ -84,14 +78,6 @@ class CommentTree
return false; return false;
} }
public function loadVisibleHtml(): void
{
foreach ($this->comments as $comment) {
$comment->setAttribute('html', $comment->safeHtml());
$comment->makeVisible('html');
}
}
/** /**
* @param Comment[] $comments * @param Comment[] $comments
* @return CommentTreeNode[] * @return CommentTreeNode[]
@@ -136,9 +122,6 @@ class CommentTree
return new CommentTreeNode($byId[$id], $depth, $children); return new CommentTreeNode($byId[$id], $depth, $children);
} }
/**
* @return Comment[]
*/
protected function loadComments(): array protected function loadComments(): array
{ {
if (!$this->enabled()) { if (!$this->enabled()) {

View File

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

View File

@@ -3,16 +3,17 @@
namespace BookStack\Activity\Tools; namespace BookStack\Activity\Tools;
use BookStack\Activity\Models\Tag; use BookStack\Activity\Models\Tag;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Permissions\Permission;
class TagClassGenerator class TagClassGenerator
{ {
public function __construct( protected array $tags;
protected Entity $entity
) { /**
* @param Tag[] $tags
*/
public function __construct(array $tags)
{
$this->tags = $tags;
} }
/** /**
@@ -21,23 +22,14 @@ class TagClassGenerator
public function generate(): array public function generate(): array
{ {
$classes = []; $classes = [];
$tags = $this->entity->tags->all();
foreach ($tags as $tag) { foreach ($this->tags as $tag) {
array_push($classes, ...$this->generateClassesForTag($tag)); $name = $this->normalizeTagClassString($tag->name);
} $value = $this->normalizeTagClassString($tag->value);
$classes[] = 'tag-name-' . $name;
if ($this->entity instanceof BookChild && userCan(Permission::BookView, $this->entity->book)) { if ($value) {
$bookTags = $this->entity->book->tags; $classes[] = 'tag-value-' . $value;
foreach ($bookTags as $bookTag) { $classes[] = 'tag-pair-' . $name . '-' . $value;
array_push($classes, ...$this->generateClassesForTag($bookTag, 'book-'));
}
}
if ($this->entity instanceof Page && $this->entity->chapter && userCan(Permission::ChapterView, $this->entity->chapter)) {
$chapterTags = $this->entity->chapter->tags;
foreach ($chapterTags as $chapterTag) {
array_push($classes, ...$this->generateClassesForTag($chapterTag, 'chapter-'));
} }
} }
@@ -49,22 +41,6 @@ class TagClassGenerator
return implode(' ', $this->generate()); return implode(' ', $this->generate());
} }
/**
* @return string[]
*/
protected function generateClassesForTag(Tag $tag, string $prefix = ''): array
{
$classes = [];
$name = $this->normalizeTagClassString($tag->name);
$value = $this->normalizeTagClassString($tag->value);
$classes[] = "{$prefix}tag-name-{$name}";
if ($value) {
$classes[] = "{$prefix}tag-value-{$value}";
$classes[] = "{$prefix}tag-pair-{$name}-{$value}";
}
return $classes;
}
protected function normalizeTagClassString(string $value): string protected function normalizeTagClassString(string $value): string
{ {
$value = str_replace(' ', '', strtolower($value)); $value = str_replace(' ', '', strtolower($value));

View File

@@ -7,7 +7,6 @@ use BookStack\Activity\WatchLevels;
use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Permissions\Permission;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -23,7 +22,7 @@ class UserEntityWatchOptions
public function canWatch(): bool public function canWatch(): bool
{ {
return $this->user->can(Permission::ReceiveNotifications) && !$this->user->isGuest(); return $this->user->can('receive-notifications') && !$this->user->isGuest();
} }
public function getWatchLevel(): string public function getWatchLevel(): string

View File

@@ -50,7 +50,7 @@ class WebhookFormatter
} }
if ($this->detail instanceof Model) { if ($this->detail instanceof Model) {
$data['related_item'] = $this->formatModel($this->detail); $data['related_item'] = $this->formatModel();
} }
return $data; return $data;
@@ -83,8 +83,10 @@ class WebhookFormatter
); );
} }
protected function formatModel(Model $model): array protected function formatModel(): array
{ {
/** @var Model $model */
$model = $this->detail;
$model->unsetRelations(); $model->unsetRelations();
foreach ($this->modelFormatters as $formatter) { foreach ($this->modelFormatters as $formatter) {

View File

@@ -36,7 +36,7 @@ class WatchLevels
/** /**
* Get all the possible values as an option_name => value array. * Get all the possible values as an option_name => value array.
* @return array<string, int> * @returns array<string, int>
*/ */
public static function all(): array public static function all(): array
{ {
@@ -50,7 +50,7 @@ class WatchLevels
/** /**
* Get the watch options suited for the given entity. * Get the watch options suited for the given entity.
* @return array<string, int> * @returns array<string, int>
*/ */
public static function allSuitedFor(Entity $entity): array public static function allSuitedFor(Entity $entity): array
{ {

View File

@@ -17,14 +17,7 @@ use ReflectionMethod;
class ApiDocsGenerator class ApiDocsGenerator
{ {
/**
* @var array<string, ReflectionClass>
*/
protected array $reflectionClasses = []; protected array $reflectionClasses = [];
/**
* @var array<string, ApiController>
*/
protected array $controllerClasses = []; protected array $controllerClasses = [];
/** /**
@@ -90,19 +83,11 @@ class ApiDocsGenerator
protected function loadDetailsFromControllers(Collection $routes): Collection protected function loadDetailsFromControllers(Collection $routes): Collection
{ {
return $routes->map(function (array $route) { return $routes->map(function (array $route) {
$class = $this->getReflectionClass($route['controller']);
$method = $this->getReflectionMethod($route['controller'], $route['controller_method']); $method = $this->getReflectionMethod($route['controller'], $route['controller_method']);
$comment = $method->getDocComment(); $comment = $method->getDocComment();
$route['description'] = $comment ? $this->parseDescriptionFromDocBlockComment($comment) : null; $route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null;
$route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']); $route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']);
// Load class description for the model
// Not ideal to have it here on each route, but adding it in a more structured manner would break
// docs resulting JSON format and therefore be an API break.
// Save refactoring for a more significant set of changes.
$classComment = $class->getDocComment();
$route['model_description'] = $classComment ? $this->parseDescriptionFromDocBlockComment($classComment) : null;
return $route; return $route;
}); });
} }
@@ -114,6 +99,7 @@ class ApiDocsGenerator
*/ */
protected function getBodyParamsFromClass(string $className, string $methodName): ?array protected function getBodyParamsFromClass(string $className, string $methodName): ?array
{ {
/** @var ApiController $class */
$class = $this->controllerClasses[$className] ?? null; $class = $this->controllerClasses[$className] ?? null;
if ($class === null) { if ($class === null) {
$class = app()->make($className); $class = app()->make($className);
@@ -154,12 +140,12 @@ class ApiDocsGenerator
/** /**
* Parse out the description text from a class method comment. * Parse out the description text from a class method comment.
*/ */
protected function parseDescriptionFromDocBlockComment(string $comment): string protected function parseDescriptionFromMethodComment(string $comment): string
{ {
$matches = []; $matches = [];
preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches); preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches);
$text = implode(' ', $matches[1]); $text = implode(' ', $matches[1] ?? []);
return str_replace(' ', "\n", $text); return str_replace(' ', "\n", $text);
} }
@@ -169,16 +155,6 @@ class ApiDocsGenerator
* @throws ReflectionException * @throws ReflectionException
*/ */
protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod
{
return $this->getReflectionClass($className)->getMethod($methodName);
}
/**
* Get a reflection class from the given class name.
*
* @throws ReflectionException
*/
protected function getReflectionClass(string $className): ReflectionClass
{ {
$class = $this->reflectionClasses[$className] ?? null; $class = $this->reflectionClasses[$className] ?? null;
if ($class === null) { if ($class === null) {
@@ -186,7 +162,7 @@ class ApiDocsGenerator
$this->reflectionClasses[$className] = $class; $this->reflectionClasses[$className] = $class;
} }
return $class; return $class->getMethod($methodName);
} }
/** /**
@@ -195,12 +171,11 @@ class ApiDocsGenerator
protected function getFlatApiRoutes(): Collection protected function getFlatApiRoutes(): Collection
{ {
return collect(Route::getRoutes()->getRoutes())->filter(function ($route) { return collect(Route::getRoutes()->getRoutes())->filter(function ($route) {
return str_starts_with($route->uri, 'api/'); return strpos($route->uri, 'api/') === 0;
})->map(function ($route) { })->map(function ($route) {
[$controller, $controllerMethod] = explode('@', $route->action['uses']); [$controller, $controllerMethod] = explode('@', $route->action['uses']);
$baseModelName = explode('.', explode('/', $route->uri)[1])[0]; $baseModelName = explode('.', explode('/', $route->uri)[1])[0];
$controllerMethodKebab = Str::kebab($controllerMethod); $shortName = $baseModelName . '-' . $controllerMethod;
$shortName = $baseModelName . '-' . $controllerMethodKebab;
return [ return [
'name' => $shortName, 'name' => $shortName,
@@ -208,7 +183,7 @@ class ApiDocsGenerator
'method' => $route->methods[0], 'method' => $route->methods[0],
'controller' => $controller, 'controller' => $controller,
'controller_method' => $controllerMethod, 'controller_method' => $controllerMethod,
'controller_method_kebab' => $controllerMethodKebab, 'controller_method_kebab' => Str::kebab($controllerMethod),
'base_model' => $baseModelName, 'base_model' => $baseModelName,
]; ];
}); });

View File

@@ -74,21 +74,18 @@ class ApiEntityListFormatter
/** /**
* Include parent book/chapter info in the formatted data. * 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 public function withParents(): self
{ {
$this->withField('book', function (Entity $entity) { $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 $entity->book->only(['id', 'name', 'slug']);
} }
return null; return null;
}); });
$this->withField('chapter', function (Entity $entity) { $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 $entity->chapter->only(['id', 'name', 'slug']);
} }
return null; return null;

View File

@@ -4,7 +4,6 @@ namespace BookStack\Api;
use BookStack\Access\LoginService; use BookStack\Access\LoginService;
use BookStack\Exceptions\ApiAuthException; use BookStack\Exceptions\ApiAuthException;
use BookStack\Permissions\Permission;
use Illuminate\Auth\GuardHelpers; use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard; use Illuminate\Contracts\Auth\Guard;
@@ -17,14 +16,29 @@ class ApiTokenGuard implements Guard
use GuardHelpers; 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, * @var LoginService
protected LoginService $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 +66,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 * @throws ApiAuthException
* *
@@ -106,7 +120,7 @@ class ApiTokenGuard implements Guard
throw new ApiAuthException(trans('errors.api_no_authorization_found')); 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')); throw new ApiAuthException(trans('errors.api_bad_authorization_format'));
} }
} }
@@ -132,7 +146,7 @@ class ApiTokenGuard implements Guard
throw new ApiAuthException(trans('errors.api_user_token_expired'), 403); throw new ApiAuthException(trans('errors.api_user_token_expired'), 403);
} }
if (!$token->user->can(Permission::AccessApi)) { if (!$token->user->can('access-api')) {
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403); throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
} }
} }
@@ -140,7 +154,7 @@ class ApiTokenGuard implements Guard
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
public function validate(array $credentials = []): bool public function validate(array $credentials = [])
{ {
if (empty($credentials['id']) || empty($credentials['secret'])) { if (empty($credentials['id']) || empty($credentials['secret'])) {
return false; return false;
@@ -160,7 +174,7 @@ class ApiTokenGuard implements Guard
/** /**
* "Log out" the currently authenticated user. * "Log out" the currently authenticated user.
*/ */
public function logout(): void public function logout()
{ {
$this->user = null; $this->user = null;
} }

View File

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

View File

@@ -4,7 +4,6 @@ namespace BookStack\Api;
use BookStack\Activity\ActivityType; use BookStack\Activity\ActivityType;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -17,8 +16,8 @@ class UserApiTokenController extends Controller
*/ */
public function create(Request $request, int $userId) public function create(Request $request, int $userId)
{ {
$this->checkPermission(Permission::AccessApi); $this->checkPermission('access-api');
$this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId); $this->checkPermissionOrCurrentUser('users-manage', $userId);
$this->updateContext($request); $this->updateContext($request);
$user = User::query()->findOrFail($userId); $user = User::query()->findOrFail($userId);
@@ -36,8 +35,8 @@ class UserApiTokenController extends Controller
*/ */
public function store(Request $request, int $userId) public function store(Request $request, int $userId)
{ {
$this->checkPermission(Permission::AccessApi); $this->checkPermission('access-api');
$this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId); $this->checkPermissionOrCurrentUser('users-manage', $userId);
$this->validate($request, [ $this->validate($request, [
'name' => ['required', 'max:250'], 'name' => ['required', 'max:250'],
@@ -48,11 +47,11 @@ class UserApiTokenController extends Controller
$secret = Str::random(32); $secret = Str::random(32);
$token = (new ApiToken())->forceFill([ $token = (new ApiToken())->forceFill([
'name' => $request->input('name'), 'name' => $request->get('name'),
'token_id' => Str::random(32), 'token_id' => Str::random(32),
'secret' => Hash::make($secret), 'secret' => Hash::make($secret),
'user_id' => $user->id, '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()) { while (ApiToken::query()->where('token_id', '=', $token->token_id)->exists()) {
@@ -100,8 +99,8 @@ class UserApiTokenController extends Controller
[$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);
$token->fill([ $token->fill([
'name' => $request->input('name'), 'name' => $request->get('name'),
'expires_at' => $request->input('expires_at') ?: ApiToken::defaultExpiry(), 'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(),
])->save(); ])->save();
$this->logActivity(ActivityType::API_TOKEN_UPDATE, $token); $this->logActivity(ActivityType::API_TOKEN_UPDATE, $token);
@@ -144,8 +143,8 @@ class UserApiTokenController extends Controller
*/ */
protected function checkPermissionAndFetchUserToken(int $userId, int $tokenId): array protected function checkPermissionAndFetchUserToken(int $userId, int $tokenId): array
{ {
$this->checkPermissionOr(Permission::UsersManage, function () use ($userId) { $this->checkPermissionOr('users-manage', function () use ($userId) {
return $userId === user()->id && userCan(Permission::AccessApi); return $userId === user()->id && userCan('access-api');
}); });
$user = User::query()->findOrFail($userId); $user = User::query()->findOrFail($userId);

View File

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

View File

@@ -8,7 +8,7 @@ class Model extends EloquentModel
{ {
/** /**
* Provides public access to get the raw attribute value from the model. * Provides public access to get the raw attribute value from the model.
* Used in areas where no mutations are required, but performance is critical. * Used in areas where no mutations are required but performance is critical.
* *
* @return mixed * @return mixed
*/ */

View File

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

View File

@@ -59,8 +59,8 @@ class AuthServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
Auth::provider('external-users', function () { Auth::provider('external-users', function ($app, array $config) {
return new ExternalBaseUserProvider(); return new ExternalBaseUserProvider($config['model']);
}); });
// Bind and provide the default system user as a singleton to the app instance when needed. // Bind and provide the default system user as a singleton to the app instance when needed.

View File

@@ -15,7 +15,7 @@ class EventServiceProvider extends ServiceProvider
/** /**
* The event listener mappings for the application. * The event listener mappings for the application.
* *
* @var array<class-string, array<int, string>> * @var array<class-string, array<int, class-string>>
*/ */
protected $listen = [ protected $listen = [
SocialiteWasCalled::class => [ SocialiteWasCalled::class => [

View File

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

View File

@@ -3,7 +3,6 @@
namespace BookStack\App\Providers; namespace BookStack\App\Providers;
use BookStack\Entities\BreadcrumbsViewComposer; use BookStack\Entities\BreadcrumbsViewComposer;
use BookStack\Util\DateFormatter;
use Illuminate\Pagination\Paginator; use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
@@ -11,15 +10,6 @@ use Illuminate\Support\ServiceProvider;
class ViewTweaksServiceProvider extends ServiceProvider class ViewTweaksServiceProvider extends ServiceProvider
{ {
public function register()
{
$this->app->singleton(DateFormatter::class, function ($app) {
return new DateFormatter(
$app['config']->get('app.display_timezone'),
);
});
}
/** /**
* Bootstrap services. * Bootstrap services.
*/ */
@@ -31,9 +21,6 @@ class ViewTweaksServiceProvider extends ServiceProvider
// View Composers // View Composers
View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class); View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class);
// View Globals
View::share('dates', $this->app->make(DateFormatter::class));
// Custom blade view directives // Custom blade view directives
Blade::directive('icon', function ($expression) { Blade::directive('icon', function ($expression) {
return "<?php echo (new \BookStack\Util\SvgIcon($expression))->toHtml(); ?>"; return "<?php echo (new \BookStack\Util\SvgIcon($expression))->toHtml(); ?>";

18
app/App/Sluggable.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
namespace BookStack\App;
/**
* Assigned to models that can have slugs.
* Must have the below properties.
*
* @property int $id
* @property string $name
*/
interface Sluggable
{
/**
* Regenerate the slug for this model.
*/
public function refreshSlug(): string;
}

View File

@@ -1,13 +0,0 @@
<?php
namespace BookStack\App;
/**
* Assigned to models that can have slugs.
* Must have the below properties.
*
* @property string $slug
*/
interface SluggableInterface
{
}

View File

@@ -3,7 +3,6 @@
use BookStack\App\AppVersion; use BookStack\App\AppVersion;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\Facades\Theme; use BookStack\Facades\Theme;
use BookStack\Permissions\Permission;
use BookStack\Permissions\PermissionApplicator; use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\SettingService; use BookStack\Settings\SettingService;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
@@ -40,7 +39,7 @@ function user(): User
* Check if the current user has a permission. If an ownable element * Check if the current user has a permission. If an ownable element
* is passed in the jointPermissions are checked against that particular item. * is passed in the jointPermissions are checked against that particular item.
*/ */
function userCan(string|Permission $permission, ?Model $ownable = null): bool function userCan(string $permission, ?Model $ownable = null): bool
{ {
if (is_null($ownable)) { if (is_null($ownable)) {
return user()->can($permission); return user()->can($permission);
@@ -56,7 +55,7 @@ function userCan(string|Permission $permission, ?Model $ownable = null): bool
* Check if the current user can perform the given action on any items in the system. * Check if the current user can perform the given action on any items in the system.
* Can be provided the class name of an entity to filter ability to that specific entity type. * Can be provided the class name of an entity to filter ability to that specific entity type.
*/ */
function userCanOnAny(string|Permission $action, string $entityClass = ''): bool function userCanOnAny(string $action, string $entityClass = ''): bool
{ {
$permissions = app()->make(PermissionApplicator::class); $permissions = app()->make(PermissionApplicator::class);
@@ -81,7 +80,8 @@ function setting(?string $key = null, mixed $default = null): mixed
/** /**
* Get a path to a theme resource. * Get a path to a theme resource.
* Returns null if a theme is not configured, and therefore a full path is not available for use. * Returns null if a theme is not configured and
* therefore a full path is not available for use.
*/ */
function theme_path(string $path = ''): ?string function theme_path(string $path = ''): ?string
{ {

View File

@@ -37,15 +37,10 @@ return [
// The limit for all uploaded files, including images and attachments in MB. // The limit for all uploaded files, including images and attachments in MB.
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50), 'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
// Control the behaviour of content filtering, primarily used for page content. // Allow <script> tags to entered within page content.
// This setting is a string of characters which represent different available filters: // <script> tags are escaped by default.
// - j - Filter out JavaScript and unknown binary data based content // Even when overridden the WYSIWYG editor may still escape script content.
// - h - Filter out unexpected, and potentially dangerous, HTML elements 'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false),
// - f - Filter out unexpected form elements
// - a - Run content through a more complex allowlist filter
// This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
// Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
'content_filtering' => env('APP_CONTENT_FILTERING', env('ALLOW_CONTENT_SCRIPTS', false) === true ? '' : 'jhfa'),
// Allow server-side fetches to be performed to potentially unknown // Allow server-side fetches to be performed to potentially unknown
// and user-provided locations. Primarily used in exports when loading // and user-provided locations. Primarily used in exports when loading
@@ -53,8 +48,8 @@ return [
'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false), 'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
// Override the default behaviour for allowing crawlers to crawl the instance. // Override the default behaviour for allowing crawlers to crawl the instance.
// May be ignored if the underlying view has been overridden or modified. // May be ignored if view has be overridden or modified.
// Defaults to null in which case the 'app-public' status is used instead. // Defaults to null since, if not set, 'app-public' status used instead.
'allow_robots' => env('ALLOW_ROBOTS', null), 'allow_robots' => env('ALLOW_ROBOTS', null),
// Application Base URL, Used by laravel in development commands // Application Base URL, Used by laravel in development commands
@@ -75,8 +70,8 @@ return [
// A list of the sources/hostnames that can be reached by application SSR calls. // A list of the sources/hostnames that can be reached by application SSR calls.
// This is used wherever users can provide URLs/hosts in-platform, like for webhooks. // This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
// Host-specific functionality (usually controlled via other options) like auth // Host-specific functionality (usually controlled via other options) like auth
// or user avatars, for example, won't use this list. // or user avatars for example, won't use this list.
// Space separated if multiple. Can use '*' as a wildcard. // Space seperated if multiple. Can use '*' as a wildcard.
// Values will be compared prefix-matched, case-insensitive, against called SSR urls. // Values will be compared prefix-matched, case-insensitive, against called SSR urls.
// Defaults to allow all hosts. // Defaults to allow all hosts.
'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'), 'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'),
@@ -85,10 +80,8 @@ return [
// Integer value between 0 (IP hidden) to 4 (Full IP usage) // Integer value between 0 (IP hidden) to 4 (Full IP usage)
'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4), 'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),
// Application timezone for stored date/time values. // Application timezone for back-end date functions.
'timezone' => env('APP_TIMEZONE', 'UTC'), 'timezone' => env('APP_TIMEZONE', 'UTC'),
// Application timezone for displayed date/time values in the UI.
'display_timezone' => env('APP_DISPLAY_TIMEZONE', env('APP_TIMEZONE', 'UTC')),
// Default locale to use // Default locale to use
// A default variant is also stored since Laravel can overwrite // A default variant is also stored since Laravel can overwrite

View File

@@ -85,6 +85,6 @@ return [
| |
*/ */
'prefix' => env('CACHE_PREFIX', 'bookstack_cache_'), 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache_'),
]; ];

View File

@@ -75,14 +75,13 @@ return [
'collation' => 'utf8mb4_unicode_ci', 'collation' => 'utf8mb4_unicode_ci',
// Prefixes are only semi-supported and may be unstable // Prefixes are only semi-supported and may be unstable
// since they are not tested as part of our automated test suite. // since they are not tested as part of our automated test suite.
// If used, the prefix should not be changed; otherwise you will likely receive errors. // If used, the prefix should not be changed otherwise you will likely receive errors.
'prefix' => env('DB_TABLE_PREFIX', ''), 'prefix' => env('DB_TABLE_PREFIX', ''),
'prefix_indexes' => true, 'prefix_indexes' => true,
'strict' => false, 'strict' => false,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([ 'options' => extension_loaded('pdo_mysql') ? array_filter([
// @phpstan-ignore class.notFound PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [], ]) : [],
], ],
@@ -104,7 +103,9 @@ return [
], ],
// Migration Repository Table // Migration Repository Table
// This table keeps track of all the migrations that have already run for the application. // This table keeps track of all the migrations that have already run for
// your application. Using this information, we can determine which of
// the migrations on disk haven't actually been run in the database.
'migrations' => 'migrations', 'migrations' => 'migrations',
// Redis configuration to use if set // Redis configuration to use if set

View File

@@ -11,7 +11,7 @@
return [ return [
// Default Filesystem Disk // Default Filesystem Disk
// Options: local, local_secure, local_secure_restricted, s3 // Options: local, local_secure, s3
'default' => env('STORAGE_TYPE', 'local'), 'default' => env('STORAGE_TYPE', 'local'),
// Filesystem to use specifically for image uploads. // Filesystem to use specifically for image uploads.

View File

@@ -11,7 +11,6 @@
// Configured mail encryption method. // Configured mail encryption method.
// STARTTLS should still be attempted, but tls/ssl forces TLS usage. // STARTTLS should still be attempted, but tls/ssl forces TLS usage.
$mailEncryption = env('MAIL_ENCRYPTION', null); $mailEncryption = env('MAIL_ENCRYPTION', null);
$mailPort = intval(env('MAIL_PORT', 587));
return [ return [
@@ -34,13 +33,13 @@ return [
'transport' => 'smtp', 'transport' => 'smtp',
'scheme' => null, 'scheme' => null,
'host' => env('MAIL_HOST', 'smtp.mailgun.org'), 'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
'port' => $mailPort, 'port' => env('MAIL_PORT', 587),
'username' => env('MAIL_USERNAME'), 'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'), 'password' => env('MAIL_PASSWORD'),
'verify_peer' => env('MAIL_VERIFY_SSL', true), 'verify_peer' => env('MAIL_VERIFY_SSL', true),
'timeout' => null, 'timeout' => null,
'local_domain' => null, 'local_domain' => null,
'require_tls' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl' || $mailPort === 465), 'tls_required' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl'),
], ],
'sendmail' => [ 'sendmail' => [

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\Rules\Unique;
class CreateAdminCommand extends Command class CreateAdminCommand extends Command
{ {
@@ -20,9 +21,7 @@ class CreateAdminCommand extends Command
{--email= : The email address for the new admin user} {--email= : The email address for the new admin user}
{--name= : The name of the new admin user} {--name= : The name of the new admin user}
{--password= : The password to assign to the new admin user} {--password= : The password to assign to the new admin user}
{--external-auth-id= : The external authentication system id for the new admin user (SAML2/LDAP/OIDC)} {--external-auth-id= : The external authentication system id for the new admin user (SAML2/LDAP/OIDC)}';
{--generate-password : Generate a random password for the new admin user}
{--initial : Indicate if this should set/update the details of the initial admin user}';
/** /**
* The console command description. * The console command description.
@@ -36,12 +35,26 @@ class CreateAdminCommand extends Command
*/ */
public function handle(UserRepo $userRepo): int public function handle(UserRepo $userRepo): int
{ {
$initialAdminOnly = $this->option('initial'); $details = $this->snakeCaseOptions();
$shouldGeneratePassword = $this->option('generate-password');
$details = $this->gatherDetails($shouldGeneratePassword, $initialAdminOnly); if (empty($details['email'])) {
$details['email'] = $this->ask('Please specify an email address for the new admin user');
}
if (empty($details['name'])) {
$details['name'] = $this->ask('Please specify a name for the new admin user');
}
if (empty($details['password'])) {
if (empty($details['external_auth_id'])) {
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
} else {
$details['password'] = Str::random(32);
}
}
$validator = Validator::make($details, [ $validator = Validator::make($details, [
'email' => ['required', 'email', 'min:5'], 'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
'name' => ['required', 'min:2'], 'name' => ['required', 'min:2'],
'password' => ['required_without:external_auth_id', Password::default()], 'password' => ['required_without:external_auth_id', Password::default()],
'external_auth_id' => ['required_without:password'], 'external_auth_id' => ['required_without:password'],
@@ -55,101 +68,16 @@ class CreateAdminCommand extends Command
return 1; return 1;
} }
$adminRole = Role::getSystemRole('admin');
if ($initialAdminOnly) {
$handled = $this->handleInitialAdminIfExists($userRepo, $details, $shouldGeneratePassword, $adminRole);
if ($handled !== null) {
return $handled;
}
}
$emailUsed = $userRepo->getByEmail($details['email']) !== null;
if ($emailUsed) {
$this->error("Could not create admin account.");
$this->error("An account with the email address \"{$details['email']}\" already exists.");
return 1;
}
$user = $userRepo->createWithoutActivity($validator->validated()); $user = $userRepo->createWithoutActivity($validator->validated());
$user->attachRole($adminRole); $user->attachRole(Role::getSystemRole('admin'));
$user->email_confirmed = true; $user->email_confirmed = true;
$user->save(); $user->save();
if ($shouldGeneratePassword) { $this->info("Admin account with email \"{$user->email}\" successfully created!");
$this->line($details['password']);
} else {
$this->info("Admin account with email \"{$user->email}\" successfully created!");
}
return 0; return 0;
} }
/**
* Handle updates to the original admin account if it exists.
* Returns an int return status if handled, otherwise returns null if not handled (new user to be created).
*/
protected function handleInitialAdminIfExists(UserRepo $userRepo, array $data, bool $generatePassword, Role $adminRole): int|null
{
$defaultAdmin = $userRepo->getByEmail('admin@admin.com');
if ($defaultAdmin && $defaultAdmin->hasSystemRole('admin')) {
if ($defaultAdmin->email !== $data['email'] && $userRepo->getByEmail($data['email']) !== null) {
$this->error("Could not create admin account.");
$this->error("An account with the email address \"{$data['email']}\" already exists.");
return 1;
}
$userRepo->updateWithoutActivity($defaultAdmin, $data, true);
if ($generatePassword) {
$this->line($data['password']);
} else {
$this->info("The default admin user has been updated with the provided details!");
}
return 0;
} else if ($adminRole->users()->count() > 0) {
$this->warn('Non-default admin user already exists. Skipping creation of new admin user.');
return 2;
}
return null;
}
protected function gatherDetails(bool $generatePassword, bool $initialAdmin): array
{
$details = $this->snakeCaseOptions();
if (empty($details['email'])) {
if ($initialAdmin) {
$details['email'] = 'admin@example.com';
} else {
$details['email'] = $this->ask('Please specify an email address for the new admin user');
}
}
if (empty($details['name'])) {
if ($initialAdmin) {
$details['name'] = 'Admin';
} else {
$details['name'] = $this->ask('Please specify a name for the new admin user');
}
}
if (empty($details['password'])) {
if (empty($details['external_auth_id'])) {
if ($generatePassword) {
$details['password'] = Str::random(32);
} else {
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
}
} else {
$details['password'] = Str::random(32);
}
}
return $details;
}
protected function snakeCaseOptions(): array protected function snakeCaseOptions(): array
{ {
$returnOpts = []; $returnOpts = [];

View File

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

View File

@@ -45,12 +45,14 @@ class UpdateUrlCommand extends Command
$columnsToUpdateByTable = [ $columnsToUpdateByTable = [
'attachments' => ['path'], 'attachments' => ['path'],
'entity_page_data' => ['html', 'text', 'markdown'], 'pages' => ['html', 'text', 'markdown'],
'entity_container_data' => ['description_html'], 'chapters' => ['description_html'],
'books' => ['description_html'],
'bookshelves' => ['description_html'],
'page_revisions' => ['html', 'text', 'markdown'], 'page_revisions' => ['html', 'text', 'markdown'],
'images' => ['url'], 'images' => ['url'],
'settings' => ['value'], 'settings' => ['value'],
'comments' => ['html'], 'comments' => ['html', 'text'],
]; ];
foreach ($columnsToUpdateByTable as $table => $columns) { foreach ($columnsToUpdateByTable as $table => $columns) {

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