Merge branch 'development' into release

This commit is contained in:
Dan Brown 2023-10-30 12:14:23 +00:00
commit 5b45eac5e1
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
536 changed files with 13670 additions and 5132 deletions

View File

@ -72,7 +72,7 @@ MYSQL_ATTR_SSL_CA="/path/to/ca.pem"
# Mail configuration # Mail configuration
# Refer to https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration # Refer to https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration
MAIL_DRIVER=smtp MAIL_DRIVER=smtp
MAIL_FROM=mail@bookstackapp.com MAIL_FROM=bookstack@example.com
MAIL_FROM_NAME=BookStack MAIL_FROM_NAME=BookStack
MAIL_HOST=localhost MAIL_HOST=localhost

View File

@ -1,7 +1,14 @@
name: Bug Report name: Bug Report
description: Create a report to help us improve or fix things description: Create a report to help us fix bugs & issues in existing supported functionality
labels: [":bug: Bug"] labels: [":bug: Bug"]
body: body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out a bug report!
Please note that this form is for reporting bugs in existing supported functionality.
If you are reporting something that's not an issue in functionality we've previously supported and/or is simply something different to your expectations, then it may be more appropriate to raise via a feature or support request instead.
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
@ -13,7 +20,7 @@ body:
id: reproduction id: reproduction
attributes: attributes:
label: Steps to Reproduce label: Steps to Reproduce
description: Detail the steps that would replicate this issue description: Detail the steps that would replicate this issue.
placeholder: | placeholder: |
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
@ -32,7 +39,7 @@ body:
id: context id: context
attributes: attributes:
label: Screenshots or Additional Context label: Screenshots or Additional Context
description: Provide any additional context and screenshots here to help us solve this issue description: Provide any additional context and screenshots here to help us solve this issue.
validations: validations:
required: false required: false
- type: input - type: input
@ -48,23 +55,7 @@ body:
id: bsversion id: bsversion
attributes: attributes:
label: Exact BookStack Version label: Exact BookStack Version
description: This can be found in the settings view of BookStack. Please provide an exact version. description: This can be found in the settings view of BookStack. Please provide an exact version(s) you've tested on.
placeholder: (eg. v21.08.5) placeholder: (eg. v23.06.7)
validations:
required: true
- type: input
id: phpversion
attributes:
label: PHP Version
description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that relevant to the issue.
placeholder: (eg. 7.4)
validations:
required: false
- type: textarea
id: hosting
attributes:
label: Hosting Environment
description: Describe your hosting environment as much as possible including any proxies used (If applicable).
placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script)
validations: validations:
required: true required: true

View File

@ -33,9 +33,9 @@ body:
attributes: attributes:
label: Have you searched for an existing open/closed issue? label: Have you searched for an existing open/closed issue?
description: | description: |
To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue) for any existing issues that cover the fundemental benefit/goal of your request. To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue) for any existing issues that cover the fundamental benefit/goal of your request.
options: options:
- label: I have searched for existing issues and none cover my fundemental request - label: I have searched for existing issues and none cover my fundamental request
required: true required: true
- type: dropdown - type: dropdown
id: existing_usage id: existing_usage
@ -43,8 +43,8 @@ body:
label: How long have you been using BookStack? label: How long have you been using BookStack?
options: options:
- Not using yet, just scoping - Not using yet, just scoping
- 0 to 6 months - Under 3 months
- 6 months to 1 year - 3 months to 1 year
- 1 to 5 years - 1 to 5 years
- Over 5 years - Over 5 years
validations: validations:

View File

@ -33,7 +33,7 @@ body:
attributes: attributes:
label: Exact BookStack Version label: Exact BookStack Version
description: This can be found in the settings view of BookStack. Please provide an exact version. description: This can be found in the settings view of BookStack. Please provide an exact version.
placeholder: (eg. v21.08.5) placeholder: (eg. v23.06.7)
validations: validations:
required: true required: true
- type: textarea - type: textarea
@ -44,19 +44,11 @@ body:
placeholder: Be sure to remove any confidential details in your logs placeholder: Be sure to remove any confidential details in your logs
validations: validations:
required: false required: false
- type: input
id: phpversion
attributes:
label: PHP Version
description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that most relevant to the issue.
placeholder: (eg. 7.4)
validations:
required: false
- type: textarea - type: textarea
id: hosting id: hosting
attributes: attributes:
label: Hosting Environment label: Hosting Environment
description: Describe your hosting environment as much as possible including any proxies used (If applicable). description: Describe your hosting environment as much as possible including any proxies used (If applicable).
placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script) placeholder: (eg. PHP8.1 on Ubuntu 22.04 VPS, installed using official installation script)
validations: validations:
required: true required: true

15
.github/SECURITY.md vendored
View File

@ -15,18 +15,13 @@ If you'd like to be notified of new potential security concerns you can [sign-up
If you've found an issue that likely has no impact to existing users (For example, in a development-only branch) If you've found an issue that likely has no impact to existing users (For example, in a development-only branch)
feel free to raise it via a standard GitHub bug report issue. feel free to raise it via a standard GitHub bug report issue.
If the issue could have a security impact to BookStack instances, please use one of the below If the issue could have a security impact to BookStack instances,
methods to report the vulnerability: please directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown).
You will need to log in to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown).
- Directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown). Alternatively you can send a DM via Mastodon to [@danb@fosstodon.org](https://fosstodon.org/@danb).
- You will need to login to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown).
- Alternatively you can send a DM via Twitter to [@ssddanbrown](https://twitter.com/ssddanbrown).
- [Disclose via huntr.dev](https://huntr.dev/bounties/disclose)
- Bounties may be available to you through this platform.
- Be sure to use `https://github.com/BookStackApp/BookStack` as the repository URL.
Please be patient while the vulnerability is being reviewed. Deploying the fix to address the vulnerability Please be patient while the vulnerability is being reviewed. Deploying the fix to address the vulnerability
can often take a little time due to the amount of preparation required, to ensure the vulnerability has can often take a little time due to the amount of preparation required, to ensure the vulnerability has
been covered, and to create the content required to adequately notify the user-base. been covered, and to create the content required to adequately notify the user-base.
Thank you for keeping BookStack instances safe! Thank you for keeping BookStack instances safe!

View File

@ -57,6 +57,7 @@ Name :: Languages
@Jokuna :: Korean @Jokuna :: Korean
@smartshogu :: German; German Informal @smartshogu :: German; German Informal
@samadha56 :: Persian @samadha56 :: Persian
@mrmuminov :: Uzbek
cipi1965 :: Italian cipi1965 :: Italian
Mykola Ronik (Mantikor) :: Ukrainian Mykola Ronik (Mantikor) :: Ukrainian
furkanoyk :: Turkish furkanoyk :: Turkish
@ -176,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; Dutch; Turkish REMOVED_USER :: French; Dutch; 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
@ -269,7 +270,7 @@ mcgong (GongMingCai) :: Chinese Simplified; Chinese Traditional
Nanang Setia Budi (sefidananang) :: Indonesian Nanang Setia Budi (sefidananang) :: Indonesian
Андрей Павлов (andrei.pavlov) :: Russian Андрей Павлов (andrei.pavlov) :: Russian
Alex Navarro (alex.n.navarro) :: Portuguese, Brazilian Alex Navarro (alex.n.navarro) :: Portuguese, Brazilian
Ji-Hyeon Gim (PotatoGim) :: Korean Jihyeon Gim (PotatoGim) :: Korean
Mihai Ochian (soulstorm19) :: Romanian Mihai Ochian (soulstorm19) :: Romanian
HeartCore :: German Informal; German HeartCore :: German Informal; German
simon.pct :: French simon.pct :: French
@ -289,7 +290,7 @@ Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
LiZerui (CNLiZerui) :: Chinese Traditional LiZerui (CNLiZerui) :: Chinese Traditional
Fabrice Boyer (FabriceBoyer) :: French Fabrice Boyer (FabriceBoyer) :: French
mikael (bitcanon) :: Swedish mikael (bitcanon) :: Swedish
Matthias Mai (schnapsidee) :: German; German Informal Matthias Mai (schnapsidee) :: German Informal; German
Ufuk Ayyıldız (ufukayyildiz) :: Turkish Ufuk Ayyıldız (ufukayyildiz) :: Turkish
Jan Mitrof (jan.kachlik) :: Czech Jan Mitrof (jan.kachlik) :: Czech
edwardsmirnov :: Russian edwardsmirnov :: Russian
@ -347,7 +348,7 @@ robing29 :: German
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
Igor V Belousov (biv) :: Russian Igor V Belousov (biv) :: Russian
David Bauer (davbauer) :: German David Bauer (davbauer) :: German
Guttorm Hveem (guttormhveem) :: Norwegian Bokmal Guttorm Hveem (guttormhveem) :: Norwegian Bokmal; Norwegian Nynorsk
Minh Giang Truong (minhgiang1204) :: Vietnamese Minh Giang Truong (minhgiang1204) :: Vietnamese
Ioannis Ioannides (i.ioannides) :: Greek Ioannis Ioannides (i.ioannides) :: Greek
Vadim (vadrozh) :: Russian Vadim (vadrozh) :: Russian
@ -357,3 +358,12 @@ Dženan (Dzenan) :: Swedish
Péter Péli (peter.peli) :: Hungarian Péter Péli (peter.peli) :: Hungarian
TWME :: Chinese Traditional TWME :: Chinese Traditional
Sascha (Man-in-Black) :: German Sascha (Man-in-Black) :: German
Mohammadreza Madadi (madadi.efl) :: Persian
Konstantin Kovacheli (kkovacheli) :: Ukrainian
link1183 :: French
Renan (rfpe) :: Portuguese, Brazilian
Lowkey (bbsweb) :: Chinese Simplified
ZZnOB (zznobzz) :: Russian
rupus :: Swedish
developernecsys :: Norwegian Nynorsk
xuan LI (xuanli233) :: Chinese Simplified

View File

@ -1,6 +1,12 @@
name: analyse-php name: analyse-php
on: [push, pull_request] on:
push:
paths:
- '**.php'
pull_request:
paths:
- '**.php'
jobs: jobs:
build: build:

View File

@ -1,6 +1,14 @@
name: lint-js name: lint-js
on: [push, pull_request] on:
push:
paths:
- '**.js'
- '**.json'
pull_request:
paths:
- '**.js'
- '**.json'
jobs: jobs:
build: build:

View File

@ -1,6 +1,12 @@
name: lint-php name: lint-php
on: [push, pull_request] on:
push:
paths:
- '**.php'
pull_request:
paths:
- '**.php'
jobs: jobs:
build: build:

View File

@ -1,6 +1,14 @@
name: test-migrations name: test-migrations
on: [push, pull_request] on:
push:
paths:
- '**.php'
- 'composer.*'
pull_request:
paths:
- '**.php'
- 'composer.*'
jobs: jobs:
build: build:

View File

@ -1,6 +1,14 @@
name: test-php name: test-php
on: [push, pull_request] on:
push:
paths:
- '**.php'
- 'composer.*'
pull_request:
paths:
- '**.php'
- 'composer.*'
jobs: jobs:
build: build:

View File

@ -16,22 +16,12 @@ use Laravel\Socialite\Contracts\User as SocialUser;
class SocialController extends Controller class SocialController extends Controller
{ {
protected SocialAuthService $socialAuthService;
protected RegistrationService $registrationService;
protected LoginService $loginService;
/**
* SocialController constructor.
*/
public function __construct( public function __construct(
SocialAuthService $socialAuthService, protected SocialAuthService $socialAuthService,
RegistrationService $registrationService, protected RegistrationService $registrationService,
LoginService $loginService protected LoginService $loginService,
) { ) {
$this->middleware('guest')->only(['register']); $this->middleware('guest')->only(['register']);
$this->socialAuthService = $socialAuthService;
$this->registrationService = $registrationService;
$this->loginService = $loginService;
} }
/** /**
@ -112,7 +102,7 @@ class SocialController extends Controller
$this->socialAuthService->detachSocialAccount($socialDriver); $this->socialAuthService->detachSocialAccount($socialDriver);
session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => Str::title($socialDriver)])); session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => Str::title($socialDriver)]));
return redirect(user()->getEditUrl()); return redirect('/my-account/auth#social-accounts');
} }
/** /**

View File

@ -71,7 +71,7 @@ trait ThrottlesLogins
*/ */
protected function limiter(): RateLimiter protected function limiter(): RateLimiter
{ {
return app(RateLimiter::class); return app()->make(RateLimiter::class);
} }
/** /**

View File

@ -2,8 +2,8 @@
namespace BookStack\Access; namespace BookStack\Access;
use BookStack\Access\Notifications\ConfirmEmailNotification;
use BookStack\Exceptions\ConfirmationEmailException; use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Notifications\ConfirmEmail;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
class EmailConfirmationService extends UserTokenService class EmailConfirmationService extends UserTokenService
@ -26,7 +26,7 @@ class EmailConfirmationService extends UserTokenService
$this->deleteByUser($user); $this->deleteByUser($user);
$token = $this->createTokenForUser($user); $token = $this->createTokenForUser($user);
$user->notify(new ConfirmEmail($token)); $user->notify(new ConfirmEmailNotification($token));
} }
/** /**

View File

@ -1,11 +1,12 @@
<?php <?php
namespace BookStack\Notifications; namespace BookStack\Access\Notifications;
use BookStack\App\MailNotification;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
class ConfirmEmail extends MailNotification class ConfirmEmailNotification extends MailNotification
{ {
public function __construct( public function __construct(
public string $token public string $token

View File

@ -1,11 +1,12 @@
<?php <?php
namespace BookStack\Notifications; namespace BookStack\Access\Notifications;
use BookStack\App\MailNotification;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
class ResetPassword extends MailNotification class ResetPasswordNotification extends MailNotification
{ {
public function __construct( public function __construct(
public string $token public string $token

View File

@ -0,0 +1,27 @@
<?php
namespace BookStack\Access\Notifications;
use BookStack\App\MailNotification;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class UserInviteNotification extends MailNotification
{
public function __construct(
public string $token
) {
}
public function toMail(User $notifiable): MailMessage
{
$appName = ['appName' => setting('app-name')];
$locale = $notifiable->getLocale();
return $this->newMailMessage($locale)
->subject($locale->trans('auth.user_invite_email_subject', $appName))
->greeting($locale->trans('auth.user_invite_email_greeting', $appName))
->line($locale->trans('auth.user_invite_email_text'))
->action($locale->trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token));
}
}

View File

@ -20,15 +20,8 @@ class OidcOAuthProvider extends AbstractProvider
{ {
use BearerAuthorizationTrait; use BearerAuthorizationTrait;
/** protected string $authorizationEndpoint;
* @var string protected string $tokenEndpoint;
*/
protected $authorizationEndpoint;
/**
* @var string
*/
protected $tokenEndpoint;
/** /**
* Scopes to use for the OIDC authorization call. * Scopes to use for the OIDC authorization call.
@ -60,7 +53,7 @@ class OidcOAuthProvider extends AbstractProvider
} }
/** /**
* Add an additional scope to this provider upon the default. * Add another scope to this provider upon the default.
*/ */
public function addScope(string $scope): void public function addScope(string $scope): void
{ {

View File

@ -59,7 +59,7 @@ class OidcProviderSettings
} }
} }
if (strpos($this->issuer, 'https://') !== 0) { if (!str_starts_with($this->issuer, 'https://')) {
throw new InvalidArgumentException('Issuer value must start with https://'); throw new InvalidArgumentException('Issuer value must start with https://');
} }
} }

View File

@ -9,13 +9,13 @@ use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\StoppedAuthenticationException; use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException; use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Theme; use BookStack\Facades\Theme;
use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider; use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException; use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Psr\Http\Client\ClientInterface as HttpClient;
/** /**
* Class OpenIdConnectService * Class OpenIdConnectService
@ -26,7 +26,7 @@ class OidcService
public function __construct( public function __construct(
protected RegistrationService $registrationService, protected RegistrationService $registrationService,
protected LoginService $loginService, protected LoginService $loginService,
protected HttpClient $httpClient, protected HttpRequestService $http,
protected GroupSyncService $groupService protected GroupSyncService $groupService
) { ) {
} }
@ -94,7 +94,7 @@ class OidcService
// Run discovery // Run discovery
if ($config['discover'] ?? false) { if ($config['discover'] ?? false) {
try { try {
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15); $settings->discoverFromIssuer($this->http->buildClient(5), Cache::store(null), 15);
} catch (OidcIssuerDiscoveryException $exception) { } catch (OidcIssuerDiscoveryException $exception) {
throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage()); throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
} }
@ -111,7 +111,7 @@ class OidcService
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
{ {
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [ $provider = new OidcOAuthProvider($settings->arrayForProvider(), [
'httpClient' => $this->httpClient, 'httpClient' => $this->http->buildClient(5),
'optionProvider' => new HttpBasicAuthOptionProvider(), 'optionProvider' => new HttpBasicAuthOptionProvider(),
]); ]);
@ -142,10 +142,11 @@ class OidcService
*/ */
protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
{ {
$displayNameAttr = $this->config()['display_name_claims']; $displayNameAttrString = $this->config()['display_name_claims'] ?? '';
$displayNameAttrs = explode('|', $displayNameAttrString);
$displayName = []; $displayName = [];
foreach ($displayNameAttr as $dnAttr) { foreach ($displayNameAttrs as $dnAttr) {
$dnComponent = $token->getClaim($dnAttr) ?? ''; $dnComponent = $token->getClaim($dnAttr) ?? '';
if ($dnComponent !== '') { if ($dnComponent !== '') {
$displayName[] = $dnComponent; $displayName[] = $dnComponent;

View File

@ -154,21 +154,21 @@ class SocialAuthService
$currentUser->socialAccounts()->save($account); $currentUser->socialAccounts()->save($account);
session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver])); session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver]));
return redirect($currentUser->getEditUrl()); return redirect('/my-account/auth#social_accounts');
} }
// 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 !== null && $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($currentUser->getEditUrl()); 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 !== null && $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($currentUser->getEditUrl()); return redirect('/my-account/auth#social_accounts');
} }
// Otherwise let the user know this social account is not used by anyone. // Otherwise let the user know this social account is not used by anyone.
@ -214,6 +214,7 @@ class SocialAuthService
/** /**
* Gets the names of the active social drivers. * Gets the names of the active social drivers.
* @returns array<string, string>
*/ */
public function getActiveDrivers(): array public function getActiveDrivers(): array
{ {

View File

@ -2,7 +2,7 @@
namespace BookStack\Access; namespace BookStack\Access;
use BookStack\Notifications\UserInvite; use BookStack\Access\Notifications\UserInviteNotification;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
class UserInviteService extends UserTokenService class UserInviteService extends UserTokenService
@ -18,6 +18,6 @@ class UserInviteService extends UserTokenService
{ {
$this->deleteByUser($user); $this->deleteByUser($user);
$token = $this->createTokenForUser($user); $token = $this->createTokenForUser($user);
$user->notify(new UserInvite($token)); $user->notify(new UserInviteNotification($token));
} }
} }

View File

