Merge branch 'development' into release
This commit is contained in:
commit
5b45eac5e1
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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,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!
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -71,7 +71,7 @@ trait ThrottlesLogins
|
||||||
*/
|
*/
|
||||||
protected function limiter(): RateLimiter
|
protected function limiter(): RateLimiter
|
||||||
{
|
{
|
||||||
return app(RateLimiter::class);
|
return app()->make(RateLimiter::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
@ -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://');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
{
|
{
|
||||||
|
|
|
@ -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, '/'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -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(); ?>";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Exceptions;
|
||||||
|
|
||||||
|
class ThemeException extends \Exception
|
||||||
|
{
|
||||||
|
}
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
{
|
{
|
|
@ -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);
|
||||||
|
|
|
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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'];
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
|
@ -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('/');
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
Loading…
Reference in New Issue