@ -6,11 +6,17 @@ use BookStack\Activity\Models\Favouritable;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Queries\TopFavourites; use BookStack\Entities\Queries\TopFavourites;
use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class FavouriteController extends Controller class FavouriteController extends Controller
{ {
public function __construct(
protected MixedEntityRequestHelper $entityHelper,
) {
}
/** /**
* Show a listing of all favourite items for the current user. * Show a listing of all favourite items for the current user.
*/ */
@ -36,13 +42,14 @@ class FavouriteController extends Controller
*/ */
public function add(Request $request) public function add(Request $request)
{ {
$favouritable = $this->getValidatedModelFromRequest($request); $modelInfo = $this->validate($request, $this->entityHelper->validationRules());
$favouritable->favourites()->firstOrCreate([ $entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
$entity->favourites()->firstOrCreate([
'user_id' => user()->id, 'user_id' => user()->id,
]); ]);
$this->showSuccessNotification(trans('activities.favourite_add_notification', [ $this->showSuccessNotification(trans('activities.favourite_add_notification', [
'name' => $favouritable->name, 'name' => $entity->name,
])); ]));
return redirect()->back(); return redirect()->back();
@ -53,48 +60,16 @@ class FavouriteController extends Controller
*/ */
public function remove(Request $request) public function remove(Request $request)
{ {
$favouritable = $this->getValidatedModelFromRequest($request); $modelInfo = $this->validate($request, $this->entityHelper->validationRules());
$favouritable->favourites()->where([ $entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
$entity->favourites()->where([
'user_id' => user()->id, 'user_id' => user()->id,
])->delete(); ])->delete();
$this->showSuccessNotification(trans('activities.favourite_remove_notification', [ $this->showSuccessNotification(trans('activities.favourite_remove_notification', [
'name' => $favouritable->name, 'name' => $entity->name,
])); ]));
return redirect()->back(); return redirect()->back();
} }
/**
* @throws \Illuminate\Validation\ValidationException
* @throws \Exception
*/
protected function getValidatedModelFromRequest(Request $request): Entity
{
$modelInfo = $this->validate($request, [
'type' => ['required', 'string'],
'id' => ['required', 'integer'],
]);
if (!class_exists($modelInfo['type'])) {
throw new \Exception('Model not found');
}
/** @var Model $model */
$model = new $modelInfo['type']();
if (!$model instanceof Favouritable) {
throw new \Exception('Model not favouritable');
}
$modelInstance = $model->newQuery()
->where('id', '=', $modelInfo['id'])
->first(['id', 'name', 'owned_by']);
$inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
if (is_null($modelInstance) || $inaccessibleEntity) {
throw new \Exception('Model instance not found');
}
return $modelInstance;
}
} }

View File

@ -3,25 +3,22 @@
namespace BookStack\Activity\Controllers; namespace BookStack\Activity\Controllers;
use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\App\Model; use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Entities\Models\Entity;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class WatchController extends Controller class WatchController extends Controller
{ {
public function update(Request $request) public function update(Request $request, MixedEntityRequestHelper $entityHelper)
{ {
$this->checkPermission('receive-notifications'); $this->checkPermission('receive-notifications');
$this->preventGuestAccess(); $this->preventGuestAccess();
$requestData = $this->validate($request, [ $requestData = $this->validate($request, array_merge([
'level' => ['required', 'string'], 'level' => ['required', 'string'],
]); ], $entityHelper->validationRules()));
$watchable = $this->getValidatedModelFromRequest($request); $watchable = $entityHelper->getVisibleEntityFromRequestData($requestData);
$watchOptions = new UserEntityWatchOptions(user(), $watchable); $watchOptions = new UserEntityWatchOptions(user(), $watchable);
$watchOptions->updateLevelByName($requestData['level']); $watchOptions->updateLevelByName($requestData['level']);
@ -29,37 +26,4 @@ class WatchController extends Controller
return redirect()->back(); return redirect()->back();
} }
/**
* @throws ValidationException
* @throws Exception
*/
protected function getValidatedModelFromRequest(Request $request): Entity
{
$modelInfo = $this->validate($request, [
'type' => ['required', 'string'],
'id' => ['required', 'integer'],
]);
if (!class_exists($modelInfo['type'])) {
throw new Exception('Model not found');
}
/** @var Model $model */
$model = new $modelInfo['type']();
if (!$model instanceof Entity) {
throw new Exception('Model not an entity');
}
$modelInstance = $model->newQuery()
->where('id', '=', $modelInfo['id'])
->first(['id', 'name', 'owned_by']);
$inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
if (is_null($modelInstance) || $inaccessibleEntity) {
throw new Exception('Model instance not found');
}
return $modelInstance;
}
} }

View File

@ -6,6 +6,7 @@ use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\Webhook; use BookStack\Activity\Models\Webhook;
use BookStack\Activity\Tools\WebhookFormatter; use BookStack\Activity\Tools\WebhookFormatter;
use BookStack\Facades\Theme; use BookStack\Facades\Theme;
use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use BookStack\Util\SsrUrlValidator; use BookStack\Util\SsrUrlValidator;
@ -14,7 +15,6 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class DispatchWebhookJob implements ShouldQueue class DispatchWebhookJob implements ShouldQueue
@ -49,25 +49,28 @@ class DispatchWebhookJob implements ShouldQueue
* *
* @return void * @return void
*/ */
public function handle() public function handle(HttpRequestService $http)
{ {
$lastError = null; $lastError = null;
try { try {
(new SsrUrlValidator())->ensureAllowed($this->webhook->endpoint); (new SsrUrlValidator())->ensureAllowed($this->webhook->endpoint);
$response = Http::asJson() $client = $http->buildClient($this->webhook->timeout, [
->withOptions(['allow_redirects' => ['strict' => true]]) 'connect_timeout' => 10,
->timeout($this->webhook->timeout) 'allow_redirects' => ['strict' => true],
->post($this->webhook->endpoint, $this->webhookData); ]);
} catch (\Exception $exception) {
$lastError = $exception->getMessage();
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
}
if (isset($response) && $response->failed()) { $response = $client->sendRequest($http->jsonRequest('POST', $this->webhook->endpoint, $this->webhookData));
$lastError = "Response status from endpoint was {$response->status()}"; $statusCode = $response->getStatusCode();
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}");
if ($statusCode >= 400) {
$lastError = "Response status from endpoint was {$statusCode}";
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$statusCode}");
}
} catch (\Exception $error) {
$lastError = $error->getMessage();
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
} }
$this->webhook->last_called_at = now(); $this->webhook->last_called_at = now();

View File

@ -41,7 +41,7 @@ class View extends Model
public static function incrementFor(Viewable $viewable): int public static function incrementFor(Viewable $viewable): int
{ {
$user = user(); $user = user();
if (is_null($user) || $user->isDefault()) { if ($user->isGuest()) {
return 0; return 0;
} }

View File

@ -4,7 +4,8 @@ namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Loggable; use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine; use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine;
use BookStack\Notifications\MailNotification; use BookStack\App\MailNotification;
use BookStack\Translation\LocaleDefinition;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
@ -35,12 +36,12 @@ abstract class BaseActivityNotification extends MailNotification
/** /**
* Build the common reason footer line used in mail messages. * Build the common reason footer line used in mail messages.
*/ */
protected function buildReasonFooterLine(string $language): LinkedMailMessageLine protected function buildReasonFooterLine(LocaleDefinition $locale): LinkedMailMessageLine
{ {
return new LinkedMailMessageLine( return new LinkedMailMessageLine(
url('/preferences/notifications'), url('/preferences/notifications'),
trans('notifications.footer_reason', [], $language), $locale->trans('notifications.footer_reason'),
trans('notifications.footer_reason_link', [], $language), $locale->trans('notifications.footer_reason_link'),
); );
} }
} }

View File

@ -17,17 +17,17 @@ class CommentCreationNotification extends BaseActivityNotification
/** @var Page $page */ /** @var Page $page */
$page = $comment->entity; $page = $comment->entity;
$language = $notifiable->getLanguage(); $locale = $notifiable->getLocale();
return $this->newMailMessage($language) return $this->newMailMessage($locale)
->subject(trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()], $language)) ->subject($locale->trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()]))
->line(trans('notifications.new_comment_intro', ['appName' => setting('app-name')], $language)) ->line($locale->trans('notifications.new_comment_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine([ ->line(new ListMessageLine([
trans('notifications.detail_page_name', [], $language) => $page->name, $locale->trans('notifications.detail_page_name') => $page->name,
trans('notifications.detail_commenter', [], $language) => $this->user->name, $locale->trans('notifications.detail_commenter') => $this->user->name,
trans('notifications.detail_comment', [], $language) => strip_tags($comment->html), $locale->trans('notifications.detail_comment') => strip_tags($comment->html),
])) ]))
->action(trans('notifications.action_view_comment', [], $language), $page->getUrl('#comment' . $comment->local_id)) ->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
->line($this->buildReasonFooterLine($language)); ->line($this->buildReasonFooterLine($locale));
} }
} }

View File

@ -14,16 +14,16 @@ class PageCreationNotification extends BaseActivityNotification
/** @var Page $page */ /** @var Page $page */
$page = $this->detail; $page = $this->detail;
$language = $notifiable->getLanguage(); $locale = $notifiable->getLocale();
return $this->newMailMessage($language) return $this->newMailMessage($locale)
->subject(trans('notifications.new_page_subject', ['pageName' => $page->getShortName()], $language)) ->subject($locale->trans('notifications.new_page_subject', ['pageName' => $page->getShortName()]))
->line(trans('notifications.new_page_intro', ['appName' => setting('app-name')], $language)) ->line($locale->trans('notifications.new_page_intro', ['appName' => setting('app-name')], $locale))
->line(new ListMessageLine([ ->line(new ListMessageLine([
trans('notifications.detail_page_name', [], $language) => $page->name, $locale->trans('notifications.detail_page_name') => $page->name,
trans('notifications.detail_created_by', [], $language) => $this->user->name, $locale->trans('notifications.detail_created_by') => $this->user->name,
])) ]))
->action(trans('notifications.action_view_page', [], $language), $page->getUrl()) ->action($locale->trans('notifications.action_view_page'), $page->getUrl())
->line($this->buildReasonFooterLine($language)); ->line($this->buildReasonFooterLine($locale));
} }
} }

View File

@ -14,17 +14,17 @@ class PageUpdateNotification extends BaseActivityNotification
/** @var Page $page */ /** @var Page $page */
$page = $this->detail; $page = $this->detail;
$language = $notifiable->getLanguage(); $locale = $notifiable->getLocale();
return $this->newMailMessage($language) return $this->newMailMessage($locale)
->subject(trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()], $language)) ->subject($locale->trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()]))
->line(trans('notifications.updated_page_intro', ['appName' => setting('app-name')], $language)) ->line($locale->trans('notifications.updated_page_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine([ ->line(new ListMessageLine([
trans('notifications.detail_page_name', [], $language) => $page->name, $locale->trans('notifications.detail_page_name') => $page->name,
trans('notifications.detail_updated_by', [], $language) => $this->user->name, $locale->trans('notifications.detail_updated_by') => $this->user->name,
])) ]))
->line(trans('notifications.updated_page_debounce', [], $language)) ->line($locale->trans('notifications.updated_page_debounce'))
->action(trans('notifications.action_view_page', [], $language), $page->getUrl()) ->action($locale->trans('notifications.action_view_page'), $page->getUrl())
->line($this->buildReasonFooterLine($language)); ->line($this->buildReasonFooterLine($locale));
} }
} }

View File

@ -22,7 +22,7 @@ class UserEntityWatchOptions
public function canWatch(): bool public function canWatch(): bool
{ {
return $this->user->can('receive-notifications') && !$this->user->isDefault(); return $this->user->can('receive-notifications') && !$this->user->isGuest();
} }
public function getWatchLevel(): string public function getWatchLevel(): string

View File

@ -31,6 +31,8 @@ class ApiDocsController extends ApiController
/** /**
* Redirect to the API docs page. * Redirect to the API docs page.
* Required as a controller method, instead of the Route::redirect helper,
* to ensure the URL is generated correctly.
*/ */
public function redirect() public function redirect()
{ {

View File

@ -52,4 +52,12 @@ class ApiToken extends Model implements Loggable
{ {
return "({$this->id}) {$this->name}; User: {$this->user->logDescriptor()}"; return "({$this->id}) {$this->name}; User: {$this->user->logDescriptor()}";
} }
/**
* Get the URL for managing this token.
*/
public function getUrl(string $path = ''): string
{
return url("/api-tokens/{$this->user_id}/{$this->id}/" . trim($path, '/'));
}
} }

View File

@ -14,16 +14,19 @@ class UserApiTokenController extends Controller
/** /**
* Show the form to create a new API token. * Show the form to create a new API token.
*/ */
public function create(int $userId) public function create(Request $request, int $userId)
{ {
// Ensure user is has access-api permission and is the current user or has permission to manage the current user.
$this->checkPermission('access-api'); $this->checkPermission('access-api');
$this->checkPermissionOrCurrentUser('users-manage', $userId); $this->checkPermissionOrCurrentUser('users-manage', $userId);
$this->updateContext($request);
$user = User::query()->findOrFail($userId); $user = User::query()->findOrFail($userId);
$this->setPageTitle(trans('settings.user_api_token_create'));
return view('users.api-tokens.create', [ return view('users.api-tokens.create', [
'user' => $user, 'user' => $user,
'back' => $this->getRedirectPath($user),
]); ]);
} }
@ -60,22 +63,27 @@ class UserApiTokenController extends Controller
session()->flash('api-token-secret:' . $token->id, $secret); session()->flash('api-token-secret:' . $token->id, $secret);
$this->logActivity(ActivityType::API_TOKEN_CREATE, $token); $this->logActivity(ActivityType::API_TOKEN_CREATE, $token);
return redirect($user->getEditUrl('/api-tokens/' . $token->id)); return redirect($token->getUrl());
} }
/** /**
* Show the details for a user API token, with access to edit. * Show the details for a user API token, with access to edit.
*/ */
public function edit(int $userId, int $tokenId) public function edit(Request $request, int $userId, int $tokenId)
{ {
$this->updateContext($request);
[$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);
$secret = session()->pull('api-token-secret:' . $token->id, null); $secret = session()->pull('api-token-secret:' . $token->id, null);
$this->setPageTitle(trans('settings.user_api_token'));
return view('users.api-tokens.edit', [ return view('users.api-tokens.edit', [
'user' => $user, 'user' => $user,
'token' => $token, 'token' => $token,
'model' => $token, 'model' => $token,
'secret' => $secret, 'secret' => $secret,
'back' => $this->getRedirectPath($user),
]); ]);
} }
@ -97,7 +105,7 @@ class UserApiTokenController extends Controller
$this->logActivity(ActivityType::API_TOKEN_UPDATE, $token); $this->logActivity(ActivityType::API_TOKEN_UPDATE, $token);
return redirect($user->getEditUrl('/api-tokens/' . $token->id)); return redirect($token->getUrl());
} }
/** /**
@ -107,6 +115,8 @@ class UserApiTokenController extends Controller
{ {
[$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);
$this->setPageTitle(trans('settings.user_api_token_delete'));
return view('users.api-tokens.delete', [ return view('users.api-tokens.delete', [
'user' => $user, 'user' => $user,
'token' => $token, 'token' => $token,
@ -123,7 +133,7 @@ class UserApiTokenController extends Controller
$this->logActivity(ActivityType::API_TOKEN_DELETE, $token); $this->logActivity(ActivityType::API_TOKEN_DELETE, $token);
return redirect($user->getEditUrl('#api_tokens')); return redirect($this->getRedirectPath($user));
} }
/** /**
@ -142,4 +152,30 @@ class UserApiTokenController extends Controller
return [$user, $token]; return [$user, $token];
} }
/**
* Update the context for where the user is coming from to manage API tokens.
* (Track of location for correct return redirects)
*/
protected function updateContext(Request $request): void
{
$context = $request->query('context');
if ($context) {
session()->put('api-token-context', $context);
}
}
/**
* Get the redirect path for the current api token editing session.
* Attempts to recall the context of where the user is editing from.
*/
protected function getRedirectPath(User $relatedUser): string
{
$context = session()->get('api-token-context');
if ($context === 'settings' || user()->id !== $relatedUser->id) {
return $relatedUser->getEditUrl('#api_tokens');
}
return url('/my-account/auth#api_tokens');
}
} }

View File

@ -78,14 +78,14 @@ class HomeController extends Controller
} }
if ($homepageOption === 'bookshelves') { if ($homepageOption === 'bookshelves') {
$shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder()); $shelves = app()->make(BookshelfRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
$data = array_merge($commonData, ['shelves' => $shelves]); $data = array_merge($commonData, ['shelves' => $shelves]);
return view('home.shelves', $data); return view('home.shelves', $data);
} }
if ($homepageOption === 'books') { if ($homepageOption === 'books') {
$books = app(BookRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder()); $books = app()->make(BookRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
$data = array_merge($commonData, ['books' => $books]); $data = array_merge($commonData, ['books' => $books]);
return view('home.books', $data); return view('home.books', $data);
@ -140,4 +140,12 @@ class HomeController extends Controller
$exists = $favicons->restoreOriginalIfNotExists(); $exists = $favicons->restoreOriginalIfNotExists();
return response()->file($exists ? $favicons->getPath() : $favicons->getOriginalPath()); return response()->file($exists ? $favicons->getPath() : $favicons->getOriginalPath());
} }
/**
* Serve a PWA application manifest.
*/
public function pwaManifest(PwaManifestBuilder $manifestBuilder)
{
return response()->json($manifestBuilder->build());
}
} }

View File

@ -1,7 +1,8 @@
<?php <?php
namespace BookStack\Notifications; namespace BookStack\App;
use BookStack\Translation\LocaleDefinition;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -32,9 +33,9 @@ abstract class MailNotification extends Notification implements ShouldQueue
/** /**
* Create a new mail message. * Create a new mail message.
*/ */
protected function newMailMessage(string $language = ''): MailMessage protected function newMailMessage(?LocaleDefinition $locale = null): MailMessage
{ {
$data = ['language' => $language ?: null]; $data = ['locale' => $locale ?? user()->getLocale()];
return (new MailMessage())->view([ return (new MailMessage())->view([
'html' => 'vendor.notifications.email', 'html' => 'vendor.notifications.email',

View File

@ -9,16 +9,15 @@ use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Exceptions\BookStackExceptionHandlerPage; use BookStack\Exceptions\BookStackExceptionHandlerPage;
use BookStack\Http\HttpRequestService;
use BookStack\Permissions\PermissionApplicator; use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\SettingService; use BookStack\Settings\SettingService;
use BookStack\Util\CspService; use BookStack\Util\CspService;
use GuzzleHttp\Client;
use Illuminate\Contracts\Foundation\ExceptionRenderer; use Illuminate\Contracts\Foundation\ExceptionRenderer;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Psr\Http\Client\ClientInterface as HttpClientInterface;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -39,6 +38,7 @@ class AppServiceProvider extends ServiceProvider
SettingService::class => SettingService::class, SettingService::class => SettingService::class,
SocialAuthService::class => SocialAuthService::class, SocialAuthService::class => SocialAuthService::class,
CspService::class => CspService::class, CspService::class => CspService::class,
HttpRequestService::class => HttpRequestService::class,
]; ];
/** /**
@ -51,7 +51,7 @@ class AppServiceProvider extends ServiceProvider
// Set root URL // Set root URL
$appUrl = config('app.url'); $appUrl = config('app.url');
if ($appUrl) { if ($appUrl) {
$isHttps = (strpos($appUrl, 'https://') === 0); $isHttps = str_starts_with($appUrl, 'https://');
URL::forceRootUrl($appUrl); URL::forceRootUrl($appUrl);
URL::forceScheme($isHttps ? 'https' : 'http'); URL::forceScheme($isHttps ? 'https' : 'http');
} }
@ -75,12 +75,6 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register() public function register()
{ {
$this->app->bind(HttpClientInterface::class, function ($app) {
return new Client([
'timeout' => 3,
]);
});
$this->app->singleton(PermissionApplicator::class, function ($app) { $this->app->singleton(PermissionApplicator::class, function ($app) {
return new PermissionApplicator(null); return new PermissionApplicator(null);
}); });

View File

@ -9,6 +9,7 @@ use BookStack\Access\LdapService;
use BookStack\Access\LoginService; use BookStack\Access\LoginService;
use BookStack\Access\RegistrationService; use BookStack\Access\RegistrationService;
use BookStack\Api\ApiTokenGuard; use BookStack\Api\ApiTokenGuard;
use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
@ -65,5 +66,11 @@ class AuthServiceProvider extends ServiceProvider
Auth::provider('external-users', function ($app, array $config) { Auth::provider('external-users', function ($app, array $config) {
return new ExternalBaseUserProvider($config['model']); return new ExternalBaseUserProvider($config['model']);
}); });
// Bind and provide the default system user as a singleton to the app instance when needed.
// This effectively "caches" fetching the user at an app-instance level.
$this->app->singleton('users.default', function () {
return User::query()->where('system_name', '=', 'public')->first();
});
} }
} }

View File

@ -3,7 +3,12 @@
namespace BookStack\App\Providers; namespace BookStack\App\Providers;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use SocialiteProviders\Azure\AzureExtendSocialite;
use SocialiteProviders\Discord\DiscordExtendSocialite;
use SocialiteProviders\GitLab\GitLabExtendSocialite;
use SocialiteProviders\Manager\SocialiteWasCalled; use SocialiteProviders\Manager\SocialiteWasCalled;
use SocialiteProviders\Okta\OktaExtendSocialite;
use SocialiteProviders\Twitch\TwitchExtendSocialite;
class EventServiceProvider extends ServiceProvider class EventServiceProvider extends ServiceProvider
{ {
@ -14,12 +19,11 @@ class EventServiceProvider extends ServiceProvider
*/ */
protected $listen = [ protected $listen = [
SocialiteWasCalled::class => [ SocialiteWasCalled::class => [
'SocialiteProviders\Slack\SlackExtendSocialite@handle', AzureExtendSocialite::class . '@handle',
'SocialiteProviders\Azure\AzureExtendSocialite@handle', OktaExtendSocialite::class . '@handle',
'SocialiteProviders\Okta\OktaExtendSocialite@handle', GitLabExtendSocialite::class . '@handle',
'SocialiteProviders\GitLab\GitLabExtendSocialite@handle', TwitchExtendSocialite::class . '@handle',
'SocialiteProviders\Twitch\TwitchExtendSocialite@handle', DiscordExtendSocialite::class . '@handle',
'SocialiteProviders\Discord\DiscordExtendSocialite@handle',
], ],
]; ];

View File

@ -25,7 +25,7 @@ class ViewTweaksServiceProvider extends ServiceProvider
// Custom blade view directives // Custom blade view directives
Blade::directive('icon', function ($expression) { Blade::directive('icon', function ($expression) {
return "<?php echo icon($expression); ?>"; return "<?php echo (new \BookStack\Util\SvgIcon($expression))->toHtml(); ?>";
}); });
} }
} }

View File

@ -0,0 +1,59 @@
<?php
namespace BookStack\App;
class PwaManifestBuilder
{
public function build(): array
{
$darkMode = (bool) setting()->getForCurrentUser('dark-mode-enabled');
$appName = setting('app-name');
return [
"name" => $appName,
"short_name" => $appName,
"start_url" => "./",
"scope" => "/",
"display" => "standalone",
"background_color" => $darkMode ? '#111111' : '#F2F2F2',
"description" => $appName,
"theme_color" => ($darkMode ? setting('app-color-dark') : setting('app-color')),
"launch_handler" => [
"client_mode" => "focus-existing"
],
"orientation" => "portrait",
"icons" => [
[
"src" => setting('app-icon-32') ?: url('/icon-32.png'),
"sizes" => "32x32",
"type" => "image/png"
],
[
"src" => setting('app-icon-64') ?: url('/icon-64.png'),
"sizes" => "64x64",
"type" => "image/png"
],
[
"src" => setting('app-icon-128') ?: url('/icon-128.png'),
"sizes" => "128x128",
"type" => "image/png"
],
[
"src" => setting('app-icon-180') ?: url('/icon-180.png'),
"sizes" => "180x180",
"type" => "image/png"
],
[
"src" => setting('app-icon') ?: url('/icon.png'),
"sizes" => "256x256",
"type" => "image/png"
],
[
"src" => url('favicon.ico'),
"sizes" => "48x48",
"type" => "image/vnd.microsoft.icon"
],
],
];
}
}

View File

@ -35,23 +35,7 @@ function versioned_asset(string $file = ''): string
*/ */
function user(): User function user(): User
{ {
return auth()->user() ?: User::getDefault(); return auth()->user() ?: User::getGuest();
}
/**
* Check if current user is a signed in user.
*/
function signedInUser(): bool
{
return auth()->user() && !auth()->user()->isDefault();
}
/**
* Check if the current user has general access.
*/
function hasAppAccess(): bool
{
return !auth()->guest() || setting('app-public');
} }
/** /**
@ -61,11 +45,11 @@ function hasAppAccess(): bool
function userCan(string $permission, Model $ownable = null): bool function userCan(string $permission, Model $ownable = null): bool
{ {
if ($ownable === null) { if ($ownable === null) {
return user() && user()->can($permission); return user()->can($permission);
} }
// Check permission on ownable item // Check permission on ownable item
$permissions = app(PermissionApplicator::class); $permissions = app()->make(PermissionApplicator::class);
return $permissions->checkOwnableUserAccess($ownable, $permission); return $permissions->checkOwnableUserAccess($ownable, $permission);
} }
@ -76,7 +60,7 @@ function userCan(string $permission, Model $ownable = null): bool
*/ */
function userCanOnAny(string $action, string $entityClass = ''): bool function userCanOnAny(string $action, string $entityClass = ''): bool
{ {
$permissions = app(PermissionApplicator::class); $permissions = app()->make(PermissionApplicator::class);
return $permissions->checkUserHasEntityPermissionOnAny($action, $entityClass); return $permissions->checkUserHasEntityPermissionOnAny($action, $entityClass);
} }
@ -88,7 +72,7 @@ function userCanOnAny(string $action, string $entityClass = ''): bool
*/ */
function setting(string $key = null, $default = null) function setting(string $key = null, $default = null)
{ {
$settingService = resolve(SettingService::class); $settingService = app()->make(SettingService::class);
if (is_null($key)) { if (is_null($key)) {
return $settingService; return $settingService;
@ -113,39 +97,6 @@ function theme_path(string $path = ''): ?string
return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path)); return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path));
} }
/**
* Get fetch an SVG icon as a string.
* Checks for icons defined within a custom theme before defaulting back
* to the 'resources/assets/icons' folder.
*
* Returns an empty string if icon file not found.
*/
function icon(string $name, array $attrs = []): string
{
$attrs = array_merge([
'class' => 'svg-icon',
'data-icon' => $name,
'role' => 'presentation',
], $attrs);
$attrString = ' ';
foreach ($attrs as $attrName => $attr) {
$attrString .= $attrName . '="' . $attr . '" ';
}
$iconPath = resource_path('icons/' . $name . '.svg');
$themeIconPath = theme_path('icons/' . $name . '.svg');
if ($themeIconPath && file_exists($themeIconPath)) {
$iconPath = $themeIconPath;
} elseif (!file_exists($iconPath)) {
return '';
}
$fileContents = file_get_contents($iconPath);
return str_replace('<svg', '<svg' . $attrString, $fileContents);
}
/** /**
* Generate a URL with multiple parameters for sorting purposes. * Generate a URL with multiple parameters for sorting purposes.
* Works out the logic to set the correct sorting direction * Works out the logic to set the correct sorting direction

View File

@ -83,10 +83,10 @@ return [
'timezone' => env('APP_TIMEZONE', 'UTC'), 'timezone' => env('APP_TIMEZONE', 'UTC'),
// Default locale to use // Default locale to use
// A default variant is also stored since Laravel can overwrite
// app.locale when dynamically setting the locale in-app.
'locale' => env('APP_LANG', 'en'), 'locale' => env('APP_LANG', 'en'),
'default_locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'el', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ka', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
// Application Fallback Locale // Application Fallback Locale
'fallback_locale' => 'en', 'fallback_locale' => 'en',
@ -94,9 +94,6 @@ return [
// Faker Locale // Faker Locale
'faker_locale' => 'en_GB', 'faker_locale' => 'en_GB',
// Enable right-to-left text control.
'rtl' => false,
// Auto-detect the locale for public users // Auto-detect the locale for public users
// For public users their locale can be guessed by headers sent by their // For public users their locale can be guessed by headers sent by their
// browser. This is usually set by users in their browser settings. // browser. This is usually set by users in their browser settings.

View File

@ -22,7 +22,7 @@ return [
// Global "From" address & name // Global "From" address & name
'from' => [ 'from' => [
'address' => env('MAIL_FROM', 'mail@bookstackapp.com'), 'address' => env('MAIL_FROM', 'bookstack@example.com'),
'name' => env('MAIL_FROM_NAME', 'BookStack'), 'name' => env('MAIL_FROM_NAME', 'BookStack'),
], ],

View File

@ -9,7 +9,7 @@ return [
'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false), 'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false),
// Claim, within an OpenId token, to find the user's display name // Claim, within an OpenId token, to find the user's display name
'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')), 'display_name_claims' => env('OIDC_DISPLAY_NAME_CLAIMS', 'name'),
// Claim, within an OpenID token, to use to connect a BookStack user to the OIDC user. // Claim, within an OpenID token, to use to connect a BookStack user to the OIDC user.
'external_id_claim' => env('OIDC_EXTERNAL_ID_CLAIM', 'sub'), 'external_id_claim' => env('OIDC_EXTERNAL_ID_CLAIM', 'sub'),

View File

@ -35,7 +35,7 @@ class CleanupImagesCommand extends Command
if (!$dryRun) { if (!$dryRun) {
$this->warn("This operation is destructive and is not guaranteed to be fully accurate.\nEnsure you have a backup of your images.\n"); $this->warn("This operation is destructive and is not guaranteed to be fully accurate.\nEnsure you have a backup of your images.\n");
$proceed = $this->confirm("Are you sure you want to proceed?"); $proceed = !$this->input->isInteractive() || $this->confirm("Are you sure you want to proceed?");
if (!$proceed) { if (!$proceed) {
return 0; return 0;
} }
@ -46,7 +46,7 @@ class CleanupImagesCommand extends Command
if ($dryRun) { if ($dryRun) {
$this->comment('Dry run, no images have been deleted'); $this->comment('Dry run, no images have been deleted');
$this->comment($deleteCount . ' images found that would have been deleted'); $this->comment($deleteCount . ' image(s) found that would have been deleted');
$this->showDeletedImages($deleted); $this->showDeletedImages($deleted);
$this->comment('Run with -f or --force to perform deletions'); $this->comment('Run with -f or --force to perform deletions');
@ -54,7 +54,8 @@ class CleanupImagesCommand extends Command
} }
$this->showDeletedImages($deleted); $this->showDeletedImages($deleted);
$this->comment($deleteCount . ' images deleted'); $this->comment("{$deleteCount} image(s) deleted");
return 0; return 0;
} }
@ -65,7 +66,7 @@ class CleanupImagesCommand extends Command
} }
if (count($paths) > 0) { if (count($paths) > 0) {
$this->line('Images to delete:'); $this->line('Image(s) to delete:');
} }
foreach ($paths as $path) { foreach ($paths as $path) {

View File

@ -0,0 +1,40 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Users\Models\User;
use Exception;
use Illuminate\Console\Command;
/**
* @mixin Command
*/
trait HandlesSingleUser
{
/**
* Fetch a user provided to this command.
* Expects the command to accept 'id' and 'email' options.
* @throws Exception
*/
private function fetchProvidedUser(): User
{
$id = $this->option('id');
$email = $this->option('email');
if (!$id && !$email) {
throw new Exception("Either a --id=<number> or --email=<email> option must be provided.\nRun this command with `--help` to show more options.");
}
$field = $id ? 'id' : 'email';
$value = $id ?: $email;
$user = User::query()
->where($field, '=', $value)
->first();
if (!$user) {
throw new Exception("A user where {$field}={$value} could not be found.");
}
return $user;
}
}

View File

@ -0,0 +1,116 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Users\Models\User;
use Exception;
use Illuminate\Console\Command;
use BookStack\Uploads\UserAvatars;
class RefreshAvatarCommand extends Command
{
use HandlesSingleUser;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:refresh-avatar
{--id= : Numeric ID of the user to refresh avatar for}
{--email= : Email address of the user to refresh avatar for}
{--users-without-avatars : Refresh avatars for users that currently have no avatar}
{--a|all : Refresh avatars for all users}
{--f|force : Actually run the update, Defaults to a dry-run}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Refresh avatar for the given user(s)';
public function handle(UserAvatars $userAvatar): int
{
if (!$userAvatar->avatarFetchEnabled()) {
$this->error("Avatar fetching is disabled on this instance.");
return self::FAILURE;
}
if ($this->option('users-without-avatars')) {
return $this->processUsers(User::query()->whereDoesntHave('avatar')->get()->all(), $userAvatar);
}
if ($this->option('all')) {
return $this->processUsers(User::query()->get()->all(), $userAvatar);
}
try {
$user = $this->fetchProvidedUser();
return $this->processUsers([$user], $userAvatar);
} catch (Exception $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
}
/**
* @param User[] $users
*/
private function processUsers(array $users, UserAvatars $userAvatar): int
{
$dryRun = !$this->option('force');
$this->info(count($users) . " user(s) found to update avatars for.");
if (count($users) === 0) {
return self::SUCCESS;
}
if (!$dryRun) {
$fetchHost = parse_url($userAvatar->getAvatarUrl(), PHP_URL_HOST);
$this->warn("This will destroy any existing avatar images these users have, and attempt to fetch new avatar images from {$fetchHost}.");
$proceed = !$this->input->isInteractive() || $this->confirm('Are you sure you want to proceed?');
if (!$proceed) {
return self::SUCCESS;
}
}
$this->info("");
$exitCode = self::SUCCESS;
foreach ($users as $user) {
$linePrefix = "[ID: {$user->id}] $user->email -";
if ($dryRun) {
$this->warn("{$linePrefix} Not updated");
continue;
}
if ($this->fetchAvatar($userAvatar, $user)) {
$this->info("{$linePrefix} Updated");
} else {
$this->error("{$linePrefix} Not updated");
$exitCode = self::FAILURE;
}
}
if ($dryRun) {
$this->comment("");
$this->comment("Dry run, no avatars were updated.");
$this->comment('Run with -f or --force to perform the update.');
}
return $exitCode;
}
private function fetchAvatar(UserAvatars $userAvatar, User $user): bool
{
$oldId = $user->avatar->id ?? 0;
$userAvatar->fetchAndAssignToUser($user);
$user->refresh();
$newId = $user->avatar->id ?? $oldId;
return $oldId !== $newId;
}
}

View File

@ -2,11 +2,13 @@
namespace BookStack\Console\Commands; namespace BookStack\Console\Commands;
use BookStack\Users\Models\User; use Exception;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class ResetMfaCommand extends Command class ResetMfaCommand extends Command
{ {
use HandlesSingleUser;
/** /**
* The name and signature of the console command. * The name and signature of the console command.
* *
@ -29,25 +31,10 @@ class ResetMfaCommand extends Command
*/ */
public function handle(): int public function handle(): int
{ {
$id = $this->option('id'); try {
$email = $this->option('email'); $user = $this->fetchProvidedUser();
if (!$id && !$email) { } catch (Exception $exception) {
$this->error('Either a --id=<number> or --email=<email> option must be provided.'); $this->error($exception->getMessage());
return 1;
}
$field = $id ? 'id' : 'email';
$value = $id ?: $email;
/** @var User $user */
$user = User::query()
->where($field, '=', $value)
->first();
if (!$user) {
$this->error("A user where {$field}={$value} could not be found.");
return 1; return 1;
} }

View File

@ -8,29 +8,21 @@ use Illuminate\View\View;
class BreadcrumbsViewComposer class BreadcrumbsViewComposer
{ {
protected $entityContextManager; public function __construct(
protected ShelfContext $shelfContext
/** ) {
* BreadcrumbsViewComposer constructor.
*
* @param ShelfContext $entityContextManager
*/
public function __construct(ShelfContext $entityContextManager)
{
$this->entityContextManager = $entityContextManager;
} }
/** /**
* Modify data when the view is composed. * Modify data when the view is composed.
*
* @param View $view
*/ */
public function compose(View $view) public function compose(View $view): void
{ {
$crumbs = $view->getData()['crumbs']; $crumbs = $view->getData()['crumbs'];
$firstCrumb = $crumbs[0] ?? null; $firstCrumb = $crumbs[0] ?? null;
if ($firstCrumb instanceof Book) { if ($firstCrumb instanceof Book) {
$shelf = $this->entityContextManager->getContextualShelfForBook($firstCrumb); $shelf = $this->shelfContext->getContextualShelfForBook($firstCrumb);
if ($shelf) { if ($shelf) {
array_unshift($crumbs, $shelf); array_unshift($crumbs, $shelf);
$view->with('crumbs', $crumbs); $view->with('crumbs', $crumbs);

View File

@ -40,26 +40,19 @@ class Book extends Entity implements HasCoverImage
/** /**
* Returns book cover image, if book cover not exists return default cover image. * Returns book cover image, if book cover not exists return default cover image.
*
* @param int $width - Width of the image
* @param int $height - Height of the image
*
* @return string
*/ */
public function getBookCover($width = 440, $height = 250) public function getBookCover(int $width = 440, int $height = 250): string
{ {
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
if (!$this->image_id) { if (!$this->image_id || !$this->cover) {
return $default; return $default;
} }
try { try {
$cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default; return $this->cover->getThumb($width, $height, false) ?? $default;
} catch (Exception $err) { } catch (Exception $err) {
$cover = $default; return $default;
} }
return $cover;
} }
/** /**

View File

@ -3,6 +3,7 @@
namespace BookStack\Entities\Models; namespace BookStack\Entities\Models;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use Exception;
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\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@ -49,28 +50,21 @@ class Bookshelf extends Entity implements HasCoverImage
} }
/** /**
* Returns BookShelf cover image, if cover does not exists return default cover image. * Returns shelf cover image, if cover not exists return default cover image.
*
* @param int $width - Width of the image
* @param int $height - Height of the image
*
* @return string
*/ */
public function getBookCover($width = 440, $height = 250) public function getBookCover(int $width = 440, int $height = 250): string
{ {
// TODO - Make generic, focused on books right now, Perhaps set-up a better image // TODO - Make generic, focused on books right now, Perhaps set-up a better image
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
if (!$this->image_id) { if (!$this->image_id || !$this->cover) {
return $default; return $default;
} }
try { try {
$cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default; return $this->cover->getThumb($width, $height, false) ?? $default;
} catch (\Exception $err) { } catch (Exception $err) {
$cover = $default; return $default;
} }
return $cover;
} }
/** /**

View File

@ -10,7 +10,7 @@ class RecentlyViewed extends EntityQuery
public function run(int $count, int $page): Collection public function run(int $count, int $page): Collection
{ {
$user = user(); $user = user();
if ($user === null || $user->isDefault()) { if ($user === null || $user->isGuest()) {
return collect(); return collect();
} }

View File

@ -10,7 +10,7 @@ class TopFavourites extends EntityQuery
public function run(int $count, int $skip = 0) public function run(int $count, int $skip = 0)
{ {
$user = user(); $user = user();
if ($user->isDefault()) { if ($user->isGuest()) {
return collect(); return collect();
} }

View File

@ -16,18 +16,11 @@ use Throwable;
class ExportFormatter class ExportFormatter
{ {
protected ImageService $imageService; public function __construct(
protected PdfGenerator $pdfGenerator; protected ImageService $imageService,
protected CspService $cspService; protected PdfGenerator $pdfGenerator,
protected CspService $cspService
/** ) {
* ExportService constructor.
*/
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator, CspService $cspService)
{
$this->imageService = $imageService;
$this->pdfGenerator = $pdfGenerator;
$this->cspService = $cspService;
} }
/** /**
@ -36,13 +29,14 @@ class ExportFormatter
* *
* @throws Throwable * @throws Throwable
*/ */
public function pageToContainedHtml(Page $page) public function pageToContainedHtml(Page $page): string
{ {
$page->html = (new PageContent($page))->render(); $page->html = (new PageContent($page))->render();
$pageHtml = view('exports.page', [ $pageHtml = view('exports.page', [
'page' => $page, 'page' => $page,
'format' => 'html', 'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(), 'cspContent' => $this->cspService->getCspMetaTagValue(),
'locale' => user()->getLocale(),
])->render(); ])->render();
return $this->containHtml($pageHtml); return $this->containHtml($pageHtml);
@ -53,7 +47,7 @@ class ExportFormatter
* *
* @throws Throwable * @throws Throwable
*/ */
public function chapterToContainedHtml(Chapter $chapter) public function chapterToContainedHtml(Chapter $chapter): string
{ {
$pages = $chapter->getVisiblePages(); $pages = $chapter->getVisiblePages();
$pages->each(function ($page) { $pages->each(function ($page) {
@ -64,6 +58,7 @@ class ExportFormatter
'pages' => $pages, 'pages' => $pages,
'format' => 'html', 'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(), 'cspContent' => $this->cspService->getCspMetaTagValue(),
'locale' => user()->getLocale(),
])->render(); ])->render();
return $this->containHtml($html); return $this->containHtml($html);
@ -74,7 +69,7 @@ class ExportFormatter
* *
* @throws Throwable * @throws Throwable
*/ */
public function bookToContainedHtml(Book $book) public function bookToContainedHtml(Book $book): string
{ {
$bookTree = (new BookContents($book))->getTree(false, true); $bookTree = (new BookContents($book))->getTree(false, true);
$html = view('exports.book', [ $html = view('exports.book', [
@ -82,6 +77,7 @@ class ExportFormatter
'bookChildren' => $bookTree, 'bookChildren' => $bookTree,
'format' => 'html', 'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(), 'cspContent' => $this->cspService->getCspMetaTagValue(),
'locale' => user()->getLocale(),
])->render(); ])->render();
return $this->containHtml($html); return $this->containHtml($html);
@ -92,13 +88,14 @@ class ExportFormatter
* *
* @throws Throwable * @throws Throwable
*/ */
public function pageToPdf(Page $page) public function pageToPdf(Page $page): string
{ {
$page->html = (new PageContent($page))->render(); $page->html = (new PageContent($page))->render();
$html = view('exports.page', [ $html = view('exports.page', [
'page' => $page, 'page' => $page,
'format' => 'pdf', 'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(), 'engine' => $this->pdfGenerator->getActiveEngine(),
'locale' => user()->getLocale(),
])->render(); ])->render();
return $this->htmlToPdf($html); return $this->htmlToPdf($html);
@ -109,7 +106,7 @@ class ExportFormatter
* *
* @throws Throwable * @throws Throwable
*/ */
public function chapterToPdf(Chapter $chapter) public function chapterToPdf(Chapter $chapter): string
{ {
$pages = $chapter->getVisiblePages(); $pages = $chapter->getVisiblePages();
$pages->each(function ($page) { $pages->each(function ($page) {
@ -121,6 +118,7 @@ class ExportFormatter
'pages' => $pages, 'pages' => $pages,
'format' => 'pdf', 'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(), 'engine' => $this->pdfGenerator->getActiveEngine(),
'locale' => user()->getLocale(),
])->render(); ])->render();
return $this->htmlToPdf($html); return $this->htmlToPdf($html);
@ -131,7 +129,7 @@ class ExportFormatter
* *
* @throws Throwable * @throws Throwable
*/ */
public function bookToPdf(Book $book) public function bookToPdf(Book $book): string
{ {
$bookTree = (new BookContents($book))->getTree(false, true); $bookTree = (new BookContents($book))->getTree(false, true);
$html = view('exports.book', [ $html = view('exports.book', [
@ -139,6 +137,7 @@ class ExportFormatter
'bookChildren' => $bookTree, 'bookChildren' => $bookTree,
'format' => 'pdf', 'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(), 'engine' => $this->pdfGenerator->getActiveEngine(),
'locale' => user()->getLocale(),
])->render(); ])->render();
return $this->htmlToPdf($html); return $this->htmlToPdf($html);
@ -194,7 +193,7 @@ class ExportFormatter
/** @var DOMElement $iframe */ /** @var DOMElement $iframe */
foreach ($iframes as $iframe) { foreach ($iframes as $iframe) {
$link = $iframe->getAttribute('src'); $link = $iframe->getAttribute('src');
if (strpos($link, '//') === 0) { if (str_starts_with($link, '//')) {
$link = 'https:' . $link; $link = 'https:' . $link;
} }
@ -223,7 +222,7 @@ class ExportFormatter
foreach ($imageTagsOutput[0] as $index => $imgMatch) { foreach ($imageTagsOutput[0] as $index => $imgMatch) {
$oldImgTagString = $imgMatch; $oldImgTagString = $imgMatch;
$srcString = $imageTagsOutput[2][$index]; $srcString = $imageTagsOutput[2][$index];
$imageEncoded = $this->imageService->imageUriToBase64($srcString); $imageEncoded = $this->imageService->imageUrlToBase64($srcString);
if ($imageEncoded === null) { if ($imageEncoded === null) {
$imageEncoded = $srcString; $imageEncoded = $srcString;
} }
@ -240,7 +239,7 @@ class ExportFormatter
foreach ($linksOutput[0] as $index => $linkMatch) { foreach ($linksOutput[0] as $index => $linkMatch) {
$oldLinkString = $linkMatch; $oldLinkString = $linkMatch;
$srcString = $linksOutput[2][$index]; $srcString = $linksOutput[2][$index];
if (strpos(trim($srcString), 'http') !== 0) { if (!str_starts_with(trim($srcString), 'http')) {
$newSrcString = url($srcString); $newSrcString = url($srcString);
$newLinkString = str_replace($srcString, $newSrcString, $oldLinkString); $newLinkString = str_replace($srcString, $newSrcString, $oldLinkString);
$htmlContent = str_replace($oldLinkString, $newLinkString, $htmlContent); $htmlContent = str_replace($oldLinkString, $newLinkString, $htmlContent);
@ -255,17 +254,20 @@ class ExportFormatter
* Converts the page contents into simple plain text. * Converts the page contents into simple plain text.
* This method filters any bad looking content to provide a nice final output. * This method filters any bad looking content to provide a nice final output.
*/ */
public function pageToPlainText(Page $page): string public function pageToPlainText(Page $page, bool $pageRendered = false, bool $fromParent = false): string
{ {
$html = (new PageContent($page))->render(); $html = $pageRendered ? $page->html : (new PageContent($page))->render();
$text = strip_tags($html); // Add proceeding spaces before tags so spaces remain between
// text within elements after stripping tags.
$html = str_replace('<', " <", $html);
$text = trim(strip_tags($html));
// Replace multiple spaces with single spaces // Replace multiple spaces with single spaces
$text = preg_replace('/\ {2,}/', ' ', $text); $text = preg_replace('/ {2,}/', ' ', $text);
// Reduce multiple horrid whitespace characters. // Reduce multiple horrid whitespace characters.
$text = preg_replace('/(\x0A|\xA0|\x0A|\r|\n){2,}/su', "\n\n", $text); $text = preg_replace('/(\x0A|\xA0|\x0A|\r|\n){2,}/su', "\n\n", $text);
$text = html_entity_decode($text); $text = html_entity_decode($text);
// Add title // Add title
$text = $page->name . "\n\n" . $text; $text = $page->name . ($fromParent ? "\n" : "\n\n") . $text;
return $text; return $text;
} }
@ -275,13 +277,15 @@ class ExportFormatter
*/ */
public function chapterToPlainText(Chapter $chapter): string public function chapterToPlainText(Chapter $chapter): string
{ {
$text = $chapter->name . "\n\n"; $text = $chapter->name . "\n" . $chapter->description;
$text .= $chapter->description . "\n\n"; $text = trim($text) . "\n\n";
$parts = [];
foreach ($chapter->getVisiblePages() as $page) { foreach ($chapter->getVisiblePages() as $page) {
$text .= $this->pageToPlainText($page); $parts[] = $this->pageToPlainText($page, false, true);
} }
return $text; return $text . implode("\n\n", $parts);
} }
/** /**
@ -289,17 +293,20 @@ class ExportFormatter
*/ */
public function bookToPlainText(Book $book): string public function bookToPlainText(Book $book): string
{ {
$bookTree = (new BookContents($book))->getTree(false, false); $bookTree = (new BookContents($book))->getTree(false, true);
$text = $book->name . "\n\n"; $text = $book->name . "\n" . $book->description;
$text = rtrim($text) . "\n\n";
$parts = [];
foreach ($bookTree as $bookChild) { foreach ($bookTree as $bookChild) {
if ($bookChild->isA('chapter')) { if ($bookChild->isA('chapter')) {
$text .= $this->chapterToPlainText($bookChild); $parts[] = $this->chapterToPlainText($bookChild);
} else { } else {
$text .= $this->pageToPlainText($bookChild); $parts[] = $this->pageToPlainText($bookChild, true, true);
} }
} }
return $text; return $text . implode("\n\n", $parts);
} }
/** /**

View File

@ -0,0 +1,39 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
class MixedEntityRequestHelper
{
public function __construct(
protected EntityProvider $entities,
) {
}
/**
* Query out an entity, visible to the current user, for the given
* entity request details (this provided in a request validated by
* this classes' validationRules method).
* @param array{type: string, id: string} $requestData
*/
public function getVisibleEntityFromRequestData(array $requestData): Entity
{
$entityType = $this->entities->get($requestData['type']);
return $entityType->newQuery()->scopes(['visible'])->findOrFail($requestData['id']);
}
/**
* Get the validation rules for an abstract entity request.
* @return array{type: string[], id: string[]}
*/
public function validationRules(): array
{
return [
'type' => ['required', 'string'],
'id' => ['required', 'integer'],
];
}
}

View File

@ -197,7 +197,7 @@ class TrashCan
$page->allRevisions()->delete(); $page->allRevisions()->delete();
// Delete Attached Files // Delete Attached Files
$attachmentService = app(AttachmentService::class); $attachmentService = app()->make(AttachmentService::class);
foreach ($page->attachments as $attachment) { foreach ($page->attachments as $attachment) {
$attachmentService->deleteFile($attachment); $attachmentService->deleteFile($attachment);
} }

View File

@ -6,9 +6,11 @@ use Exception;
use Illuminate\Auth\AuthenticationException; use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\Exceptions\PostTooLargeException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Symfony\Component\ErrorHandler\Error\FatalError;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Throwable; use Throwable;
@ -35,6 +37,15 @@ class Handler extends ExceptionHandler
'password_confirmation', 'password_confirmation',
]; ];
/**
* A function to run upon out of memory.
* If it returns a response, that will be provided back to the request
* upon an out of memory event.
*
* @var ?callable<?\Illuminate\Http\Response>
*/
protected $onOutOfMemory = null;
/** /**
* Report or log an exception. * Report or log an exception.
* *
@ -59,6 +70,17 @@ class Handler extends ExceptionHandler
*/ */
public function render($request, Throwable $e) public function render($request, Throwable $e)
{ {
if ($e instanceof FatalError && str_contains($e->getMessage(), 'bytes exhausted (tried to allocate') && $this->onOutOfMemory) {
$response = call_user_func($this->onOutOfMemory);
if ($response) {
return $response;
}
}
if ($e instanceof PostTooLargeException) {
$e = new NotifyException(trans('errors.server_post_limit'), '/', 413);
}
if ($this->isApiRequest($request)) { if ($this->isApiRequest($request)) {
return $this->renderApiException($e); return $this->renderApiException($e);
} }
@ -66,12 +88,30 @@ class Handler extends ExceptionHandler
return parent::render($request, $e); return parent::render($request, $e);
} }
/**
* Provide a function to be called when an out of memory event occurs.
* If the callable returns a response, this response will be returned
* to the request upon error.
*/
public function prepareForOutOfMemory(callable $onOutOfMemory)
{
$this->onOutOfMemory = $onOutOfMemory;
}
/**
* Forget the current out of memory handler, if existing.
*/
public function forgetOutOfMemoryHandler()
{
$this->onOutOfMemory = null;
}
/** /**
* Check if the given request is an API request. * Check if the given request is an API request.
*/ */
protected function isApiRequest(Request $request): bool protected function isApiRequest(Request $request): bool
{ {
return strpos($request->path(), 'api/') === 0; return str_starts_with($request->path(), 'api/');
} }
/** /**

View File

@ -0,0 +1,7 @@
<?php
namespace BookStack\Exceptions;
class ThemeException extends \Exception
{
}

View File

@ -71,7 +71,7 @@ abstract class Controller extends BaseController
*/ */
protected function preventGuestAccess(): void protected function preventGuestAccess(): void
{ {
if (!signedInUser()) { if (user()->isGuest()) {
$this->showPermissionError(); $this->showPermissionError();
} }
} }

View File

@ -0,0 +1,33 @@
<?php
namespace BookStack\Http;
use GuzzleHttp\Psr7\Request as GuzzleRequest;
class HttpClientHistory
{
public function __construct(
protected &$container
) {
}
public function requestCount(): int
{
return count($this->container);
}
public function requestAt(int $index): ?GuzzleRequest
{
return $this->container[$index]['request'] ?? null;
}
public function latestRequest(): ?GuzzleRequest
{
return $this->requestAt($this->requestCount() - 1);
}
public function all(): array
{
return $this->container;
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace BookStack\Http;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Request as GuzzleRequest;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Client\ClientInterface;
class HttpRequestService
{
protected ?HandlerStack $handler = null;
/**
* Build a new http client for sending requests on.
*/
public function buildClient(int $timeout, array $options = []): ClientInterface
{
$defaultOptions = [
'timeout' => $timeout,
'handler' => $this->handler,
];
return new Client(array_merge($options, $defaultOptions));
}
/**
* Create a new JSON http request for use with a client.
*/
public function jsonRequest(string $method, string $uri, array $data): GuzzleRequest
{
$headers = ['Content-Type' => 'application/json'];
return new GuzzleRequest($method, $uri, $headers, json_encode($data));
}
/**
* Mock any http clients built from this service, and response with the given responses.
* Returns history which can then be queried.
* @link https://docs.guzzlephp.org/en/stable/testing.html#history-middleware
*/
public function mockClient(array $responses = [], bool $pad = true): HttpClientHistory
{
// By default, we pad out the responses with 10 successful values so that requests will be
// properly recorded for inspection. Otherwise, we can't later check if we're received
// too many requests.
if ($pad) {
$response = new Response(200, [], 'success');
$responses = array_merge($responses, array_fill(0, 10, $response));
}
$container = [];
$history = Middleware::history($container);
$mock = new MockHandler($responses);
$this->handler = HandlerStack::create($mock);
$this->handler->push($history, 'history');
return new HttpClientHistory($container);
}
/**
* Clear mocking that has been set up for clients.
*/
public function clearMocking(): void
{
$this->handler = null;
}
}

View File

@ -15,6 +15,7 @@ class Kernel extends HttpKernel
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class, \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\BookStack\Http\Middleware\TrimStrings::class, \BookStack\Http\Middleware\TrimStrings::class,
\BookStack\Http\Middleware\TrustProxies::class, \BookStack\Http\Middleware\TrustProxies::class,
\BookStack\Http\Middleware\PreventResponseCaching::class,
]; ];
/** /**
@ -30,7 +31,6 @@ class Kernel extends HttpKernel
\Illuminate\Session\Middleware\StartSession::class, \Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class,
\BookStack\Http\Middleware\VerifyCsrfToken::class, \BookStack\Http\Middleware\VerifyCsrfToken::class,
\BookStack\Http\Middleware\PreventAuthenticatedResponseCaching::class,
\BookStack\Http\Middleware\CheckEmailConfirmed::class, \BookStack\Http\Middleware\CheckEmailConfirmed::class,
\BookStack\Http\Middleware\RunThemeActions::class, \BookStack\Http\Middleware\RunThemeActions::class,
\BookStack\Http\Middleware\Localization::class, \BookStack\Http\Middleware\Localization::class,
@ -40,7 +40,6 @@ class Kernel extends HttpKernel
\BookStack\Http\Middleware\EncryptCookies::class, \BookStack\Http\Middleware\EncryptCookies::class,
\BookStack\Http\Middleware\StartSessionIfCookieExists::class, \BookStack\Http\Middleware\StartSessionIfCookieExists::class,
\BookStack\Http\Middleware\ApiAuthenticate::class, \BookStack\Http\Middleware\ApiAuthenticate::class,
\BookStack\Http\Middleware\PreventAuthenticatedResponseCaching::class,
\BookStack\Http\Middleware\CheckEmailConfirmed::class, \BookStack\Http\Middleware\CheckEmailConfirmed::class,
], ],
]; ];

View File

@ -31,7 +31,7 @@ class ApiAuthenticate
{ {
// Return if the user is already found to be signed in via session-based auth. // Return if the user is already found to be signed in via session-based auth.
// This is to make it easy to browser the API via browser after just logging into the system. // This is to make it easy to browser the API via browser after just logging into the system.
if (signedInUser() || session()->isStarted()) { if (!user()->isGuest() || session()->isStarted()) {
if (!$this->sessionUserHasApiAccess()) { if (!$this->sessionUserHasApiAccess()) {
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403); throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
} }
@ -53,6 +53,6 @@ class ApiAuthenticate
{ {
$hasApiPermission = user()->can('access-api'); $hasApiPermission = user()->can('access-api');
return $hasApiPermission && hasAppAccess(); return $hasApiPermission && user()->hasAppAccess();
} }
} }

View File

@ -12,7 +12,7 @@ class Authenticate
*/ */
public function handle(Request $request, Closure $next) public function handle(Request $request, Closure $next)
{ {
if (!hasAppAccess()) { if (!user()->hasAppAccess()) {
if ($request->ajax()) { if ($request->ajax()) {
return response('Unauthorized.', 401); return response('Unauthorized.', 401);
} }

View File

@ -2,17 +2,14 @@
namespace BookStack\Http\Middleware; namespace BookStack\Http\Middleware;
use BookStack\Translation\LanguageManager; use BookStack\Translation\LocaleManager;
use Carbon\Carbon;
use Closure; use Closure;
class Localization class Localization
{ {
protected LanguageManager $languageManager; public function __construct(
protected LocaleManager $localeManager
public function __construct(LanguageManager $languageManager) ) {
{
$this->languageManager = $languageManager;
} }
/** /**
@ -25,22 +22,12 @@ class Localization
*/ */
public function handle($request, Closure $next) public function handle($request, Closure $next)
{ {
// Get and record the default language in the config // Share details of the user's locale for use in views
$defaultLang = config('app.locale'); $userLocale = $this->localeManager->getForUser(user());
config()->set('app.default_locale', $defaultLang); view()->share('locale', $userLocale);
// Get the user's language and record that in the config for use in views // Set locale for system components
$userLang = $this->languageManager->getUserLanguage($request, $defaultLang); app()->setLocale($userLocale->appLocale());
config()->set('app.lang', str_replace('_', '-', $this->languageManager->getIsoName($userLang)));
// Set text direction
if ($this->languageManager->isRTL($userLang)) {
config()->set('app.rtl', true);
}
app()->setLocale($userLang);
Carbon::setLocale($userLang);
$this->languageManager->setPhpDateTimeLocale($userLang);
return $next($request); return $next($request);
} }

View File

@ -5,7 +5,7 @@ namespace BookStack\Http\Middleware;
use Closure; use Closure;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class PreventAuthenticatedResponseCaching class PreventResponseCaching
{ {
/** /**
* Handle an incoming request. * Handle an incoming request.
@ -20,11 +20,8 @@ class PreventAuthenticatedResponseCaching
/** @var Response $response */ /** @var Response $response */
$response = $next($request); $response = $next($request);
if (signedInUser()) { $response->headers->set('Cache-Control', 'no-cache, no-store, private');
$response->headers->set('Cache-Control', 'max-age=0, no-store, private'); $response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');
}
return $response; return $response;
} }

View File

@ -1,26 +0,0 @@
<?php
namespace BookStack\Notifications;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class UserInvite extends MailNotification
{
public function __construct(
public string $token
) {
}
public function toMail(User $notifiable): MailMessage
{
$appName = ['appName' => setting('app-name')];
$language = $notifiable->getLanguage();
return $this->newMailMessage($language)
->subject(trans('auth.user_invite_email_subject', $appName, $language))
->greeting(trans('auth.user_invite_email_greeting', $appName, $language))
->line(trans('auth.user_invite_email_text', [], $language))
->action(trans('auth.user_invite_email_action', [], $language), url('/register/invite/' . $this->token));
}
}

View File

@ -44,8 +44,8 @@ class SearchOptions
$inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']); $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
$parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? ''); $parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? '');
$instance->searches = $parsedStandardTerms['terms']; $instance->searches = array_filter($parsedStandardTerms['terms']);
$instance->exacts = $parsedStandardTerms['exacts']; $instance->exacts = array_filter($parsedStandardTerms['exacts']);
array_push($instance->exacts, ...array_filter($inputs['exact'] ?? [])); array_push($instance->exacts, ...array_filter($inputs['exact'] ?? []));
@ -78,7 +78,7 @@ class SearchOptions
]; ];
$patterns = [ $patterns = [
'exacts' => '/"(.*?)"/', 'exacts' => '/"((?:\\\\.|[^"\\\\])*)"/',
'tags' => '/\[(.*?)\]/', 'tags' => '/\[(.*?)\]/',
'filters' => '/\{(.*?)\}/', 'filters' => '/\{(.*?)\}/',
]; ];
@ -93,6 +93,11 @@ class SearchOptions
} }
} }
// Unescape exacts and backslash escapes
foreach ($terms['exacts'] as $index => $exact) {
$terms['exacts'][$index] = static::decodeEscapes($exact);
}
// Parse standard terms // Parse standard terms
$parsedStandardTerms = static::parseStandardTermString($searchString); $parsedStandardTerms = static::parseStandardTermString($searchString);
array_push($terms['searches'], ...$parsedStandardTerms['terms']); array_push($terms['searches'], ...$parsedStandardTerms['terms']);
@ -106,12 +111,41 @@ class SearchOptions
} }
$terms['filters'] = $splitFilters; $terms['filters'] = $splitFilters;
// Filter down terms where required
$terms['exacts'] = array_filter($terms['exacts']);
$terms['searches'] = array_filter($terms['searches']);
return $terms; return $terms;
} }
/**
* Decode backslash escaping within the input string.
*/
protected static function decodeEscapes(string $input): string
{
$decoded = "";
$escaping = false;
foreach (str_split($input) as $char) {
if ($escaping) {
$decoded .= $char;
$escaping = false;
} else if ($char === '\\') {
$escaping = true;
} else {
$decoded .= $char;
}
}
return $decoded;
}
/** /**
* Parse a standard search term string into individual search terms and * Parse a standard search term string into individual search terms and
* extract any exact terms searches to be made. * convert any required terms to exact matches. This is done since some
* characters will never be in the standard index, since we use them as
* delimiters, and therefore we convert a term to be exact if it
* contains one of those delimiter characters.
* *
* @return array{terms: array<string>, exacts: array<string>} * @return array{terms: array<string>, exacts: array<string>}
*/ */
@ -129,8 +163,8 @@ class SearchOptions
continue; continue;
} }
$parsedList = (strpbrk($searchTerm, $indexDelimiters) === false) ? 'terms' : 'exacts'; $becomeExact = (strpbrk($searchTerm, $indexDelimiters) !== false);
$parsed[$parsedList][] = $searchTerm; $parsed[$becomeExact ? 'exacts' : 'terms'][] = $searchTerm;
} }
return $parsed; return $parsed;
@ -141,20 +175,22 @@ class SearchOptions
*/ */
public function toString(): string public function toString(): string
{ {
$string = implode(' ', $this->searches ?? []); $parts = $this->searches;
foreach ($this->exacts as $term) { foreach ($this->exacts as $term) {
$string .= ' "' . $term . '"'; $escaped = str_replace('\\', '\\\\', $term);
$escaped = str_replace('"', '\"', $escaped);
$parts[] = '"' . $escaped . '"';
} }
foreach ($this->tags as $term) { foreach ($this->tags as $term) {
$string .= " [{$term}]"; $parts[] = "[{$term}]";
} }
foreach ($this->filters as $filterName => $filterVal) { foreach ($this->filters as $filterName => $filterVal) {
$string .= ' {' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}'; $parts[] = '{' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
} }
return $string; return implode(' ', $parts);
} }
} }

View File

@ -5,7 +5,6 @@ namespace BookStack\Settings;
use BookStack\Activity\ActivityType; use BookStack\Activity\ActivityType;
use BookStack\Entities\Tools\TrashCan; use BookStack\Entities\Tools\TrashCan;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Notifications\TestEmail;
use BookStack\References\ReferenceStore; use BookStack\References\ReferenceStore;
use BookStack\Uploads\ImageService; use BookStack\Uploads\ImageService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -69,7 +68,7 @@ class MaintenanceController extends Controller
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email'); $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email');
try { try {
user()->notifyNow(new TestEmail()); user()->notifyNow(new TestEmailNotification());
$this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email])); $this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
} catch (\Exception $exception) { } catch (\Exception $exception) {
$errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage(); $errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();

View File

@ -34,7 +34,7 @@ class SettingController extends Controller
return view('settings.' . $category, [ return view('settings.' . $category, [
'category' => $category, 'category' => $category,
'version' => $version, 'version' => $version,
'guestUser' => User::getDefault(), 'guestUser' => User::getGuest(),
]); ]);
} }

View File

@ -47,7 +47,7 @@ class SettingService
$default = config('setting-defaults.user.' . $key, false); $default = config('setting-defaults.user.' . $key, false);
} }
if ($user->isDefault()) { if ($user->isGuest()) {
return $this->getFromSession($key, $default); return $this->getFromSession($key, $default);
} }
@ -206,7 +206,7 @@ class SettingService
*/ */
public function putUser(User $user, string $key, string $value): bool public function putUser(User $user, string $key, string $value): bool
{ {
if ($user->isDefault()) { if ($user->isGuest()) {
session()->put($key, $value); session()->put($key, $value);
return true; return true;

View File

@ -1,11 +1,12 @@
<?php <?php
namespace BookStack\Notifications; namespace BookStack\Settings;
use BookStack\App\MailNotification;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Notifications\Messages\MailMessage;
class TestEmail extends MailNotification class TestEmailNotification extends MailNotification
{ {
public function toMail(User $notifiable): MailMessage public function toMail(User $notifiable): MailMessage
{ {

View File

@ -3,19 +3,23 @@
namespace BookStack\Theming; namespace BookStack\Theming;
use BookStack\Access\SocialAuthService; use BookStack\Access\SocialAuthService;
use BookStack\Exceptions\ThemeException;
use Illuminate\Console\Application; use Illuminate\Console\Application;
use Illuminate\Console\Application as Artisan; use Illuminate\Console\Application as Artisan;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
class ThemeService class ThemeService
{ {
protected $listeners = []; /**
* @var array<string, callable[]>
*/
protected array $listeners = [];
/** /**
* Listen to a given custom theme event, * Listen to a given custom theme event,
* setting up the action to be ran when the event occurs. * setting up the action to be ran when the event occurs.
*/ */
public function listen(string $event, callable $action) public function listen(string $event, callable $action): void
{ {
if (!isset($this->listeners[$event])) { if (!isset($this->listeners[$event])) {
$this->listeners[$event] = []; $this->listeners[$event] = [];
@ -31,10 +35,8 @@ class ThemeService
* *
* If a callback returns a non-null value, this method will * If a callback returns a non-null value, this method will
* stop and return that value itself. * stop and return that value itself.
*
* @return mixed
*/ */
public function dispatch(string $event, ...$args) public function dispatch(string $event, ...$args): mixed
{ {
foreach ($this->listeners[$event] ?? [] as $action) { foreach ($this->listeners[$event] ?? [] as $action) {
$result = call_user_func_array($action, $args); $result = call_user_func_array($action, $args);
@ -49,7 +51,7 @@ class ThemeService
/** /**
* Register a new custom artisan command to be available. * Register a new custom artisan command to be available.
*/ */
public function registerCommand(Command $command) public function registerCommand(Command $command): void
{ {
Artisan::starting(function (Application $application) use ($command) { Artisan::starting(function (Application $application) use ($command) {
$application->addCommands([$command]); $application->addCommands([$command]);
@ -59,18 +61,22 @@ class ThemeService
/** /**
* Read any actions from the set theme path if the 'functions.php' file exists. * Read any actions from the set theme path if the 'functions.php' file exists.
*/ */
public function readThemeActions() public function readThemeActions(): void
{ {
$themeActionsFile = theme_path('functions.php'); $themeActionsFile = theme_path('functions.php');
if ($themeActionsFile && file_exists($themeActionsFile)) { if ($themeActionsFile && file_exists($themeActionsFile)) {
require $themeActionsFile; try {
require $themeActionsFile;
} catch (\Error $exception) {
throw new ThemeException("Failed loading theme functions file at \"{$themeActionsFile}\" with error: {$exception->getMessage()}");
}
} }
} }
/** /**
* @see SocialAuthService::addSocialDriver * @see SocialAuthService::addSocialDriver
*/ */
public function addSocialDriver(string $driverName, array $config, string $socialiteHandler, callable $configureForRedirect = null) public function addSocialDriver(string $driverName, array $config, string $socialiteHandler, callable $configureForRedirect = null): void
{ {
$socialAuthService = app()->make(SocialAuthService::class); $socialAuthService = app()->make(SocialAuthService::class);
$socialAuthService->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect); $socialAuthService->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect);

View File

@ -1,147 +0,0 @@
<?php
namespace BookStack\Translation;
use BookStack\Users\Models\User;
use Illuminate\Http\Request;
class LanguageManager
{
/**
* Array of right-to-left language options.
*/
protected array $rtlLanguages = ['ar', 'fa', 'he'];
/**
* Map of BookStack language names to best-estimate ISO and windows locale names.
* Locales can often be found by running `locale -a` on a linux system.
* Windows locales can be found at:
* https://docs.microsoft.com/en-us/cpp/c-runtime-library/language-strings?view=msvc-170.
*
* @var array<string, array{iso: string, windows: string}>
*/
protected array $localeMap = [
'ar' => ['iso' => 'ar', 'windows' => 'Arabic'],
'bg' => ['iso' => 'bg_BG', 'windows' => 'Bulgarian'],
'bs' => ['iso' => 'bs_BA', 'windows' => 'Bosnian (Latin)'],
'ca' => ['iso' => 'ca', 'windows' => 'Catalan'],
'cs' => ['iso' => 'cs_CZ', 'windows' => 'Czech'],
'da' => ['iso' => 'da_DK', 'windows' => 'Danish'],
'de' => ['iso' => 'de_DE', 'windows' => 'German'],
'de_informal' => ['iso' => 'de_DE', 'windows' => 'German'],
'en' => ['iso' => 'en_GB', 'windows' => 'English'],
'el' => ['iso' => 'el_GR', 'windows' => 'Greek'],
'es' => ['iso' => 'es_ES', 'windows' => 'Spanish'],
'es_AR' => ['iso' => 'es_AR', 'windows' => 'Spanish'],
'et' => ['iso' => 'et_EE', 'windows' => 'Estonian'],
'eu' => ['iso' => 'eu_ES', 'windows' => 'Basque'],
'fa' => ['iso' => 'fa_IR', 'windows' => 'Persian'],
'fr' => ['iso' => 'fr_FR', 'windows' => 'French'],
'he' => ['iso' => 'he_IL', 'windows' => 'Hebrew'],
'hr' => ['iso' => 'hr_HR', 'windows' => 'Croatian'],
'hu' => ['iso' => 'hu_HU', 'windows' => 'Hungarian'],
'id' => ['iso' => 'id_ID', 'windows' => 'Indonesian'],
'it' => ['iso' => 'it_IT', 'windows' => 'Italian'],
'ja' => ['iso' => 'ja', 'windows' => 'Japanese'],
'ko' => ['iso' => 'ko_KR', 'windows' => 'Korean'],
'lt' => ['iso' => 'lt_LT', 'windows' => 'Lithuanian'],
'lv' => ['iso' => 'lv_LV', 'windows' => 'Latvian'],
'nl' => ['iso' => 'nl_NL', 'windows' => 'Dutch'],
'nb' => ['iso' => 'nb_NO', 'windows' => 'Norwegian (Bokmal)'],
'pl' => ['iso' => 'pl_PL', 'windows' => 'Polish'],
'pt' => ['iso' => 'pt_PT', 'windows' => 'Portuguese'],
'pt_BR' => ['iso' => 'pt_BR', 'windows' => 'Portuguese'],
'ro' => ['iso' => 'ro_RO', 'windows' => 'Romanian'],
'ru' => ['iso' => 'ru', 'windows' => 'Russian'],
'sk' => ['iso' => 'sk_SK', 'windows' => 'Slovak'],
'sl' => ['iso' => 'sl_SI', 'windows' => 'Slovenian'],
'sv' => ['iso' => 'sv_SE', 'windows' => 'Swedish'],
'uk' => ['iso' => 'uk_UA', 'windows' => 'Ukrainian'],
'vi' => ['iso' => 'vi_VN', 'windows' => 'Vietnamese'],
'zh_CN' => ['iso' => 'zh_CN', 'windows' => 'Chinese (Simplified)'],
'zh_TW' => ['iso' => 'zh_TW', 'windows' => 'Chinese (Traditional)'],
'tr' => ['iso' => 'tr_TR', 'windows' => 'Turkish'],
];
/**
* Get the language specifically for the currently logged-in user if available.
*/
public function getUserLanguage(Request $request, string $default): string
{
try {
$user = user();
} catch (\Exception $exception) {
return $default;
}
if ($user->isDefault() && config('app.auto_detect_locale')) {
return $this->autoDetectLocale($request, $default);
}
return setting()->getUser($user, 'language', $default);
}
/**
* Get the language for the given user.
*/
public function getLanguageForUser(User $user): string
{
$default = config('app.locale');
return setting()->getUser($user, 'language', $default);
}
/**
* Check if the given BookStack language value is a right-to-left language.
*/
public function isRTL(string $language): bool
{
return in_array($language, $this->rtlLanguages);
}
/**
* Autodetect the visitors locale by matching locales in their headers
* against the locales supported by BookStack.
*/
protected function autoDetectLocale(Request $request, string $default): string
{
$availableLocales = config('app.locales');
foreach ($request->getLanguages() as $lang) {
if (in_array($lang, $availableLocales)) {
return $lang;
}
}
return $default;
}
/**
* Get the ISO version of a BookStack language name.
*/
public function getIsoName(string $language): string
{
return $this->localeMap[$language]['iso'] ?? $language;
}
/**
* Set the system date locale for localized date formatting.
* Will try both the standard locale name and the UTF8 variant.
*/
public function setPhpDateTimeLocale(string $language): void
{
$isoLang = $this->localeMap[$language]['iso'] ?? '';
$isoLangPrefix = explode('_', $isoLang)[0];
$locales = array_values(array_filter([
$isoLang ? $isoLang . '.utf8' : false,
$isoLang ?: false,
$isoLang ? str_replace('_', '-', $isoLang) : false,
$isoLang ? $isoLangPrefix . '.UTF-8' : false,
$this->localeMap[$language]['windows'] ?? false,
$language,
]));
if (!empty($locales)) {
setlocale(LC_TIME, $locales[0], ...array_slice($locales, 1));
}
}
}

View File

@ -0,0 +1,53 @@
<?php
namespace BookStack\Translation;
class LocaleDefinition
{
public function __construct(
protected string $appName,
protected string $isoName,
protected bool $isRtl
) {
}
/**
* Provide the BookStack-specific locale name.
*/
public function appLocale(): string
{
return $this->appName;
}
/**
* Provide the ISO-aligned locale name.
*/
public function isoLocale(): string
{
return $this->isoName;
}
/**
* Returns a string suitable for the HTML "lang" attribute.
*/
public function htmlLang(): string
{
return str_replace('_', '-', $this->isoName);
}
/**
* Returns a string suitable for the HTML "dir" attribute.
*/
public function htmlDirection(): string
{
return $this->isRtl ? 'rtl' : 'ltr';
}
/**
* Translate using this locate.
*/
public function trans(string $key, array $replace = []): string
{
return trans($key, $replace, $this->appLocale());
}
}

View File

@ -0,0 +1,122 @@
<?php
namespace BookStack\Translation;
use BookStack\Users\Models\User;
use Illuminate\Http\Request;
class LocaleManager
{
/**
* Array of right-to-left locale options.
*/
protected array $rtlLocales = ['ar', 'fa', 'he'];
/**
* Map of BookStack locale names to best-estimate ISO locale names.
* Locales can often be found by running `locale -a` on a linux system.
*
* @var array<string, string>
*/
protected array $localeMap = [
'ar' => 'ar',
'bg' => 'bg_BG',
'bs' => 'bs_BA',
'ca' => 'ca',
'cs' => 'cs_CZ',
'cy' => 'cy_GB',
'da' => 'da_DK',
'de' => 'de_DE',
'de_informal' => 'de_DE',
'el' => 'el_GR',
'en' => 'en_GB',
'es' => 'es_ES',
'es_AR' => 'es_AR',
'et' => 'et_EE',
'eu' => 'eu_ES',
'fa' => 'fa_IR',
'fi' => 'fi_FI',
'fr' => 'fr_FR',
'he' => 'he_IL',
'hr' => 'hr_HR',
'hu' => 'hu_HU',
'id' => 'id_ID',
'it' => 'it_IT',
'ja' => 'ja',
'ka' => 'ka_GE',
'ko' => 'ko_KR',
'lt' => 'lt_LT',
'lv' => 'lv_LV',
'nb' => 'nb_NO',
'nl' => 'nl_NL',
'nn' => 'nn_NO',
'pl' => 'pl_PL',
'pt' => 'pt_PT',
'pt_BR' => 'pt_BR',
'ro' => 'ro_RO',
'ru' => 'ru',
'sk' => 'sk_SK',
'sl' => 'sl_SI',
'sq' => 'sq_AL',
'sv' => 'sv_SE',
'tr' => 'tr_TR',
'uk' => 'uk_UA',
'uz' => 'uz_UZ',
'vi' => 'vi_VN',
'zh_CN' => 'zh_CN',
'zh_TW' => 'zh_TW',
];
/**
* Get the BookStack locale string for the given user.
*/
protected function getLocaleForUser(User $user): string
{
$default = config('app.default_locale');
if ($user->isGuest() && config('app.auto_detect_locale')) {
return $this->autoDetectLocale(request(), $default);
}
return setting()->getUser($user, 'language', $default);
}
/**
* Get a locale definition for the current user.
*/
public function getForUser(User $user): LocaleDefinition
{
$localeString = $this->getLocaleForUser($user);
return new LocaleDefinition(
$localeString,
$this->localeMap[$localeString] ?? $localeString,
in_array($localeString, $this->rtlLocales),
);
}
/**
* Autodetect the visitors locale by matching locales in their headers
* against the locales supported by BookStack.
*/
protected function autoDetectLocale(Request $request, string $default): string
{
$availableLocales = $this->getAllAppLocales();
foreach ($request->getLanguages() as $lang) {
if (in_array($lang, $availableLocales)) {
return $lang;
}
}
return $default;
}
/**
* Get all the available app-specific level locale strings.
*/
public function getAllAppLocales(): array
{
return array_keys($this->localeMap);
}
}

View File

@ -5,23 +5,23 @@ namespace BookStack\Uploads\Controllers;
use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\ImageUploadException;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageResizer;
use BookStack\Util\OutOfMemoryHandler;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class DrawioImageController extends Controller class DrawioImageController extends Controller
{ {
protected $imageRepo; public function __construct(
protected ImageRepo $imageRepo
public function __construct(ImageRepo $imageRepo) ) {
{
$this->imageRepo = $imageRepo;
} }
/** /**
* Get a list of gallery images, in a list. * Get a list of gallery images, in a list.
* Can be paged and filtered by entity. * Can be paged and filtered by entity.
*/ */
public function list(Request $request) public function list(Request $request, ImageResizer $resizer)
{ {
$page = $request->get('page', 1); $page = $request->get('page', 1);
$searchTerm = $request->get('search', null); $searchTerm = $request->get('search', null);
@ -29,11 +29,20 @@ class DrawioImageController extends Controller
$parentTypeFilter = $request->get('filter_type', null); $parentTypeFilter = $request->get('filter_type', null);
$imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm); $imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
$viewData = [
return view('pages.parts.image-manager-list', [ 'warning' => '',
'images' => $imgData['images'], 'images' => $imgData['images'],
'hasMore' => $imgData['has_more'], 'hasMore' => $imgData['has_more'],
]); ];
new OutOfMemoryHandler(function () use ($viewData) {
$viewData['warning'] = trans('errors.image_gallery_thumbnail_memory_limit');
return response()->view('pages.parts.image-manager-list', $viewData, 200);
});
$resizer->loadGalleryThumbnailsForMany($imgData['images']);
return view('pages.parts.image-manager-list', $viewData);
} }
/** /**

View File

@ -5,7 +5,11 @@ namespace BookStack\Uploads\Controllers;
use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\ImageUploadException;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageResizer;
use BookStack\Util\OutOfMemoryHandler;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class GalleryImageController extends Controller class GalleryImageController extends Controller
@ -19,7 +23,7 @@ class GalleryImageController extends Controller
* Get a list of gallery images, in a list. * Get a list of gallery images, in a list.
* Can be paged and filtered by entity. * Can be paged and filtered by entity.
*/ */
public function list(Request $request) public function list(Request $request, ImageResizer $resizer)
{ {
$page = $request->get('page', 1); $page = $request->get('page', 1);
$searchTerm = $request->get('search', null); $searchTerm = $request->get('search', null);
@ -27,11 +31,20 @@ class GalleryImageController extends Controller
$parentTypeFilter = $request->get('filter_type', null); $parentTypeFilter = $request->get('filter_type', null);
$imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 30, $uploadedToFilter, $searchTerm); $imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 30, $uploadedToFilter, $searchTerm);
$viewData = [
return view('pages.parts.image-manager-list', [ 'warning' => '',
'images' => $imgData['images'], 'images' => $imgData['images'],
'hasMore' => $imgData['has_more'], 'hasMore' => $imgData['has_more'],
]); ];
new OutOfMemoryHandler(function () use ($viewData) {
$viewData['warning'] = trans('errors.image_gallery_thumbnail_memory_limit');
return response()->view('pages.parts.image-manager-list', $viewData, 200);
});
$resizer->loadGalleryThumbnailsForMany($imgData['images']);
return view('pages.parts.image-manager-list', $viewData);
} }
/** /**
@ -51,6 +64,10 @@ class GalleryImageController extends Controller
return $this->jsonError(implode("\n", $exception->errors()['file'])); return $this->jsonError(implode("\n", $exception->errors()['file']));
} }
new OutOfMemoryHandler(function () {
return $this->jsonError(trans('errors.image_upload_memory_limit'));
});
try { try {
$imageUpload = $request->file('file'); $imageUpload = $request->file('file');
$uploadedTo = $request->get('uploaded_to', 0); $uploadedTo = $request->get('uploaded_to', 0);

View File

@ -4,19 +4,22 @@ namespace BookStack\Uploads\Controllers;
use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageResizer;
use BookStack\Uploads\ImageService; use BookStack\Uploads\ImageService;
use BookStack\Util\OutOfMemoryHandler;
use Exception; use Exception;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class ImageController extends Controller class ImageController extends Controller
{ {
public function __construct( public function __construct(
protected ImageRepo $imageRepo, protected ImageRepo $imageRepo,
protected ImageService $imageService protected ImageService $imageService,
protected ImageResizer $imageResizer,
) { ) {
} }
@ -38,13 +41,10 @@ class ImageController extends Controller
/** /**
* Update image details. * Update image details.
*
* @throws ImageUploadException
* @throws ValidationException
*/ */
public function update(Request $request, string $id) public function update(Request $request, string $id)
{ {
$this->validate($request, [ $data = $this->validate($request, [
'name' => ['required', 'min:2', 'string'], 'name' => ['required', 'min:2', 'string'],
]); ]);
@ -52,9 +52,7 @@ class ImageController extends Controller
$this->checkImagePermission($image); $this->checkImagePermission($image);
$this->checkOwnablePermission('image-update', $image); $this->checkOwnablePermission('image-update', $image);
$image = $this->imageRepo->updateImageDetails($image, $request->all()); $image = $this->imageRepo->updateImageDetails($image, $data);
$this->imageRepo->loadThumbs($image);
return view('pages.parts.image-manager-form', [ return view('pages.parts.image-manager-form', [
'image' => $image, 'image' => $image,
@ -76,6 +74,10 @@ class ImageController extends Controller
$this->checkOwnablePermission('image-update', $image); $this->checkOwnablePermission('image-update', $image);
$file = $request->file('file'); $file = $request->file('file');
new OutOfMemoryHandler(function () {
return $this->jsonError(trans('errors.image_upload_memory_limit'));
});
try { try {
$this->imageRepo->updateImageFile($image, $file); $this->imageRepo->updateImageFile($image, $file);
} catch (ImageUploadException $exception) { } catch (ImageUploadException $exception) {
@ -99,12 +101,20 @@ class ImageController extends Controller
$dependantPages = $this->imageRepo->getPagesUsingImage($image); $dependantPages = $this->imageRepo->getPagesUsingImage($image);
} }
$this->imageRepo->loadThumbs($image); $viewData = [
return view('pages.parts.image-manager-form', [
'image' => $image, 'image' => $image,
'dependantPages' => $dependantPages ?? null, 'dependantPages' => $dependantPages ?? null,
]); 'warning' => '',
];
new OutOfMemoryHandler(function () use ($viewData) {
$viewData['warning'] = trans('errors.image_thumbnail_memory_limit');
return response()->view('pages.parts.image-manager-form', $viewData);
});
$this->imageResizer->loadGalleryThumbnailsForImage($image, false);
return view('pages.parts.image-manager-form', $viewData);
} }
/** /**
@ -124,9 +134,28 @@ class ImageController extends Controller
} }
/** /**
* Check related page permission and ensure type is drawio or gallery. * Rebuild the thumbnails for the given image.
*/ */
protected function checkImagePermission(Image $image) public function rebuildThumbnails(string $id)
{
$image = $this->imageRepo->getById($id);
$this->checkImagePermission($image);
$this->checkOwnablePermission('image-update', $image);
new OutOfMemoryHandler(function () {
return $this->jsonError(trans('errors.image_thumbnail_memory_limit'));
});
$this->imageResizer->loadGalleryThumbnailsForImage($image, true);
return response(trans('components.image_rebuild_thumbs_success'));
}
/**
* Check related page permission and ensure type is drawio or gallery.
* @throws NotifyException
*/
protected function checkImagePermission(Image $image): void
{ {
if ($image->type !== 'drawio' && $image->type !== 'gallery') { if ($image->type !== 'drawio' && $image->type !== 'gallery') {
$this->showPermissionError(); $this->showPermissionError();

View File

@ -6,6 +6,7 @@ use BookStack\Entities\Models\Page;
use BookStack\Http\ApiController; use BookStack\Http\ApiController;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageResizer;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class ImageGalleryApiController extends ApiController class ImageGalleryApiController extends ApiController
@ -15,7 +16,8 @@ class ImageGalleryApiController extends ApiController
]; ];
public function __construct( public function __construct(
protected ImageRepo $imageRepo protected ImageRepo $imageRepo,
protected ImageResizer $imageResizer,
) { ) {
} }
@ -130,7 +132,7 @@ class ImageGalleryApiController extends ApiController
*/ */
protected function formatForSingleResponse(Image $image): array protected function formatForSingleResponse(Image $image): array
{ {
$this->imageRepo->loadThumbs($image); $this->imageResizer->loadGalleryThumbnailsForImage($image, false);
$data = $image->toArray(); $data = $image->toArray();
$data['created_by'] = $image->createdBy; $data['created_by'] = $image->createdBy;
$data['updated_by'] = $image->updatedBy; $data['updated_by'] = $image->updatedBy;
@ -138,6 +140,7 @@ class ImageGalleryApiController extends ApiController
$escapedUrl = htmlentities($image->url); $escapedUrl = htmlentities($image->url);
$escapedName = htmlentities($image->name); $escapedName = htmlentities($image->name);
if ($image->type === 'drawio') { if ($image->type === 'drawio') {
$data['content']['html'] = "<div drawio-diagram=\"{$image->id}\"><img src=\"{$escapedUrl}\"></div>"; $data['content']['html'] = "<div drawio-diagram=\"{$image->id}\"><img src=\"{$escapedUrl}\"></div>";
$data['content']['markdown'] = $data['content']['html']; $data['content']['markdown'] = $data['content']['html'];

View File

@ -1,38 +0,0 @@
<?php
namespace BookStack\Uploads;
use BookStack\Exceptions\HttpFetchException;
class HttpFetcher
{
/**
* Fetch content from an external URI.
*
* @param string $uri
*
* @throws HttpFetchException
*
* @return bool|string
*/
public function fetch(string $uri)
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $uri,
CURLOPT_RETURNTRANSFER => 1,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$data = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($err) {
$errno = curl_errno($ch);
throw new HttpFetchException($err, $errno);
}
return $data;
}
}

View File

@ -45,13 +45,14 @@ class Image extends Model
} }
/** /**
* Get a thumbnail for this image. * Get a thumbnail URL for this image.
* Attempts to generate the thumbnail if not already existing.
* *
* @throws \Exception * @throws \Exception
*/ */
public function getThumb(?int $width, ?int $height, bool $keepRatio = false): string public function getThumb(?int $width, ?int $height, bool $keepRatio = false): ?string
{ {
return app()->make(ImageService::class)->getThumbnail($this, $width, $height, $keepRatio); return app()->make(ImageResizer::class)->resizeToThumbnailUrl($this, $width, $height, $keepRatio, false);
} }
/** /**

View File

@ -13,7 +13,8 @@ class ImageRepo
{ {
public function __construct( public function __construct(
protected ImageService $imageService, protected ImageService $imageService,
protected PermissionApplicator $permissions protected PermissionApplicator $permissions,
protected ImageResizer $imageResizer,
) { ) {
} }
@ -29,19 +30,13 @@ class ImageRepo
* Execute a paginated query, returning in a standard format. * Execute a paginated query, returning in a standard format.
* Also runs the query through the restriction system. * Also runs the query through the restriction system.
*/ */
private function returnPaginated($query, $page = 1, $pageSize = 24): array protected function returnPaginated(Builder $query, int $page = 1, int $pageSize = 24): array
{ {
$images = $query->orderBy('created_at', 'desc')->skip($pageSize * ($page - 1))->take($pageSize + 1)->get(); $images = $query->orderBy('created_at', 'desc')->skip($pageSize * ($page - 1))->take($pageSize + 1)->get();
$hasMore = count($images) > $pageSize;
$returnImages = $images->take($pageSize);
$returnImages->each(function (Image $image) {
$this->loadThumbs($image);
});
return [ return [
'images' => $returnImages, 'images' => $images->take($pageSize),
'has_more' => $hasMore, 'has_more' => count($images) > $pageSize,
]; ];
} }
@ -119,7 +114,7 @@ class ImageRepo
$image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio); $image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio);
if ($type !== 'system') { if ($type !== 'system') {
$this->loadThumbs($image); $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
} }
return $image; return $image;
@ -133,7 +128,7 @@ class ImageRepo
public function saveNewFromData(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image public function saveNewFromData(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
{ {
$image = $this->imageService->saveNew($imageName, $imageData, $type, $uploadedTo); $image = $this->imageService->saveNew($imageName, $imageData, $type, $uploadedTo);
$this->loadThumbs($image); $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
return $image; return $image;
} }
@ -160,7 +155,7 @@ class ImageRepo
$image->fill($updateDetails); $image->fill($updateDetails);
$image->updated_by = user()->id; $image->updated_by = user()->id;
$image->save(); $image->save();
$this->loadThumbs($image); $this->imageResizer->loadGalleryThumbnailsForImage($image, false);
return $image; return $image;
} }
@ -179,8 +174,9 @@ class ImageRepo
$image->updated_by = user()->id; $image->updated_by = user()->id;
$image->touch(); $image->touch();
$image->save(); $image->save();
$this->imageService->replaceExistingFromUpload($image->path, $image->type, $file); $this->imageService->replaceExistingFromUpload($image->path, $image->type, $file);
$this->loadThumbs($image, true); $this->imageResizer->loadGalleryThumbnailsForImage($image, true);
} }
/** /**
@ -212,31 +208,6 @@ class ImageRepo
} }
} }
/**
* Load thumbnails onto an image object.
*/
public function loadThumbs(Image $image, bool $forceCreate = false): void
{
$image->setAttribute('thumbs', [
'gallery' => $this->getThumbnail($image, 150, 150, false, $forceCreate),
'display' => $this->getThumbnail($image, 1680, null, true, $forceCreate),
]);
}
/**
* Get the thumbnail for an image.
* If $keepRatio is true only the width will be used.
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
*/
protected function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio, bool $forceCreate): ?string
{
try {
return $this->imageService->getThumbnail($image, $width, $height, $keepRatio, $forceCreate);
} catch (Exception $exception) {
return null;
}
}
/** /**
* Get the raw image data from an Image. * Get the raw image data from an Image.
*/ */

View File

@ -0,0 +1,206 @@
<?php
namespace BookStack\Uploads;
use BookStack\Exceptions\ImageUploadException;
use Exception;
use GuzzleHttp\Psr7\Utils;
use Illuminate\Support\Facades\Cache;
use Intervention\Image\Image as InterventionImage;
use Intervention\Image\ImageManager;
class ImageResizer
{
protected const THUMBNAIL_CACHE_TIME = 604_800; // 1 week
public function __construct(
protected ImageManager $intervention,
protected ImageStorage $storage,
) {
}
/**
* Load gallery thumbnails for a set of images.
* @param iterable<Image> $images
*/
public function loadGalleryThumbnailsForMany(iterable $images, bool $shouldCreate = false): void
{
foreach ($images as $image) {
$this->loadGalleryThumbnailsForImage($image, $shouldCreate);
}
}
/**
* Load gallery thumbnails into the given image instance.
*/
public function loadGalleryThumbnailsForImage(Image $image, bool $shouldCreate): void
{
$thumbs = ['gallery' => null, 'display' => null];
try {
$thumbs['gallery'] = $this->resizeToThumbnailUrl($image, 150, 150, false, $shouldCreate);
$thumbs['display'] = $this->resizeToThumbnailUrl($image, 1680, null, true, $shouldCreate);
} catch (Exception $exception) {
// Prevent thumbnail errors from stopping execution
}
$image->setAttribute('thumbs', $thumbs);
}
/**
* Get the thumbnail for an image.
* If $keepRatio is true only the width will be used.
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
*
* @throws Exception
*/
public function resizeToThumbnailUrl(
Image $image,
?int $width,
?int $height,
bool $keepRatio = false,
bool $shouldCreate = false
): ?string {
// Do not resize GIF images where we're not cropping
if ($keepRatio && $this->isGif($image)) {
return $this->storage->getPublicUrl($image->path);
}
$thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
$imagePath = $image->path;
$thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
$thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
// Return path if in cache
$cachedThumbPath = Cache::get($thumbCacheKey);
if ($cachedThumbPath && !$shouldCreate) {
return $this->storage->getPublicUrl($cachedThumbPath);
}
// If thumbnail has already been generated, serve that and cache path
$disk = $this->storage->getDisk($image->type);
if (!$shouldCreate && $disk->exists($thumbFilePath)) {
Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
return $this->storage->getPublicUrl($thumbFilePath);
}
$imageData = $disk->get($imagePath);
// Do not resize apng images where we're not cropping
if ($keepRatio && $this->isApngData($image, $imageData)) {
Cache::put($thumbCacheKey, $image->path, static::THUMBNAIL_CACHE_TIME);
return $this->storage->getPublicUrl($image->path);
}
// If not in cache and thumbnail does not exist, generate thumb and cache path
$thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio);
$disk->put($thumbFilePath, $thumbData, true);
Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
return $this->storage->getPublicUrl($thumbFilePath);
}
/**
* Resize the image of given data to the specified size, and return the new image data.
*
* @throws ImageUploadException
*/
public function resizeImageData(string $imageData, ?int $width, ?int $height, bool $keepRatio): string
{
try {
$thumb = $this->intervention->make($imageData);
} catch (Exception $e) {
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
}
$this->orientImageToOriginalExif($thumb, $imageData);
if ($keepRatio) {
$thumb->resize($width, $height, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
});
} else {
$thumb->fit($width, $height);
}
$thumbData = (string) $thumb->encode();
// Use original image data if we're keeping the ratio
// and the resizing does not save any space.
if ($keepRatio && strlen($thumbData) > strlen($imageData)) {
return $imageData;
}
return $thumbData;
}
/**
* Orientate the given intervention image based upon the given original image data.
* Intervention does have an `orientate` method but the exif data it needs is lost before it
* can be used (At least when created using binary string data) so we need to do some
* implementation on our side to use the original image data.
* Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
* Copyright (c) Oliver Vogel, MIT License.
*/
protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
{
if (!extension_loaded('exif')) {
return;
}
$stream = Utils::streamFor($originalData)->detach();
$exif = @exif_read_data($stream);
$orientation = $exif ? ($exif['Orientation'] ?? null) : null;
switch ($orientation) {
case 2:
$image->flip();
break;
case 3:
$image->rotate(180);
break;
case 4:
$image->rotate(180)->flip();
break;
case 5:
$image->rotate(270)->flip();
break;
case 6:
$image->rotate(270);
break;
case 7:
$image->rotate(90)->flip();
break;
case 8:
$image->rotate(90);
break;
}
}
/**
* Checks if the image is a gif. Returns true if it is, else false.
*/
protected function isGif(Image $image): bool
{
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
}
/**
* Check if the given image and image data is apng.
*/
protected function isApngData(Image $image, string &$imageData): bool
{
$isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
if (!$isPng) {
return false;
}
$initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
return str_contains($initialHeader, 'acTL');
}
}

View File

@ -6,109 +6,27 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\ImageUploadException;
use ErrorException;
use Exception; use Exception;
use GuzzleHttp\Psr7\Utils;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Contracts\Filesystem\Filesystem as Storage;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Filesystem\FilesystemManager;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Intervention\Image\Exception\NotSupportedException;
use Intervention\Image\Image as InterventionImage;
use Intervention\Image\ImageManager;
use League\Flysystem\WhitespacePathNormalizer;
use Psr\SimpleCache\InvalidArgumentException;
use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
class ImageService class ImageService
{ {
protected ImageManager $imageTool;
protected Cache $cache;
protected FilesystemManager $fileSystem;
protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp']; protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
public function __construct(ImageManager $imageTool, FilesystemManager $fileSystem, Cache $cache) public function __construct(
{ protected ImageStorage $storage,
$this->imageTool = $imageTool; protected ImageResizer $resizer,
$this->fileSystem = $fileSystem; ) {
$this->cache = $cache;
}
/**
* Get the storage that will be used for storing images.
*/
protected function getStorageDisk(string $imageType = ''): Storage
{
return $this->fileSystem->disk($this->getStorageDiskName($imageType));
}
/**
* Check if local secure image storage (Fetched behind authentication)
* is currently active in the instance.
*/
protected function usingSecureImages(string $imageType = 'gallery'): bool
{
return $this->getStorageDiskName($imageType) === 'local_secure_images';
}
/**
* Check if "local secure restricted" (Fetched behind auth, with permissions enforced)
* is currently active in the instance.
*/
protected function usingSecureRestrictedImages()
{
return config('filesystems.images') === 'local_secure_restricted';
}
/**
* Change the originally provided path to fit any disk-specific requirements.
* This also ensures the path is kept to the expected root folders.
*/
protected function adjustPathForStorageDisk(string $path, string $imageType = ''): string
{
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/images/', '', $path));
if ($this->usingSecureImages($imageType)) {
return $path;
}
return 'uploads/images/' . $path;
}
/**
* Get the name of the storage disk to use.
*/
protected function getStorageDiskName(string $imageType): string
{
$storageType = config('filesystems.images');
$localSecureInUse = ($storageType === 'local_secure' || $storageType === 'local_secure_restricted');
// Ensure system images (App logo) are uploaded to a public space
if ($imageType === 'system' && $localSecureInUse) {
return 'local';
}
// Rename local_secure options to get our image specific storage driver which
// is scoped to the relevant image directories.
if ($localSecureInUse) {
return 'local_secure_images';
}
return $storageType;
} }
/** /**
* Saves a new image from an upload. * Saves a new image from an upload.
* *
* @throws ImageUploadException * @throws ImageUploadException
*
* @return mixed
*/ */
public function saveNewFromUpload( public function saveNewFromUpload(
UploadedFile $uploadedFile, UploadedFile $uploadedFile,
@ -117,12 +35,12 @@ class ImageService
int $resizeWidth = null, int $resizeWidth = null,
int $resizeHeight = null, int $resizeHeight = null,
bool $keepRatio = true bool $keepRatio = true
) { ): Image {
$imageName = $uploadedFile->getClientOriginalName(); $imageName = $uploadedFile->getClientOriginalName();
$imageData = file_get_contents($uploadedFile->getRealPath()); $imageData = file_get_contents($uploadedFile->getRealPath());
if ($resizeWidth !== null || $resizeHeight !== null) { if ($resizeWidth !== null || $resizeHeight !== null) {
$imageData = $this->resizeImage($imageData, $resizeWidth, $resizeHeight, $keepRatio); $imageData = $this->resizer->resizeImageData($imageData, $resizeWidth, $resizeHeight, $keepRatio);
} }
return $this->saveNew($imageName, $imageData, $type, $uploadedTo); return $this->saveNew($imageName, $imageData, $type, $uploadedTo);
@ -151,13 +69,13 @@ class ImageService
*/ */
public function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image public function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
{ {
$storage = $this->getStorageDisk($type); $disk = $this->storage->getDisk($type);
$secureUploads = setting('app-secure-images'); $secureUploads = setting('app-secure-images');
$fileName = $this->cleanImageFileName($imageName); $fileName = $this->storage->cleanImageFileName($imageName);
$imagePath = '/uploads/images/' . $type . '/' . date('Y-m') . '/'; $imagePath = '/uploads/images/' . $type . '/' . date('Y-m') . '/';
while ($storage->exists($this->adjustPathForStorageDisk($imagePath . $fileName, $type))) { while ($disk->exists($imagePath . $fileName)) {
$fileName = Str::random(3) . $fileName; $fileName = Str::random(3) . $fileName;
} }
@ -167,7 +85,7 @@ class ImageService
} }
try { try {
$this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($fullPath, $type), $imageData); $disk->put($fullPath, $imageData, true);
} catch (Exception $e) { } catch (Exception $e) {
Log::error('Error when attempting image upload:' . $e->getMessage()); Log::error('Error when attempting image upload:' . $e->getMessage());
@ -177,7 +95,7 @@ class ImageService
$imageDetails = [ $imageDetails = [
'name' => $imageName, 'name' => $imageName,
'path' => $fullPath, 'path' => $fullPath,
'url' => $this->getPublicUrl($fullPath), 'url' => $this->storage->getPublicUrl($fullPath),
'type' => $type, 'type' => $type,
'uploaded_to' => $uploadedTo, 'uploaded_to' => $uploadedTo,
]; ];
@ -194,214 +112,26 @@ class ImageService
return $image; return $image;
} }
/**
* Replace an existing image file in the system using the given file.
*/
public function replaceExistingFromUpload(string $path, string $type, UploadedFile $file): void public function replaceExistingFromUpload(string $path, string $type, UploadedFile $file): void
{ {
$imageData = file_get_contents($file->getRealPath()); $imageData = file_get_contents($file->getRealPath());
$storage = $this->getStorageDisk($type); $disk = $this->storage->getDisk($type);
$adjustedPath = $this->adjustPathForStorageDisk($path, $type); $disk->put($path, $imageData);
$storage->put($adjustedPath, $imageData);
}
/**
* Save image data for the given path in the public space, if possible,
* for the provided storage mechanism.
*/
protected function saveImageDataInPublicSpace(Storage $storage, string $path, string $data)
{
$storage->put($path, $data);
// Set visibility when a non-AWS-s3, s3-like storage option is in use.
// Done since this call can break s3-like services but desired for other image stores.
// Attempting to set ACL during above put request requires different permissions
// hence would technically be a breaking change for actual s3 usage.
$usingS3 = strtolower(config('filesystems.images')) === 's3';
$usingS3Like = $usingS3 && !is_null(config('filesystems.disks.s3.endpoint'));
if (!$usingS3Like) {
$storage->setVisibility($path, 'public');
}
}
/**
* Clean up an image file name to be both URL and storage safe.
*/
protected function cleanImageFileName(string $name): string
{
$name = str_replace(' ', '-', $name);
$nameParts = explode('.', $name);
$extension = array_pop($nameParts);
$name = implode('-', $nameParts);
$name = Str::slug($name);
if (strlen($name) === 0) {
$name = Str::random(10);
}
return $name . '.' . $extension;
}
/**
* Checks if the image is a gif. Returns true if it is, else false.
*/
protected function isGif(Image $image): bool
{
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
}
/**
* Check if the given image and image data is apng.
*/
protected function isApngData(Image $image, string &$imageData): bool
{
$isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
if (!$isPng) {
return false;
}
$initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
return strpos($initialHeader, 'acTL') !== false;
}
/**
* Get the thumbnail for an image.
* If $keepRatio is true only the width will be used.
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
*
* @throws Exception
* @throws InvalidArgumentException
*/
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false, bool $forceCreate = false): string
{
// Do not resize GIF images where we're not cropping
if ($keepRatio && $this->isGif($image)) {
return $this->getPublicUrl($image->path);
}
$thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
$imagePath = $image->path;
$thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
$thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
// Return path if in cache
$cachedThumbPath = $this->cache->get($thumbCacheKey);
if ($cachedThumbPath && !$forceCreate) {
return $this->getPublicUrl($cachedThumbPath);
}
// If thumbnail has already been generated, serve that and cache path
$storage = $this->getStorageDisk($image->type);
if (!$forceCreate && $storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
$this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath);
}
$imageData = $storage->get($this->adjustPathForStorageDisk($imagePath, $image->type));
// Do not resize apng images where we're not cropping
if ($keepRatio && $this->isApngData($image, $imageData)) {
$this->cache->put($thumbCacheKey, $image->path, 60 * 60 * 72);
return $this->getPublicUrl($image->path);
}
// If not in cache and thumbnail does not exist, generate thumb and cache path
$thumbData = $this->resizeImage($imageData, $width, $height, $keepRatio);
$this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($thumbFilePath, $image->type), $thumbData);
$this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath);
}
/**
* Resize the image of given data to the specified size, and return the new image data.
*
* @throws ImageUploadException
*/
protected function resizeImage(string $imageData, ?int $width, ?int $height, bool $keepRatio): string
{
try {
$thumb = $this->imageTool->make($imageData);
} catch (ErrorException | NotSupportedException $e) {
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
}
$this->orientImageToOriginalExif($thumb, $imageData);
if ($keepRatio) {
$thumb->resize($width, $height, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
});
} else {
$thumb->fit($width, $height);
}
$thumbData = (string) $thumb->encode();
// Use original image data if we're keeping the ratio
// and the resizing does not save any space.
if ($keepRatio && strlen($thumbData) > strlen($imageData)) {
return $imageData;
}
return $thumbData;
}
/**
* Orientate the given intervention image based upon the given original image data.
* Intervention does have an `orientate` method but the exif data it needs is lost before it
* can be used (At least when created using binary string data) so we need to do some
* implementation on our side to use the original image data.
* Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
* Copyright (c) Oliver Vogel, MIT License.
*/
protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
{
if (!extension_loaded('exif')) {
return;
}
$stream = Utils::streamFor($originalData)->detach();
$exif = @exif_read_data($stream);
$orientation = $exif ? ($exif['Orientation'] ?? null) : null;
switch ($orientation) {
case 2:
$image->flip();
break;
case 3:
$image->rotate(180);
break;
case 4:
$image->rotate(180)->flip();
break;
case 5:
$image->rotate(270)->flip();
break;
case 6:
$image->rotate(270);
break;
case 7:
$image->rotate(90)->flip();
break;
case 8:
$image->rotate(90);
break;
}
} }
/** /**
* Get the raw data content from an image. * Get the raw data content from an image.
* *
* @throws FileNotFoundException * @throws Exception
*/ */
public function getImageData(Image $image): string public function getImageData(Image $image): string
{ {
$storage = $this->getStorageDisk(); $disk = $this->storage->getDisk();
return $storage->get($this->adjustPathForStorageDisk($image->path, $image->type)); return $disk->get($image->path);
} }
/** /**
@ -409,53 +139,13 @@ class ImageService
* *
* @throws Exception * @throws Exception
*/ */
public function destroy(Image $image) public function destroy(Image $image): void
{ {
$this->destroyImagesFromPath($image->path, $image->type); $disk = $this->storage->getDisk($image->type);
$disk->destroyAllMatchingNameFromPath($image->path);
$image->delete(); $image->delete();
} }
/**
* Destroys an image at the given path.
* Searches for image thumbnails in addition to main provided path.
*/
protected function destroyImagesFromPath(string $path, string $imageType): bool
{
$path = $this->adjustPathForStorageDisk($path, $imageType);
$storage = $this->getStorageDisk($imageType);
$imageFolder = dirname($path);
$imageFileName = basename($path);
$allImages = collect($storage->allFiles($imageFolder));
// Delete image files
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
return basename($imagePath) === $imageFileName;
});
$storage->delete($imagesToDelete->all());
// Cleanup of empty folders
$foldersInvolved = array_merge([$imageFolder], $storage->directories($imageFolder));
foreach ($foldersInvolved as $directory) {
if ($this->isFolderEmpty($storage, $directory)) {
$storage->deleteDirectory($directory);
}
}
return true;
}
/**
* Check whether a folder is empty.
*/
protected function isFolderEmpty(Storage $storage, string $path): bool
{
$files = $storage->files($path);
$folders = $storage->directories($path);
return count($files) === 0 && count($folders) === 0;
}
/** /**
* Delete gallery and drawings that are not within HTML content of pages or page revisions. * Delete gallery and drawings that are not within HTML content of pages or page revisions.
* Checks based off of only the image name. * Checks based off of only the image name.
@ -463,7 +153,7 @@ class ImageService
* *
* Returns the path of the images that would be/have been deleted. * Returns the path of the images that would be/have been deleted.
*/ */
public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = true) public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = true): array
{ {
$types = ['gallery', 'drawio']; $types = ['gallery', 'drawio'];
$deletedPaths = []; $deletedPaths = [];
@ -499,36 +189,32 @@ class ImageService
* Attempts to convert the URL to a system storage url then * Attempts to convert the URL to a system storage url then
* fetch the data from the disk or storage location. * fetch the data from the disk or storage location.
* Returns null if the image data cannot be fetched from storage. * Returns null if the image data cannot be fetched from storage.
*
* @throws FileNotFoundException
*/ */
public function imageUriToBase64(string $uri): ?string public function imageUrlToBase64(string $url): ?string
{ {
$storagePath = $this->imageUrlToStoragePath($uri); $storagePath = $this->storage->urlToPath($url);
if (empty($uri) || is_null($storagePath)) { if (empty($url) || is_null($storagePath)) {
return null; return null;
} }
$storagePath = $this->adjustPathForStorageDisk($storagePath);
// Apply access control when local_secure_restricted images are active // Apply access control when local_secure_restricted images are active
if ($this->usingSecureRestrictedImages()) { if ($this->storage->usingSecureRestrictedImages()) {
if (!$this->checkUserHasAccessToRelationOfImageAtPath($storagePath)) { if (!$this->checkUserHasAccessToRelationOfImageAtPath($storagePath)) {
return null; return null;
} }
} }
$storage = $this->getStorageDisk(); $disk = $this->storage->getDisk();
$imageData = null; $imageData = null;
if ($storage->exists($storagePath)) { if ($disk->exists($storagePath)) {
$imageData = $storage->get($storagePath); $imageData = $disk->get($storagePath);
} }
if (is_null($imageData)) { if (is_null($imageData)) {
return null; return null;
} }
$extension = pathinfo($uri, PATHINFO_EXTENSION); $extension = pathinfo($url, PATHINFO_EXTENSION);
if ($extension === 'svg') { if ($extension === 'svg') {
$extension = 'svg+xml'; $extension = 'svg+xml';
} }
@ -543,20 +229,18 @@ class ImageService
*/ */
public function pathAccessibleInLocalSecure(string $imagePath): bool public function pathAccessibleInLocalSecure(string $imagePath): bool
{ {
/** @var FilesystemAdapter $disk */ $disk = $this->storage->getDisk('gallery');
$disk = $this->getStorageDisk('gallery');
if ($this->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) { if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
return false; return false;
} }
// Check local_secure is active // Check local_secure is active
return $this->usingSecureImages() return $disk->usingSecureImages()
&& $disk instanceof FilesystemAdapter
// Check the image file exists // Check the image file exists
&& $disk->exists($imagePath) && $disk->exists($imagePath)
// Check the file is likely an image file // Check the file is likely an image file
&& strpos($disk->mimeType($imagePath), 'image/') === 0; && str_starts_with($disk->mimeType($imagePath), 'image/');
} }
/** /**
@ -565,14 +249,14 @@ class ImageService
*/ */
protected function checkUserHasAccessToRelationOfImageAtPath(string $path): bool protected function checkUserHasAccessToRelationOfImageAtPath(string $path): bool
{ {
if (strpos($path, '/uploads/images/') === 0) { if (str_starts_with($path, 'uploads/images/')) {
$path = substr($path, 15); $path = substr($path, 15);
} }
// Strip thumbnail element from path if existing // Strip thumbnail element from path if existing
$originalPathSplit = array_filter(explode('/', $path), function (string $part) { $originalPathSplit = array_filter(explode('/', $path), function (string $part) {
$resizedDir = (strpos($part, 'thumbs-') === 0 || strpos($part, 'scaled-') === 0); $resizedDir = (str_starts_with($part, 'thumbs-') || str_starts_with($part, 'scaled-'));
$missingExtension = strpos($part, '.') === false; $missingExtension = !str_contains($part, '.');
return !($resizedDir && $missingExtension); return !($resizedDir && $missingExtension);
}); });
@ -613,7 +297,7 @@ class ImageService
*/ */
public function streamImageFromStorageResponse(string $imageType, string $path): StreamedResponse public function streamImageFromStorageResponse(string $imageType, string $path): StreamedResponse
{ {
$disk = $this->getStorageDisk($imageType); $disk = $this->storage->getDisk($imageType);
return $disk->response($path); return $disk->response($path);
} }
@ -627,64 +311,4 @@ class ImageService
{ {
return in_array($extension, static::$supportedExtensions); return in_array($extension, static::$supportedExtensions);
} }
/**
* Get a storage path for the given image URL.
* Ensures the path will start with "uploads/images".
* Returns null if the url cannot be resolved to a local URL.
*/
private function imageUrlToStoragePath(string $url): ?string
{
$url = ltrim(trim($url), '/');
// Handle potential relative paths
$isRelative = strpos($url, 'http') !== 0;
if ($isRelative) {
if (strpos(strtolower($url), 'uploads/images') === 0) {
return trim($url, '/');
}
return null;
}
// Handle local images based on paths on the same domain
$potentialHostPaths = [
url('uploads/images/'),
$this->getPublicUrl('/uploads/images/'),
];
foreach ($potentialHostPaths as $potentialBasePath) {
$potentialBasePath = strtolower($potentialBasePath);
if (strpos(strtolower($url), $potentialBasePath) === 0) {
return 'uploads/images/' . trim(substr($url, strlen($potentialBasePath)), '/');
}
}
return null;
}
/**
* Gets a public facing url for an image by checking relevant environment variables.
* If s3-style store is in use it will default to guessing a public bucket URL.
*/
private function getPublicUrl(string $filePath): string
{
$storageUrl = config('filesystems.url');
// Get the standard public s3 url if s3 is set as storage type
// Uses the nice, short URL if bucket name has no periods in otherwise the longer
// region-based url will be used to prevent http issues.
if (!$storageUrl && config('filesystems.images') === 's3') {
$storageDetails = config('filesystems.disks.s3');
if (strpos($storageDetails['bucket'], '.') === false) {
$storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com';
} else {
$storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
}
}
$basePath = $storageUrl ?: url('/');
return rtrim($basePath, '/') . $filePath;
}
} }

View File

@ -0,0 +1,136 @@
<?php
namespace BookStack\Uploads;
use Illuminate\Filesystem\FilesystemManager;
use Illuminate\Support\Str;
class ImageStorage
{
public function __construct(
protected FilesystemManager $fileSystem,
) {
}
/**
* Get the storage disk for the given image type.
*/
public function getDisk(string $imageType = ''): ImageStorageDisk
{
$diskName = $this->getDiskName($imageType);
return new ImageStorageDisk(
$diskName,
$this->fileSystem->disk($diskName),
);
}
/**
* Check if "local secure restricted" (Fetched behind auth, with permissions enforced)
* is currently active in the instance.
*/
public function usingSecureRestrictedImages(): bool
{
return config('filesystems.images') === 'local_secure_restricted';
}
/**
* Clean up an image file name to be both URL and storage safe.
*/
public function cleanImageFileName(string $name): string
{
$name = str_replace(' ', '-', $name);
$nameParts = explode('.', $name);
$extension = array_pop($nameParts);
$name = implode('-', $nameParts);
$name = Str::slug($name);
if (strlen($name) === 0) {
$name = Str::random(10);
}
return $name . '.' . $extension;
}
/**
* Get the name of the storage disk to use.
*/
protected function getDiskName(string $imageType): string
{
$storageType = strtolower(config('filesystems.images'));
$localSecureInUse = ($storageType === 'local_secure' || $storageType === 'local_secure_restricted');
// Ensure system images (App logo) are uploaded to a public space
if ($imageType === 'system' && $localSecureInUse) {
return 'local';
}
// Rename local_secure options to get our image specific storage driver which
// is scoped to the relevant image directories.
if ($localSecureInUse) {
return 'local_secure_images';
}
return $storageType;
}
/**
* Get a storage path for the given image URL.
* Ensures the path will start with "uploads/images".
* Returns null if the url cannot be resolved to a local URL.
*/
public function urlToPath(string $url): ?string
{
$url = ltrim(trim($url), '/');
// Handle potential relative paths
$isRelative = !str_starts_with($url, 'http');
if ($isRelative) {
if (str_starts_with(strtolower($url), 'uploads/images')) {
return trim($url, '/');
}
return null;
}
// Handle local images based on paths on the same domain
$potentialHostPaths = [
url('uploads/images/'),
$this->getPublicUrl('/uploads/images/'),
];
foreach ($potentialHostPaths as $potentialBasePath) {
$potentialBasePath = strtolower($potentialBasePath);
if (str_starts_with(strtolower($url), $potentialBasePath)) {
return 'uploads/images/' . trim(substr($url, strlen($potentialBasePath)), '/');
}
}
return null;
}
/**
* Gets a public facing url for an image by checking relevant environment variables.
* If s3-style store is in use it will default to guessing a public bucket URL.
*/
public function getPublicUrl(string $filePath): string
{
$storageUrl = config('filesystems.url');
// Get the standard public s3 url if s3 is set as storage type
// Uses the nice, short URL if bucket name has no periods in otherwise the longer
// region-based url will be used to prevent http issues.
if (!$storageUrl && config('filesystems.images') === 's3') {
$storageDetails = config('filesystems.disks.s3');
if (!str_contains($storageDetails['bucket'], '.')) {
$storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com';
} else {
$storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
}
}
$basePath = $storageUrl ?: url('/');
return rtrim($basePath, '/') . $filePath;
}
}

View File

@ -0,0 +1,140 @@
<?php
namespace BookStack\Uploads;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Filesystem\FilesystemAdapter;
use League\Flysystem\WhitespacePathNormalizer;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ImageStorageDisk
{
public function __construct(
protected string $diskName,
protected Filesystem $filesystem,
) {
}
/**
* Check if local secure image storage (Fetched behind authentication)
* is currently active in the instance.
*/
public function usingSecureImages(): bool
{
return $this->diskName === 'local_secure_images';
}
/**
* Change the originally provided path to fit any disk-specific requirements.
* This also ensures the path is kept to the expected root folders.
*/
protected function adjustPathForDisk(string $path): string
{
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/images/', '', $path));
if ($this->usingSecureImages()) {
return $path;
}
return 'uploads/images/' . $path;
}
/**
* Check if a file at the given path exists.
*/
public function exists(string $path): bool
{
return $this->filesystem->exists($this->adjustPathForDisk($path));
}
/**
* Get the file at the given path.
*/
public function get(string $path): ?string
{
return $this->filesystem->get($this->adjustPathForDisk($path));
}
/**
* Save the given image data at the given path. Can choose to set
* the image as public which will update its visibility after saving.
*/
public function put(string $path, string $data, bool $makePublic = false): void
{
$path = $this->adjustPathForDisk($path);
$this->filesystem->put($path, $data);
// Set visibility when a non-AWS-s3, s3-like storage option is in use.
// Done since this call can break s3-like services but desired for other image stores.
// Attempting to set ACL during above put request requires different permissions
// hence would technically be a breaking change for actual s3 usage.
if ($makePublic && !$this->isS3Like()) {
$this->filesystem->setVisibility($path, 'public');
}
}
/**
* Destroys an image at the given path.
* Searches for image thumbnails in addition to main provided path.
*/
public function destroyAllMatchingNameFromPath(string $path): void
{
$path = $this->adjustPathForDisk($path);
$imageFolder = dirname($path);
$imageFileName = basename($path);
$allImages = collect($this->filesystem->allFiles($imageFolder));
// Delete image files
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
return basename($imagePath) === $imageFileName;
});
$this->filesystem->delete($imagesToDelete->all());
// Cleanup of empty folders
$foldersInvolved = array_merge([$imageFolder], $this->filesystem->directories($imageFolder));
foreach ($foldersInvolved as $directory) {
if ($this->isFolderEmpty($directory)) {
$this->filesystem->deleteDirectory($directory);
}
}
}
/**
* Get the mime type of the file at the given path.
* Only works for local filesystem adapters.
*/
public function mimeType(string $path): string
{
$path = $this->adjustPathForDisk($path);
return $this->filesystem instanceof FilesystemAdapter ? $this->filesystem->mimeType($path) : '';
}
/**
* Get a stream response for the image at the given path.
*/
public function response(string $path): StreamedResponse
{
return $this->filesystem->response($this->adjustPathForDisk($path));
}
/**
* Check if the image storage in use is an S3-like (but not likely S3) external system.
*/
protected function isS3Like(): bool
{
$usingS3 = $this->diskName === 's3';
return $usingS3 && !is_null(config('filesystems.disks.s3.endpoint'));
}
/**
* Check whether a folder is empty.
*/
protected function isFolderEmpty(string $path): bool
{
$files = $this->filesystem->files($path);
$folders = $this->filesystem->directories($path);
return count($files) === 0 && count($folders) === 0;
}
}

View File

@ -3,20 +3,20 @@
namespace BookStack\Uploads; namespace BookStack\Uploads;
use BookStack\Exceptions\HttpFetchException; use BookStack\Exceptions\HttpFetchException;
use BookStack\Http\HttpRequestService;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Exception; use Exception;
use GuzzleHttp\Psr7\Request;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Psr\Http\Client\ClientExceptionInterface;
class UserAvatars class UserAvatars
{ {
protected $imageService; public function __construct(
protected $http; protected ImageService $imageService,
protected HttpRequestService $http
public function __construct(ImageService $imageService, HttpFetcher $http) ) {
{
$this->imageService = $imageService;
$this->http = $http;
} }
/** /**
@ -56,7 +56,7 @@ class UserAvatars
/** /**
* Destroy all user avatars uploaded to the given user. * Destroy all user avatars uploaded to the given user.
*/ */
public function destroyAllForUser(User $user) public function destroyAllForUser(User $user): void
{ {
$profileImages = Image::query()->where('type', '=', 'user') $profileImages = Image::query()->where('type', '=', 'user')
->where('uploaded_to', '=', $user->id) ->where('uploaded_to', '=', $user->id)
@ -70,7 +70,7 @@ class UserAvatars
/** /**
* Save an avatar image from an external service. * Save an avatar image from an external service.
* *
* @throws Exception * @throws HttpFetchException
*/ */
protected function saveAvatarImage(User $user, int $size = 500): Image protected function saveAvatarImage(User $user, int $size = 500): Image
{ {
@ -112,28 +112,32 @@ class UserAvatars
protected function getAvatarImageData(string $url): string protected function getAvatarImageData(string $url): string
{ {
try { try {
$imageData = $this->http->fetch($url); $client = $this->http->buildClient(5);
} catch (HttpFetchException $exception) { $response = $client->sendRequest(new Request('GET', $url));
if ($response->getStatusCode() !== 200) {
throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url]));
}
return (string) $response->getBody();
} catch (ClientExceptionInterface $exception) {
throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url]), $exception->getCode(), $exception); throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url]), $exception->getCode(), $exception);
} }
return $imageData;
} }
/** /**
* Check if fetching external avatars is enabled. * Check if fetching external avatars is enabled.
*/ */
protected function avatarFetchEnabled(): bool public function avatarFetchEnabled(): bool
{ {
$fetchUrl = $this->getAvatarUrl(); $fetchUrl = $this->getAvatarUrl();
return is_string($fetchUrl) && strpos($fetchUrl, 'http') === 0; return str_starts_with($fetchUrl, 'http');
} }
/** /**
* Get the URL to fetch avatars from. * Get the URL to fetch avatars from.
*/ */
protected function getAvatarUrl(): string public function getAvatarUrl(): string
{ {
$configOption = config('services.avatar_url'); $configOption = config('services.avatar_url');
if ($configOption === false) { if ($configOption === false) {

View File

@ -0,0 +1,227 @@
<?php
namespace BookStack\Users\Controllers;
use BookStack\Access\SocialAuthService;
use BookStack\Http\Controller;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Settings\UserShortcutMap;
use BookStack\Uploads\ImageRepo;
use BookStack\Users\UserRepo;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Validation\Rules\Password;
class UserAccountController extends Controller
{
public function __construct(
protected UserRepo $userRepo,
) {
$this->middleware(function (Request $request, Closure $next) {
$this->preventGuestAccess();
return $next($request);
});
}
/**
* Redirect the root my-account path to the main/first category.
* Required as a controller method, instead of the Route::redirect helper,
* to ensure the URL is generated correctly.
*/
public function redirect()
{
return redirect('/my-account/profile');
}
/**
* Show the profile form interface.
*/
public function showProfile()
{
$this->setPageTitle(trans('preferences.profile'));
return view('users.account.profile', [
'model' => user(),
'category' => 'profile',
]);
}
/**
* Handle the submission of the user profile form.
*/
public function updateProfile(Request $request, ImageRepo $imageRepo)
{
$this->preventAccessInDemoMode();
$user = user();
$validated = $this->validate($request, [
'name' => ['min:2', 'max:100'],
'email' => ['min:2', 'email', 'unique:users,email,' . $user->id],
'language' => ['string', 'max:15', 'alpha_dash'],
'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()),
]);
$this->userRepo->update($user, $validated, userCan('users-manage'));
// Save profile image if in request
if ($request->hasFile('profile_image')) {
$imageUpload = $request->file('profile_image');
$imageRepo->destroyImage($user->avatar);
$image = $imageRepo->saveNew($imageUpload, 'user', $user->id);
$user->image_id = $image->id;
$user->save();
}
// Delete the profile image if reset option is in request
if ($request->has('profile_image_reset')) {
$imageRepo->destroyImage($user->avatar);
$user->image_id = 0;
$user->save();
}
return redirect('/my-account/profile');
}
/**
* Show the user-specific interface shortcuts.
*/
public function showShortcuts()
{
$shortcuts = UserShortcutMap::fromUserPreferences();
$enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false);
$this->setPageTitle(trans('preferences.shortcuts_interface'));
return view('users.account.shortcuts', [
'category' => 'shortcuts',
'shortcuts' => $shortcuts,
'enabled' => $enabled,
]);
}
/**
* Update the user-specific interface shortcuts.
*/
public function updateShortcuts(Request $request)
{
$enabled = $request->get('enabled') === 'true';
$providedShortcuts = $request->get('shortcut', []);
$shortcuts = new UserShortcutMap($providedShortcuts);
setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson());
setting()->putForCurrentUser('ui-shortcuts-enabled', $enabled);
$this->showSuccessNotification(trans('preferences.shortcuts_update_success'));
return redirect('/my-account/shortcuts');
}
/**
* Show the notification preferences for the current user.
*/
public function showNotifications(PermissionApplicator $permissions)
{
$this->checkPermission('receive-notifications');
$preferences = (new UserNotificationPreferences(user()));
$query = user()->watches()->getQuery();
$query = $permissions->restrictEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
$query = $permissions->filterDeletedFromEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
$watches = $query->with('watchable')->paginate(20);
$this->setPageTitle(trans('preferences.notifications'));
return view('users.account.notifications', [
'category' => 'notifications',
'preferences' => $preferences,
'watches' => $watches,
]);
}
/**
* Update the notification preferences for the current user.
*/
public function updateNotifications(Request $request)
{
$this->preventAccessInDemoMode();
$this->checkPermission('receive-notifications');
$data = $this->validate($request, [
'preferences' => ['required', 'array'],
'preferences.*' => ['required', 'string'],
]);
$preferences = (new UserNotificationPreferences(user()));
$preferences->updateFromSettingsArray($data['preferences']);
$this->showSuccessNotification(trans('preferences.notifications_update_success'));
return redirect('/my-account/notifications');
}
/**
* Show the view for the "Access & Security" account options.
*/
public function showAuth(SocialAuthService $socialAuthService)
{
$mfaMethods = user()->mfaValues()->get()->groupBy('method');
$this->setPageTitle(trans('preferences.auth'));
return view('users.account.auth', [
'category' => 'auth',
'mfaMethods' => $mfaMethods,
'authMethod' => config('auth.method'),
'activeSocialDrivers' => $socialAuthService->getActiveDrivers(),
]);
}
/**
* Handle the submission for the auth change password form.
*/
public function updatePassword(Request $request)
{
$this->preventAccessInDemoMode();
if (config('auth.method') !== 'standard') {
$this->showPermissionError();
}
$validated = $this->validate($request, [
'password' => ['required_with:password_confirm', Password::default()],
'password-confirm' => ['same:password', 'required_with:password'],
]);
$this->userRepo->update(user(), $validated, false);
$this->showSuccessNotification(trans('preferences.auth_change_password_success'));
return redirect('/my-account/auth');
}
/**
* Show the user self-delete page.
*/
public function delete()
{
$this->setPageTitle(trans('preferences.delete_my_account'));
return view('users.account.delete', [
'category' => 'profile',
]);
}
/**
* Remove the current user from the system.
*/
public function destroy(Request $request)
{
$this->preventAccessInDemoMode();
$requestNewOwnerId = intval($request->get('new_owner_id')) ?: null;
$newOwnerId = userCan('users-manage') ? $requestNewOwnerId : null;
$this->userRepo->destroy(user(), $newOwnerId);
return redirect('/');
}
}

View File

@ -103,8 +103,7 @@ class UserController extends Controller
*/ */
public function edit(int $id, SocialAuthService $socialAuthService) public function edit(int $id, SocialAuthService $socialAuthService)
{ {
$this->preventGuestAccess(); $this->checkPermission('users-manage');
$this->checkPermissionOrCurrentUser('users-manage', $id);
$user = $this->userRepo->getById($id); $user = $this->userRepo->getById($id);
$user->load(['apiTokens', 'mfaValues']); $user->load(['apiTokens', 'mfaValues']);
@ -134,8 +133,7 @@ class UserController extends Controller
public function update(Request $request, int $id) public function update(Request $request, int $id)
{ {
$this->preventAccessInDemoMode(); $this->preventAccessInDemoMode();
$this->preventGuestAccess(); $this->checkPermission('users-manage');
$this->checkPermissionOrCurrentUser('users-manage', $id);
$validated = $this->validate($request, [ $validated = $this->validate($request, [
'name' => ['min:2', 'max:100'], 'name' => ['min:2', 'max:100'],
@ -150,7 +148,7 @@ class UserController extends Controller
]); ]);
$user = $this->userRepo->getById($id); $user = $this->userRepo->getById($id);
$this->userRepo->update($user, $validated, userCan('users-manage')); $this->userRepo->update($user, $validated, true);
// Save profile image if in request // Save profile image if in request
if ($request->hasFile('profile_image')) { if ($request->hasFile('profile_image')) {
@ -168,9 +166,7 @@ class UserController extends Controller
$user->save(); $user->save();
} }
$redirectUrl = userCan('users-manage') ? '/settings/users' : "/settings/users/{$user->id}"; return redirect('/settings/users');
return redirect($redirectUrl);
} }
/** /**
@ -178,8 +174,7 @@ class UserController extends Controller
*/ */
public function delete(int $id) public function delete(int $id)
{ {
$this->preventGuestAccess(); $this->checkPermission('users-manage');
$this->checkPermissionOrCurrentUser('users-manage', $id);
$user = $this->userRepo->getById($id); $user = $this->userRepo->getById($id);
$this->setPageTitle(trans('settings.users_delete_named', ['userName' => $user->name])); $this->setPageTitle(trans('settings.users_delete_named', ['userName' => $user->name]));
@ -195,8 +190,7 @@ class UserController extends Controller
public function destroy(Request $request, int $id) public function destroy(Request $request, int $id)
{ {
$this->preventAccessInDemoMode(); $this->preventAccessInDemoMode();
$this->preventGuestAccess(); $this->checkPermission('users-manage');
$this->checkPermissionOrCurrentUser('users-manage', $id);
$user = $this->userRepo->getById($id); $user = $this->userRepo->getById($id);
$newOwnerId = intval($request->get('new_owner_id')) ?: null; $newOwnerId = intval($request->get('new_owner_id')) ?: null;

View File

@ -16,88 +16,6 @@ class UserPreferencesController extends Controller
) { ) {
} }
/**
* Show the overview for user preferences.
*/
public function index()
{
return view('users.preferences.index');
}
/**
* Show the user-specific interface shortcuts.
*/
public function showShortcuts()
{
$shortcuts = UserShortcutMap::fromUserPreferences();
$enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false);
$this->setPageTitle(trans('preferences.shortcuts_interface'));
return view('users.preferences.shortcuts', [
'shortcuts' => $shortcuts,
'enabled' => $enabled,
]);
}
/**
* Update the user-specific interface shortcuts.
*/
public function updateShortcuts(Request $request)
{
$enabled = $request->get('enabled') === 'true';
$providedShortcuts = $request->get('shortcut', []);
$shortcuts = new UserShortcutMap($providedShortcuts);
setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson());
setting()->putForCurrentUser('ui-shortcuts-enabled', $enabled);
$this->showSuccessNotification(trans('preferences.shortcuts_update_success'));
return redirect('/preferences/shortcuts');
}
/**
* Show the notification preferences for the current user.
*/
public function showNotifications(PermissionApplicator $permissions)
{
$this->checkPermission('receive-notifications');
$this->preventGuestAccess();
$preferences = (new UserNotificationPreferences(user()));
$query = user()->watches()->getQuery();
$query = $permissions->restrictEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
$query = $permissions->filterDeletedFromEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
$watches = $query->with('watchable')->paginate(20);
$this->setPageTitle(trans('preferences.notifications'));
return view('users.preferences.notifications', [
'preferences' => $preferences,
'watches' => $watches,
]);
}
/**
* Update the notification preferences for the current user.
*/
public function updateNotifications(Request $request)
{
$this->checkPermission('receive-notifications');
$this->preventGuestAccess();
$data = $this->validate($request, [
'preferences' => ['required', 'array'],
'preferences.*' => ['required', 'string'],
]);
$preferences = (new UserNotificationPreferences(user()));
$preferences->updateFromSettingsArray($data['preferences']);
$this->showSuccessNotification(trans('preferences.notifications_update_success'));
return redirect('/preferences/notifications');
}
/** /**
* Update the preferred view format for a list view of the given type. * Update the preferred view format for a list view of the given type.
*/ */
@ -145,7 +63,7 @@ class UserPreferencesController extends Controller
*/ */
public function toggleDarkMode() public function toggleDarkMode()
{ {
$enabled = setting()->getForCurrentUser('dark-mode-enabled', false); $enabled = setting()->getForCurrentUser('dark-mode-enabled');
setting()->putForCurrentUser('dark-mode-enabled', $enabled ? 'false' : 'true'); setting()->putForCurrentUser('dark-mode-enabled', $enabled ? 'false' : 'true');
return redirect()->back(); return redirect()->back();

View File

@ -14,7 +14,7 @@ class UserSearchController extends Controller
*/ */
public function forSelect(Request $request) public function forSelect(Request $request)
{ {
$hasPermission = signedInUser() && ( $hasPermission = !user()->isGuest() && (
userCan('users-manage') userCan('users-manage')
|| userCan('restrictions-manage-own') || userCan('restrictions-manage-own')
|| userCan('restrictions-manage-all') || userCan('restrictions-manage-all')

View File

@ -3,6 +3,7 @@
namespace BookStack\Users\Models; namespace BookStack\Users\Models;
use BookStack\Access\Mfa\MfaValue; use BookStack\Access\Mfa\MfaValue;
use BookStack\Access\Notifications\ResetPasswordNotification;
use BookStack\Access\SocialAccount; use BookStack\Access\SocialAccount;
use BookStack\Activity\Models\Favourite; use BookStack\Activity\Models\Favourite;
use BookStack\Activity\Models\Loggable; use BookStack\Activity\Models\Loggable;
@ -11,8 +12,8 @@ use BookStack\Api\ApiToken;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\App\Sluggable; use BookStack\App\Sluggable;
use BookStack\Entities\Tools\SlugGenerator; use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Notifications\ResetPassword; use BookStack\Translation\LocaleDefinition;
use BookStack\Translation\LanguageManager; use BookStack\Translation\LocaleManager;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use Carbon\Carbon; use Carbon\Carbon;
use Exception; use Exception;
@ -88,38 +89,31 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/ */
protected string $avatarUrl = ''; protected string $avatarUrl = '';
/**
* This holds the default user when loaded.
*/
protected static ?User $defaultUser = null;
/** /**
* Returns the default public user. * Returns the default public user.
* Fetches from the container as a singleton to effectively cache at an app level.
*/ */
public static function getDefault(): self public static function getGuest(): self
{ {
if (!is_null(static::$defaultUser)) { return app()->make('users.default');
return static::$defaultUser;
}
static::$defaultUser = static::query()->where('system_name', '=', 'public')->first();
return static::$defaultUser;
}
public static function clearDefault(): void
{
static::$defaultUser = null;
} }
/** /**
* Check if the user is the default public user. * Check if the user is the default public user.
*/ */
public function isDefault(): bool public function isGuest(): bool
{ {
return $this->system_name === 'public'; return $this->system_name === 'public';
} }
/**
* Check if the user has general access to the application.
*/
public function hasAppAccess(): bool
{
return !$this->isGuest() || setting('app-public');
}
/** /**
* The roles that belong to the user. * The roles that belong to the user.
* *
@ -250,7 +244,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
} }
try { try {
$avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default; $avatar = $this->avatar?->getThumb($size, $size, false) ?? $default;
} catch (Exception $err) { } catch (Exception $err) {
$avatar = $default; $avatar = $default;
} }
@ -345,15 +339,15 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return $splitName[0]; return $splitName[0];
} }
return ''; return mb_substr($this->name, 0, max($chars - 2, 0)) . '';
} }
/** /**
* Get the system language for this user. * Get the locale for this user.
*/ */
public function getLanguage(): string public function getLocale(): LocaleDefinition
{ {
return app()->make(LanguageManager::class)->getLanguageForUser($this); return app()->make(LocaleManager::class)->getForUser($this);
} }
/** /**
@ -365,7 +359,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/ */
public function sendPasswordResetNotification($token) public function sendPasswordResetNotification($token)
{ {
$this->notify(new ResetPassword($token)); $this->notify(new ResetPasswordNotification($token));
} }
/** /**
@ -381,7 +375,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/ */
public function refreshSlug(): string public function refreshSlug(): string
{ {
$this->slug = app(SlugGenerator::class)->generate($this); $this->slug = app()->make(SlugGenerator::class)->generate($this);
return $this->slug; return $this->slug;
} }

View File

@ -0,0 +1,58 @@
<?php
namespace BookStack\Util;
use BookStack\Exceptions\Handler;
use Illuminate\Contracts\Debug\ExceptionHandler;
/**
* Create a handler which runs the provided actions upon an
* out-of-memory event. This allows reserving of memory to allow
* the desired action to run as needed.
*
* Essentially provides a wrapper and memory reserving around the
* memory handling added to the default app error handler.
*/
class OutOfMemoryHandler
{
protected $onOutOfMemory;
protected string $memoryReserve = '';
public function __construct(callable $onOutOfMemory, int $memoryReserveMB = 4)
{
$this->onOutOfMemory = $onOutOfMemory;
$this->memoryReserve = str_repeat('x', $memoryReserveMB * 1_000_000);
$this->getHandler()->prepareForOutOfMemory(function () {
return $this->handle();
});
}
protected function handle(): mixed
{
$result = null;
$this->memoryReserve = '';
if ($this->onOutOfMemory) {
$result = call_user_func($this->onOutOfMemory);
$this->forget();
}
return $result;
}
/**
* Forget the handler so no action is taken place on out of memory.
*/
public function forget(): void
{
$this->memoryReserve = '';
$this->onOutOfMemory = null;
$this->getHandler()->forgetOutOfMemoryHandler();
}
protected function getHandler(): Handler
{
return app()->make(ExceptionHandler::class);
}
}

39
app/Util/SvgIcon.php Normal file
View File

@ -0,0 +1,39 @@
<?php
namespace BookStack\Util;
class SvgIcon
{
public function __construct(
protected string $name,
protected array $attrs = []
) {
}
public function toHtml(): string
{
$attrs = array_merge([
'class' => 'svg-icon',
'data-icon' => $this->name,
'role' => 'presentation',
], $this->attrs);
$attrString = ' ';
foreach ($attrs as $attrName => $attr) {
$attrString .= $attrName . '="' . $attr . '" ';
}
$iconPath = resource_path('icons/' . $this->name . '.svg');
$themeIconPath = theme_path('icons/' . $this->name . '.svg');
if ($themeIconPath && file_exists($themeIconPath)) {
$iconPath = $themeIconPath;
} elseif (!file_exists($iconPath)) {
return '';
}
$fileContents = file_get_contents($iconPath);
return str_replace('<svg', '<svg' . $attrString, $fileContents);
}
}

View File

@ -23,7 +23,7 @@
"guzzlehttp/guzzle": "^7.4", "guzzlehttp/guzzle": "^7.4",
"intervention/image": "^2.7", "intervention/image": "^2.7",
"laravel/framework": "^9.0", "laravel/framework": "^9.0",
"laravel/socialite": "^5.2", "laravel/socialite": "^5.8",
"laravel/tinker": "^2.6", "laravel/tinker": "^2.6",
"league/commonmark": "^2.3", "league/commonmark": "^2.3",
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
@ -37,7 +37,6 @@
"socialiteproviders/gitlab": "^4.1", "socialiteproviders/gitlab": "^4.1",
"socialiteproviders/microsoft-azure": "^5.1", "socialiteproviders/microsoft-azure": "^5.1",
"socialiteproviders/okta": "^4.2", "socialiteproviders/okta": "^4.2",
"socialiteproviders/slack": "^4.1",
"socialiteproviders/twitch": "^5.3", "socialiteproviders/twitch": "^5.3",
"ssddanbrown/htmldiff": "^1.0.2" "ssddanbrown/htmldiff": "^1.0.2"
}, },

575
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -23,13 +23,13 @@ npm run production
npm run dev npm run dev
``` ```
BookStack has many integration tests that use Laravel's built-in testing capabilities which makes use of PHPUnit. There is a `mysql_testing` database defined within the app config which is what is used by PHPUnit. This database is set with the database name, username and password all defined as `bookstack-test`. You will have to create that database and that set of credentials before testing. Further details about the BookStack JavaScript codebase can be found in the [javascript-code.md document](javascript-code.md).
The testing database will also need migrating and seeding beforehand. This can be done by running `composer refresh-test-database`. ## Automated App Testing
Once done you can run `composer test` in the application root directory to run all tests. Tests can be ran in parallel by running them via `composer t`. This will use Laravel's built-in parallel testing functionality, and attempt to create and seed a database instance for each testing thread. If required these parallel testing instances can be reset, before testing again, by running `composer t-reset`. BookStack has a large suite of PHP tests to cover application functionality. We try to ensure that all additions and changes to the platform are covered with testing.
If the codebase needs to be tested with deprecations, this can be done via uncommenting the relevant line within the TestCase@setUp function. For details about setting-up, running and writing tests please see the [php-testing.md document](php-testing.md).
## Code Standards ## Code Standards

87
dev/docs/php-testing.md Normal file
View File

@ -0,0 +1,87 @@
# BookStack PHP Testing
BookStack has many test cases defined within the `tests/` directory of the app. These are built upon [PHPUnit](https://phpunit.de/) along with Laravel's own test framework additions, and a bunch of custom helper classes.
## Setup
The application tests are mostly functional, rather than unit tests, meaning they simulate user actions and system components and therefore these require use of the database. To avoid potential conflicts within your development environment, the tests use a separate database. This is defined via a specific `mysql_testing` database connection in our configuration, and expects to use the following database access details:
- Host: `127.0.0.1`
- Username: `bookstack-test`
- Password: `bookstack-test`
- Database: `bookstack-test`
You will need to create a database, with access for these credentials, to allow the system to connect when running tests. Alternatively, if those don't suit, you can define a `TEST_DATABASE_URL` option in your `.env` file, or environment, with connection details like so:
```bash
TEST_DATABASE_URL="mysql://username:password@host-name:port/database-name"
```
The testing database will need migrating and seeding with test data beforehand. This can be done by running `composer refresh-test-database`.
## Running Tests
You can run all tests via composer with `composer test` in the application root directory.
Alternatively, you can run PHPUnit directly with `php vendor/bin/phpunit`.
Some editors, like PHPStorm, have in-built support for running tests on a per file, directory or class basis.
Otherwise, you can run PHPUnit with specified tests and/or filter to limit the tests ran:
```bash
# Run all test in the "./tests/HomepageTest.php" file
php vendor/bin/phpunit ./tests/HomepageTest.php
# Run all test in the "./tests/User" directory
php vendor/bin/phpunit ./tests/User
# Filter to a particular test method name
php vendor/bin/phpunit --filter test_default_homepage_visible
# Filter to a particular test class name
php vendor/bin/phpunit --filter HomepageTest
```
If the codebase needs to be tested with deprecations, this can be done via uncommenting the relevant line within the `TestCase@setUp` function. This is not expected for most PRs to the project, but instead used for maintenance tasks like dependency & PHP upgrades.
## Writing Tests
To understand how tests are written & used, it's advised you read through existing test cases similar to what you need to write. Tests are written in a rather scrappy manner, compared to the core app codebase, which is fine and expected since there's often hoops to jump through for various functionality. Scrappy tests are better than no tests.
Test classes have to be within the `tests/` folder, and be named ending in `Test`. These should always extend the `Tests\TestCase` class.
Test methods should be written in snake_case, start with `test_`, and be public methods.
Here are some general rules & patterns we follow in the tests:
- All external remote system resources, like HTTP calls and LDAP connections, are mocked.
- We prefer to hard-code expected text & URLs to better detect potential changes in the system rather than use dynamic references. This provides higher sensitivity to changes, and has never been much of a maintenance issue.
- Only test with an admin user if needed, otherwise keep to less privileged users to ensure permission systems are active and exercised within tests.
- If testing for the lack of something (e.g. `$this->assertDontSee('TextAfterChange')`) then this should be accompanied by some form of positive confirmation (e.g. `$this->assertSee('TextBeforeChange')`).
### Test Helpers
Our default `TestCase` is bloated with helpers to assist in testing scenarios. Some of these shown below, but you should jump through and explore these in your IDE/editor to explore their full capabilities and options:
```php
// Run the test as a logged-in-user at a certain privilege level
$this->asAdmin();
$this->asEditor();
$this->asViewer();
// Provides a bunch of entity (shelf/book/chapter/page) content and actions
$this->entities;
// Provides various user & role abilities
$this->users;
// Provides many helpful actions relate to system & content permissions
$this->permissions;
// Provides a range of methods for dealing with files & uploads in tests
$this->files;
// Parse HTML of a response to assert HTML-based conditions
// Uses https://github.com/ssddanbrown/asserthtml library.
$this->withHtml($resp);
// Example:
$this->withHtml($this->get('/'))->assertElementContains('p[id="top"]', 'Hello!');
```

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