mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-05 08:39:55 +03:00
Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e44b195c5 | ||
|
|
5b45eac5e1 | ||
|
|
49b286cd34 | ||
|
|
e006f9674f | ||
|
|
8bffcebd64 | ||
|
|
7c4dc981cd | ||
|
|
9b4f1fb981 | ||
|
|
d42af4affc | ||
|
|
8375d341ea | ||
|
|
f5756ff28a | ||
|
|
c513cdaebe | ||
|
|
995b7d61e9 | ||
|
|
02bfaffeb4 | ||
|
|
38fe40809b | ||
|
|
ce53f641ad | ||
|
|
f55e7ca3c9 | ||
|
|
fabc854390 | ||
|
|
12946414b0 | ||
|
|
f9422dff18 | ||
|
|
cf72e48d2a | ||
|
|
e4ea73ee25 | ||
|
|
03c44b3992 | ||
|
|
c1b01639c1 | ||
|
|
a868012048 | ||
|
|
a9d0f36766 | ||
|
|
3274181e14 | ||
|
|
8166e27f2b | ||
|
|
8ffa436f3d | ||
|
|
8c10959339 | ||
|
|
45c7409092 | ||
|
|
a12b60e1ad | ||
|
|
ccb3c2516a | ||
|
|
2e2272343b | ||
|
|
031067745b | ||
|
|
1267068d9c | ||
|
|
0241032f06 | ||
|
|
bd7c7eb8d6 | ||
|
|
c5d5b6e3c1 | ||
|
|
1005f4bd7a | ||
|
|
b24296e0c9 | ||
|
|
d1f28ed245 | ||
|
|
1d91b4d8a6 | ||
|
|
8bba5dd5a0 | ||
|
|
ffb04a8be6 | ||
|
|
b2d48d9a7f | ||
|
|
20bcbd76ef | ||
|
|
e703009d7f | ||
|
|
7247e31936 | ||
|
|
40721433f7 | ||
|
|
97274a8140 | ||
|
|
5c318a45b8 | ||
|
|
5af3041b9b | ||
|
|
cc0827ff28 | ||
|
|
59da7666b5 | ||
|
|
287ed4ff3b | ||
|
|
21badde4ef | ||
|
|
e9664dc678 | ||
|
|
d5a3bdb7aa | ||
|
|
c3b4128a38 | ||
|
|
f77bb01b51 | ||
|
|
fb417828a4 | ||
|
|
57791c1466 | ||
|
|
46e3b2ceb3 | ||
|
|
10e8e1a88d | ||
|
|
7e09c9a147 | ||
|
|
2a2f893fcc | ||
|
|
9b99664bff | ||
|
|
f910424fa3 | ||
|
|
6e19a8a4bb | ||
|
|
cb9c3fc9f5 | ||
|
|
effc03e99e | ||
|
|
8964575973 | ||
|
|
4b4d8ba2a1 | ||
|
|
588ed785d2 | ||
|
|
ca98155373 | ||
|
|
ea7592509f | ||
|
|
95b9ea1a21 | ||
|
|
684a9dee8e | ||
|
|
c42cd29ed3 | ||
|
|
35813e818d | ||
|
|
78bf11cf65 | ||
|
|
baa957d980 | ||
|
|
b42e8cdb63 | ||
|
|
8994c1b9d9 | ||
|
|
ac9a65945f | ||
|
|
b292cf7090 | ||
|
|
54791c8627 | ||
|
|
e16bdf443c | ||
|
|
b90033a730 | ||
|
|
9ac932fc28 | ||
|
|
6a5361d853 | ||
|
|
c1d30341e7 | ||
|
|
80d2b4913b | ||
|
|
45b8d6cd0c | ||
|
|
dfaf6f7c13 | ||
|
|
417705651c | ||
|
|
4ec600adfa | ||
|
|
709c182bda | ||
|
|
a452092e40 | ||
|
|
83028f3fbe | ||
|
|
f4deb13301 | ||
|
|
6e098905d4 | ||
|
|
f997d3e0bb | ||
|
|
8e3f8de627 | ||
|
|
18f396c21b | ||
|
|
ec86576e1e | ||
|
|
99eb3e5f71 | ||
|
|
4985e39db4 | ||
|
|
05f2ec40cc | ||
|
|
564dc70ac4 | ||
|
|
2fbf5527c7 | ||
|
|
3928cbac18 | ||
|
|
8659ee0936 | ||
|
|
06490f624c | ||
|
|
a8b5652210 | ||
|
|
15da4b98ef | ||
|
|
21cd2d17f6 | ||
|
|
3f473528b1 | ||
|
|
d0dcd4f61b | ||
|
|
ad60517536 | ||
|
|
2c20abc872 | ||
|
|
2b604b5af9 | ||
|
|
08ea97fd83 | ||
|
|
601491b275 | ||
|
|
88e148ba00 |
@@ -72,7 +72,7 @@ MYSQL_ATTR_SSL_CA="/path/to/ca.pem"
|
||||
# Mail configuration
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration
|
||||
MAIL_DRIVER=smtp
|
||||
MAIL_FROM=mail@bookstackapp.com
|
||||
MAIL_FROM=bookstack@example.com
|
||||
MAIL_FROM_NAME=BookStack
|
||||
|
||||
MAIL_HOST=localhost
|
||||
|
||||
33
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
33
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,7 +1,14 @@
|
||||
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"]
|
||||
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
|
||||
id: description
|
||||
attributes:
|
||||
@@ -13,7 +20,7 @@ body:
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Detail the steps that would replicate this issue
|
||||
description: Detail the steps that would replicate this issue.
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
@@ -32,7 +39,7 @@ body:
|
||||
id: context
|
||||
attributes:
|
||||
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:
|
||||
required: false
|
||||
- type: input
|
||||
@@ -48,23 +55,7 @@ body:
|
||||
id: bsversion
|
||||
attributes:
|
||||
label: Exact BookStack Version
|
||||
description: This can be found in the settings view of BookStack. Please provide an exact version.
|
||||
placeholder: (eg. v21.08.5)
|
||||
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)
|
||||
description: This can be found in the settings view of BookStack. Please provide an exact version(s) you've tested on.
|
||||
placeholder: (eg. v23.06.7)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
8
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -33,9 +33,9 @@ body:
|
||||
attributes:
|
||||
label: Have you searched for an existing open/closed issue?
|
||||
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:
|
||||
- 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
|
||||
- type: dropdown
|
||||
id: existing_usage
|
||||
@@ -43,8 +43,8 @@ body:
|
||||
label: How long have you been using BookStack?
|
||||
options:
|
||||
- Not using yet, just scoping
|
||||
- 0 to 6 months
|
||||
- 6 months to 1 year
|
||||
- Under 3 months
|
||||
- 3 months to 1 year
|
||||
- 1 to 5 years
|
||||
- Over 5 years
|
||||
validations:
|
||||
|
||||
12
.github/ISSUE_TEMPLATE/support_request.yml
vendored
12
.github/ISSUE_TEMPLATE/support_request.yml
vendored
@@ -33,7 +33,7 @@ body:
|
||||
attributes:
|
||||
label: Exact BookStack 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:
|
||||
required: true
|
||||
- type: textarea
|
||||
@@ -44,19 +44,11 @@ body:
|
||||
placeholder: Be sure to remove any confidential details in your logs
|
||||
validations:
|
||||
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
|
||||
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)
|
||||
placeholder: (eg. PHP8.1 on Ubuntu 22.04 VPS, installed using official installation script)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
15
.github/SECURITY.md
vendored
15
.github/SECURITY.md
vendored
@@ -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)
|
||||
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
|
||||
methods to report the vulnerability:
|
||||
|
||||
- Directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown).
|
||||
- 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.
|
||||
If the issue could have a security impact to BookStack instances,
|
||||
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).
|
||||
Alternatively you can send a DM via Mastodon to [@danb@fosstodon.org](https://fosstodon.org/@danb).
|
||||
|
||||
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
|
||||
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!
|
||||
|
||||
18
.github/translators.txt
vendored
18
.github/translators.txt
vendored
@@ -57,6 +57,7 @@ Name :: Languages
|
||||
@Jokuna :: Korean
|
||||
@smartshogu :: German; German Informal
|
||||
@samadha56 :: Persian
|
||||
@mrmuminov :: Uzbek
|
||||
cipi1965 :: Italian
|
||||
Mykola Ronik (Mantikor) :: Ukrainian
|
||||
furkanoyk :: Turkish
|
||||
@@ -176,7 +177,7 @@ Alexander Predl (Harveyhase68) :: German
|
||||
Rem (Rem9000) :: Dutch
|
||||
Michał Stelmach (stelmach-web) :: Polish
|
||||
arniom :: French
|
||||
REMOVED_USER :: ; French; Dutch; Turkish
|
||||
REMOVED_USER :: French; Dutch; Turkish;
|
||||
林祖年 (contagion) :: Chinese Traditional
|
||||
Siamak Guodarzi (siamakgoudarzi88) :: Persian
|
||||
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
|
||||
@@ -269,7 +270,7 @@ mcgong (GongMingCai) :: Chinese Simplified; Chinese Traditional
|
||||
Nanang Setia Budi (sefidananang) :: Indonesian
|
||||
Андрей Павлов (andrei.pavlov) :: Russian
|
||||
Alex Navarro (alex.n.navarro) :: Portuguese, Brazilian
|
||||
Ji-Hyeon Gim (PotatoGim) :: Korean
|
||||
Jihyeon Gim (PotatoGim) :: Korean
|
||||
Mihai Ochian (soulstorm19) :: Romanian
|
||||
HeartCore :: German Informal; German
|
||||
simon.pct :: French
|
||||
@@ -289,7 +290,7 @@ Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
|
||||
LiZerui (CNLiZerui) :: Chinese Traditional
|
||||
Fabrice Boyer (FabriceBoyer) :: French
|
||||
mikael (bitcanon) :: Swedish
|
||||
Matthias Mai (schnapsidee) :: German; German Informal
|
||||
Matthias Mai (schnapsidee) :: German Informal; German
|
||||
Ufuk Ayyıldız (ufukayyildiz) :: Turkish
|
||||
Jan Mitrof (jan.kachlik) :: Czech
|
||||
edwardsmirnov :: Russian
|
||||
@@ -347,7 +348,7 @@ robing29 :: German
|
||||
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
|
||||
Igor V Belousov (biv) :: Russian
|
||||
David Bauer (davbauer) :: German
|
||||
Guttorm Hveem (guttormhveem) :: Norwegian Bokmal
|
||||
Guttorm Hveem (guttormhveem) :: Norwegian Bokmal; Norwegian Nynorsk
|
||||
Minh Giang Truong (minhgiang1204) :: Vietnamese
|
||||
Ioannis Ioannides (i.ioannides) :: Greek
|
||||
Vadim (vadrozh) :: Russian
|
||||
@@ -357,3 +358,12 @@ Dženan (Dzenan) :: Swedish
|
||||
Péter Péli (peter.peli) :: Hungarian
|
||||
TWME :: Chinese Traditional
|
||||
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
|
||||
|
||||
8
.github/workflows/analyse-php.yml
vendored
8
.github/workflows/analyse-php.yml
vendored
@@ -1,6 +1,12 @@
|
||||
name: analyse-php
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.php'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.php'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
10
.github/workflows/lint-js.yml
vendored
10
.github/workflows/lint-js.yml
vendored
@@ -1,6 +1,14 @@
|
||||
name: lint-js
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.json'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
8
.github/workflows/lint-php.yml
vendored
8
.github/workflows/lint-php.yml
vendored
@@ -1,6 +1,12 @@
|
||||
name: lint-php
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.php'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.php'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
10
.github/workflows/test-migrations.yml
vendored
10
.github/workflows/test-migrations.yml
vendored
@@ -1,6 +1,14 @@
|
||||
name: test-migrations
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.php'
|
||||
- 'composer.*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.php'
|
||||
- 'composer.*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
10
.github/workflows/test-php.yml
vendored
10
.github/workflows/test-php.yml
vendored
@@ -1,6 +1,14 @@
|
||||
name: test-php
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.php'
|
||||
- 'composer.*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.php'
|
||||
- 'composer.*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -16,22 +16,12 @@ use Laravel\Socialite\Contracts\User as SocialUser;
|
||||
|
||||
class SocialController extends Controller
|
||||
{
|
||||
protected SocialAuthService $socialAuthService;
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
|
||||
/**
|
||||
* SocialController constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
SocialAuthService $socialAuthService,
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService
|
||||
protected SocialAuthService $socialAuthService,
|
||||
protected RegistrationService $registrationService,
|
||||
protected LoginService $loginService,
|
||||
) {
|
||||
$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);
|
||||
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
|
||||
{
|
||||
return app(RateLimiter::class);
|
||||
return app()->make(RateLimiter::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Access\Notifications\ConfirmEmailNotification;
|
||||
use BookStack\Exceptions\ConfirmationEmailException;
|
||||
use BookStack\Notifications\ConfirmEmail;
|
||||
use BookStack\Users\Models\User;
|
||||
|
||||
class EmailConfirmationService extends UserTokenService
|
||||
@@ -26,7 +26,7 @@ class EmailConfirmationService extends UserTokenService
|
||||
$this->deleteByUser($user);
|
||||
$token = $this->createTokenForUser($user);
|
||||
|
||||
$user->notify(new ConfirmEmail($token));
|
||||
$user->notify(new ConfirmEmailNotification($token));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Notifications;
|
||||
namespace BookStack\Access\Notifications;
|
||||
|
||||
use BookStack\App\MailNotification;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class ConfirmEmail extends MailNotification
|
||||
class ConfirmEmailNotification extends MailNotification
|
||||
{
|
||||
public function __construct(
|
||||
public string $token
|
||||
@@ -1,11 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Notifications;
|
||||
namespace BookStack\Access\Notifications;
|
||||
|
||||
use BookStack\App\MailNotification;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class ResetPassword extends MailNotification
|
||||
class ResetPasswordNotification extends MailNotification
|
||||
{
|
||||
public function __construct(
|
||||
public string $token
|
||||
27
app/Access/Notifications/UserInviteNotification.php
Normal file
27
app/Access/Notifications/UserInviteNotification.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Notifications;
|
||||
|
||||
use BookStack\App\MailNotification;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class UserInviteNotification extends MailNotification
|
||||
{
|
||||
public function __construct(
|
||||
public string $token
|
||||
) {
|
||||
}
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
$appName = ['appName' => setting('app-name')];
|
||||
$locale = $notifiable->getLocale();
|
||||
|
||||
return $this->newMailMessage($locale)
|
||||
->subject($locale->trans('auth.user_invite_email_subject', $appName))
|
||||
->greeting($locale->trans('auth.user_invite_email_greeting', $appName))
|
||||
->line($locale->trans('auth.user_invite_email_text'))
|
||||
->action($locale->trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token));
|
||||
}
|
||||
}
|
||||
@@ -20,15 +20,8 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
{
|
||||
use BearerAuthorizationTrait;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $authorizationEndpoint;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $tokenEndpoint;
|
||||
protected string $authorizationEndpoint;
|
||||
protected string $tokenEndpoint;
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
|
||||
@@ -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://');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Http\HttpRequestService;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
|
||||
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
||||
use Psr\Http\Client\ClientInterface as HttpClient;
|
||||
|
||||
/**
|
||||
* Class OpenIdConnectService
|
||||
@@ -26,7 +26,7 @@ class OidcService
|
||||
public function __construct(
|
||||
protected RegistrationService $registrationService,
|
||||
protected LoginService $loginService,
|
||||
protected HttpClient $httpClient,
|
||||
protected HttpRequestService $http,
|
||||
protected GroupSyncService $groupService
|
||||
) {
|
||||
}
|
||||
@@ -94,7 +94,7 @@ class OidcService
|
||||
// Run discovery
|
||||
if ($config['discover'] ?? false) {
|
||||
try {
|
||||
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
|
||||
$settings->discoverFromIssuer($this->http->buildClient(5), Cache::store(null), 15);
|
||||
} catch (OidcIssuerDiscoveryException $exception) {
|
||||
throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
|
||||
}
|
||||
@@ -111,7 +111,7 @@ class OidcService
|
||||
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
|
||||
{
|
||||
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [
|
||||
'httpClient' => $this->httpClient,
|
||||
'httpClient' => $this->http->buildClient(5),
|
||||
'optionProvider' => new HttpBasicAuthOptionProvider(),
|
||||
]);
|
||||
|
||||
@@ -142,10 +142,11 @@ class OidcService
|
||||
*/
|
||||
protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
|
||||
{
|
||||
$displayNameAttr = $this->config()['display_name_claims'];
|
||||
$displayNameAttrString = $this->config()['display_name_claims'] ?? '';
|
||||
$displayNameAttrs = explode('|', $displayNameAttrString);
|
||||
|
||||
$displayName = [];
|
||||
foreach ($displayNameAttr as $dnAttr) {
|
||||
foreach ($displayNameAttrs as $dnAttr) {
|
||||
$dnComponent = $token->getClaim($dnAttr) ?? '';
|
||||
if ($dnComponent !== '') {
|
||||
$displayName[] = $dnComponent;
|
||||
|
||||
@@ -154,21 +154,21 @@ class SocialAuthService
|
||||
$currentUser->socialAccounts()->save($account);
|
||||
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.
|
||||
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) {
|
||||
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.
|
||||
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) {
|
||||
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.
|
||||
@@ -214,6 +214,7 @@ class SocialAuthService
|
||||
|
||||
/**
|
||||
* Gets the names of the active social drivers.
|
||||
* @returns array<string, string>
|
||||
*/
|
||||
public function getActiveDrivers(): array
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Notifications\UserInvite;
|
||||
use BookStack\Access\Notifications\UserInviteNotification;
|
||||
use BookStack\Users\Models\User;
|
||||
|
||||
class UserInviteService extends UserTokenService
|
||||
@@ -18,6 +18,6 @@ class UserInviteService extends UserTokenService
|
||||
{
|
||||
$this->deleteByUser($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\Entities\Models\Entity;
|
||||
use BookStack\Entities\Queries\TopFavourites;
|
||||
use BookStack\Entities\Tools\MixedEntityRequestHelper;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class FavouriteController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected MixedEntityRequestHelper $entityHelper,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a listing of all favourite items for the current user.
|
||||
*/
|
||||
@@ -36,13 +42,14 @@ class FavouriteController extends Controller
|
||||
*/
|
||||
public function add(Request $request)
|
||||
{
|
||||
$favouritable = $this->getValidatedModelFromRequest($request);
|
||||
$favouritable->favourites()->firstOrCreate([
|
||||
$modelInfo = $this->validate($request, $this->entityHelper->validationRules());
|
||||
$entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
|
||||
$entity->favourites()->firstOrCreate([
|
||||
'user_id' => user()->id,
|
||||
]);
|
||||
|
||||
$this->showSuccessNotification(trans('activities.favourite_add_notification', [
|
||||
'name' => $favouritable->name,
|
||||
'name' => $entity->name,
|
||||
]));
|
||||
|
||||
return redirect()->back();
|
||||
@@ -53,48 +60,16 @@ class FavouriteController extends Controller
|
||||
*/
|
||||
public function remove(Request $request)
|
||||
{
|
||||
$favouritable = $this->getValidatedModelFromRequest($request);
|
||||
$favouritable->favourites()->where([
|
||||
$modelInfo = $this->validate($request, $this->entityHelper->validationRules());
|
||||
$entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
|
||||
$entity->favourites()->where([
|
||||
'user_id' => user()->id,
|
||||
])->delete();
|
||||
|
||||
$this->showSuccessNotification(trans('activities.favourite_remove_notification', [
|
||||
'name' => $favouritable->name,
|
||||
'name' => $entity->name,
|
||||
]));
|
||||
|
||||
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;
|
||||
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Tools\MixedEntityRequestHelper;
|
||||
use BookStack\Http\Controller;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class WatchController extends Controller
|
||||
{
|
||||
public function update(Request $request)
|
||||
public function update(Request $request, MixedEntityRequestHelper $entityHelper)
|
||||
{
|
||||
$this->checkPermission('receive-notifications');
|
||||
$this->preventGuestAccess();
|
||||
|
||||
$requestData = $this->validate($request, [
|
||||
$requestData = $this->validate($request, array_merge([
|
||||
'level' => ['required', 'string'],
|
||||
]);
|
||||
], $entityHelper->validationRules()));
|
||||
|
||||
$watchable = $this->getValidatedModelFromRequest($request);
|
||||
$watchable = $entityHelper->getVisibleEntityFromRequestData($requestData);
|
||||
$watchOptions = new UserEntityWatchOptions(user(), $watchable);
|
||||
$watchOptions->updateLevelByName($requestData['level']);
|
||||
|
||||
@@ -29,37 +26,4 @@ class WatchController extends Controller
|
||||
|
||||
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\Tools\WebhookFormatter;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Http\HttpRequestService;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Users\Models\User;
|
||||
use BookStack\Util\SsrUrlValidator;
|
||||
@@ -14,7 +15,6 @@ use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DispatchWebhookJob implements ShouldQueue
|
||||
@@ -49,25 +49,28 @@ class DispatchWebhookJob implements ShouldQueue
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
public function handle(HttpRequestService $http)
|
||||
{
|
||||
$lastError = null;
|
||||
|
||||
try {
|
||||
(new SsrUrlValidator())->ensureAllowed($this->webhook->endpoint);
|
||||
|
||||
$response = Http::asJson()
|
||||
->withOptions(['allow_redirects' => ['strict' => true]])
|
||||
->timeout($this->webhook->timeout)
|
||||
->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}\"");
|
||||
}
|
||||
$client = $http->buildClient($this->webhook->timeout, [
|
||||
'connect_timeout' => 10,
|
||||
'allow_redirects' => ['strict' => true],
|
||||
]);
|
||||
|
||||
if (isset($response) && $response->failed()) {
|
||||
$lastError = "Response status from endpoint was {$response->status()}";
|
||||
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}");
|
||||
$response = $client->sendRequest($http->jsonRequest('POST', $this->webhook->endpoint, $this->webhookData));
|
||||
$statusCode = $response->getStatusCode();
|
||||
|
||||
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();
|
||||
|
||||
@@ -12,10 +12,12 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
* @property int $id
|
||||
* @property string $text
|
||||
* @property string $html
|
||||
* @property int|null $parent_id
|
||||
* @property int|null $parent_id - Relates to local_id, not id
|
||||
* @property int $local_id
|
||||
* @property string $entity_type
|
||||
* @property int $entity_id
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
*/
|
||||
class Comment extends Model implements Loggable
|
||||
{
|
||||
@@ -38,7 +40,9 @@ class Comment extends Model implements Loggable
|
||||
*/
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Comment::class);
|
||||
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
|
||||
->where('entity_type', '=', $this->entity_type)
|
||||
->where('entity_id', '=', $this->entity_id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,7 +41,7 @@ class View extends Model
|
||||
public static function incrementFor(Viewable $viewable): int
|
||||
{
|
||||
$user = user();
|
||||
if (is_null($user) || $user->isDefault()) {
|
||||
if ($user->isGuest()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ namespace BookStack\Activity\Notifications\Messages;
|
||||
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine;
|
||||
use BookStack\Notifications\MailNotification;
|
||||
use BookStack\App\MailNotification;
|
||||
use BookStack\Translation\LocaleDefinition;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
|
||||
@@ -35,12 +36,12 @@ abstract class BaseActivityNotification extends MailNotification
|
||||
/**
|
||||
* 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(
|
||||
url('/preferences/notifications'),
|
||||
trans('notifications.footer_reason', [], $language),
|
||||
trans('notifications.footer_reason_link', [], $language),
|
||||
$locale->trans('notifications.footer_reason'),
|
||||
$locale->trans('notifications.footer_reason_link'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,17 +17,17 @@ class CommentCreationNotification extends BaseActivityNotification
|
||||
/** @var Page $page */
|
||||
$page = $comment->entity;
|
||||
|
||||
$language = $notifiable->getLanguage();
|
||||
$locale = $notifiable->getLocale();
|
||||
|
||||
return $this->newMailMessage($language)
|
||||
->subject(trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()], $language))
|
||||
->line(trans('notifications.new_comment_intro', ['appName' => setting('app-name')], $language))
|
||||
return $this->newMailMessage($locale)
|
||||
->subject($locale->trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()]))
|
||||
->line($locale->trans('notifications.new_comment_intro', ['appName' => setting('app-name')]))
|
||||
->line(new ListMessageLine([
|
||||
trans('notifications.detail_page_name', [], $language) => $page->name,
|
||||
trans('notifications.detail_commenter', [], $language) => $this->user->name,
|
||||
trans('notifications.detail_comment', [], $language) => strip_tags($comment->html),
|
||||
$locale->trans('notifications.detail_page_name') => $page->name,
|
||||
$locale->trans('notifications.detail_commenter') => $this->user->name,
|
||||
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
|
||||
]))
|
||||
->action(trans('notifications.action_view_comment', [], $language), $page->getUrl('#comment' . $comment->local_id))
|
||||
->line($this->buildReasonFooterLine($language));
|
||||
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
|
||||
->line($this->buildReasonFooterLine($locale));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,16 +14,16 @@ class PageCreationNotification extends BaseActivityNotification
|
||||
/** @var Page $page */
|
||||
$page = $this->detail;
|
||||
|
||||
$language = $notifiable->getLanguage();
|
||||
$locale = $notifiable->getLocale();
|
||||
|
||||
return $this->newMailMessage($language)
|
||||
->subject(trans('notifications.new_page_subject', ['pageName' => $page->getShortName()], $language))
|
||||
->line(trans('notifications.new_page_intro', ['appName' => setting('app-name')], $language))
|
||||
return $this->newMailMessage($locale)
|
||||
->subject($locale->trans('notifications.new_page_subject', ['pageName' => $page->getShortName()]))
|
||||
->line($locale->trans('notifications.new_page_intro', ['appName' => setting('app-name')], $locale))
|
||||
->line(new ListMessageLine([
|
||||
trans('notifications.detail_page_name', [], $language) => $page->name,
|
||||
trans('notifications.detail_created_by', [], $language) => $this->user->name,
|
||||
$locale->trans('notifications.detail_page_name') => $page->name,
|
||||
$locale->trans('notifications.detail_created_by') => $this->user->name,
|
||||
]))
|
||||
->action(trans('notifications.action_view_page', [], $language), $page->getUrl())
|
||||
->line($this->buildReasonFooterLine($language));
|
||||
->action($locale->trans('notifications.action_view_page'), $page->getUrl())
|
||||
->line($this->buildReasonFooterLine($locale));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,17 +14,17 @@ class PageUpdateNotification extends BaseActivityNotification
|
||||
/** @var Page $page */
|
||||
$page = $this->detail;
|
||||
|
||||
$language = $notifiable->getLanguage();
|
||||
$locale = $notifiable->getLocale();
|
||||
|
||||
return $this->newMailMessage($language)
|
||||
->subject(trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()], $language))
|
||||
->line(trans('notifications.updated_page_intro', ['appName' => setting('app-name')], $language))
|
||||
return $this->newMailMessage($locale)
|
||||
->subject($locale->trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()]))
|
||||
->line($locale->trans('notifications.updated_page_intro', ['appName' => setting('app-name')]))
|
||||
->line(new ListMessageLine([
|
||||
trans('notifications.detail_page_name', [], $language) => $page->name,
|
||||
trans('notifications.detail_updated_by', [], $language) => $this->user->name,
|
||||
$locale->trans('notifications.detail_page_name') => $page->name,
|
||||
$locale->trans('notifications.detail_updated_by') => $this->user->name,
|
||||
]))
|
||||
->line(trans('notifications.updated_page_debounce', [], $language))
|
||||
->action(trans('notifications.action_view_page', [], $language), $page->getUrl())
|
||||
->line($this->buildReasonFooterLine($language));
|
||||
->line($locale->trans('notifications.updated_page_debounce'))
|
||||
->action($locale->trans('notifications.action_view_page'), $page->getUrl())
|
||||
->line($this->buildReasonFooterLine($locale));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ class UserEntityWatchOptions
|
||||
|
||||
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
|
||||
|
||||
@@ -31,6 +31,8 @@ class ApiDocsController extends ApiController
|
||||
|
||||
/**
|
||||
* 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()
|
||||
{
|
||||
|
||||
@@ -52,4 +52,12 @@ class ApiToken extends Model implements Loggable
|
||||
{
|
||||
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.
|
||||
*/
|
||||
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->checkPermissionOrCurrentUser('users-manage', $userId);
|
||||
$this->updateContext($request);
|
||||
|
||||
$user = User::query()->findOrFail($userId);
|
||||
|
||||
$this->setPageTitle(trans('settings.user_api_token_create'));
|
||||
|
||||
return view('users.api-tokens.create', [
|
||||
'user' => $user,
|
||||
'back' => $this->getRedirectPath($user),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -60,22 +63,27 @@ class UserApiTokenController extends Controller
|
||||
session()->flash('api-token-secret:' . $token->id, $secret);
|
||||
$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.
|
||||
*/
|
||||
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);
|
||||
$secret = session()->pull('api-token-secret:' . $token->id, null);
|
||||
|
||||
$this->setPageTitle(trans('settings.user_api_token'));
|
||||
|
||||
return view('users.api-tokens.edit', [
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
'model' => $token,
|
||||
'secret' => $secret,
|
||||
'back' => $this->getRedirectPath($user),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -97,7 +105,7 @@ class UserApiTokenController extends Controller
|
||||
|
||||
$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);
|
||||
|
||||
$this->setPageTitle(trans('settings.user_api_token_delete'));
|
||||
|
||||
return view('users.api-tokens.delete', [
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
@@ -123,7 +133,7 @@ class UserApiTokenController extends Controller
|
||||
|
||||
$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];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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') {
|
||||
$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]);
|
||||
|
||||
return view('home.shelves', $data);
|
||||
}
|
||||
|
||||
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]);
|
||||
|
||||
return view('home.books', $data);
|
||||
@@ -140,4 +140,12 @@ class HomeController extends Controller
|
||||
$exists = $favicons->restoreOriginalIfNotExists();
|
||||
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
|
||||
|
||||
namespace BookStack\Notifications;
|
||||
namespace BookStack\App;
|
||||
|
||||
use BookStack\Translation\LocaleDefinition;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -32,9 +33,9 @@ abstract class MailNotification extends Notification implements ShouldQueue
|
||||
/**
|
||||
* 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([
|
||||
'html' => 'vendor.notifications.email',
|
||||
@@ -9,16 +9,15 @@ use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exceptions\BookStackExceptionHandlerPage;
|
||||
use BookStack\Http\HttpRequestService;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Settings\SettingService;
|
||||
use BookStack\Util\CspService;
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Contracts\Foundation\ExceptionRenderer;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Psr\Http\Client\ClientInterface as HttpClientInterface;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -39,6 +38,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
SettingService::class => SettingService::class,
|
||||
SocialAuthService::class => SocialAuthService::class,
|
||||
CspService::class => CspService::class,
|
||||
HttpRequestService::class => HttpRequestService::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -51,7 +51,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
// Set root URL
|
||||
$appUrl = config('app.url');
|
||||
if ($appUrl) {
|
||||
$isHttps = (strpos($appUrl, 'https://') === 0);
|
||||
$isHttps = str_starts_with($appUrl, 'https://');
|
||||
URL::forceRootUrl($appUrl);
|
||||
URL::forceScheme($isHttps ? 'https' : 'http');
|
||||
}
|
||||
@@ -75,12 +75,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->app->bind(HttpClientInterface::class, function ($app) {
|
||||
return new Client([
|
||||
'timeout' => 3,
|
||||
]);
|
||||
});
|
||||
|
||||
$this->app->singleton(PermissionApplicator::class, function ($app) {
|
||||
return new PermissionApplicator(null);
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ use BookStack\Access\LdapService;
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Access\RegistrationService;
|
||||
use BookStack\Api\ApiTokenGuard;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
@@ -65,5 +66,11 @@ class AuthServiceProvider extends ServiceProvider
|
||||
Auth::provider('external-users', function ($app, array $config) {
|
||||
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;
|
||||
|
||||
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\Okta\OktaExtendSocialite;
|
||||
use SocialiteProviders\Twitch\TwitchExtendSocialite;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -14,12 +19,11 @@ class EventServiceProvider extends ServiceProvider
|
||||
*/
|
||||
protected $listen = [
|
||||
SocialiteWasCalled::class => [
|
||||
'SocialiteProviders\Slack\SlackExtendSocialite@handle',
|
||||
'SocialiteProviders\Azure\AzureExtendSocialite@handle',
|
||||
'SocialiteProviders\Okta\OktaExtendSocialite@handle',
|
||||
'SocialiteProviders\GitLab\GitLabExtendSocialite@handle',
|
||||
'SocialiteProviders\Twitch\TwitchExtendSocialite@handle',
|
||||
'SocialiteProviders\Discord\DiscordExtendSocialite@handle',
|
||||
AzureExtendSocialite::class . '@handle',
|
||||
OktaExtendSocialite::class . '@handle',
|
||||
GitLabExtendSocialite::class . '@handle',
|
||||
TwitchExtendSocialite::class . '@handle',
|
||||
DiscordExtendSocialite::class . '@handle',
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ class ViewTweaksServiceProvider extends ServiceProvider
|
||||
|
||||
// Custom blade view directives
|
||||
Blade::directive('icon', function ($expression) {
|
||||
return "<?php echo icon($expression); ?>";
|
||||
return "<?php echo (new \BookStack\Util\SvgIcon($expression))->toHtml(); ?>";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
59
app/App/PwaManifestBuilder.php
Normal file
59
app/App/PwaManifestBuilder.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\App;
|
||||
|
||||
class PwaManifestBuilder
|
||||
{
|
||||
public function build(): array
|
||||
{
|
||||
$darkMode = (bool) setting()->getForCurrentUser('dark-mode-enabled');
|
||||
$appName = setting('app-name');
|
||||
|
||||
return [
|
||||
"name" => $appName,
|
||||
"short_name" => $appName,
|
||||
"start_url" => "./",
|
||||
"scope" => "/",
|
||||
"display" => "standalone",
|
||||
"background_color" => $darkMode ? '#111111' : '#F2F2F2',
|
||||
"description" => $appName,
|
||||
"theme_color" => ($darkMode ? setting('app-color-dark') : setting('app-color')),
|
||||
"launch_handler" => [
|
||||
"client_mode" => "focus-existing"
|
||||
],
|
||||
"orientation" => "portrait",
|
||||
"icons" => [
|
||||
[
|
||||
"src" => setting('app-icon-32') ?: url('/icon-32.png'),
|
||||
"sizes" => "32x32",
|
||||
"type" => "image/png"
|
||||
],
|
||||
[
|
||||
"src" => setting('app-icon-64') ?: url('/icon-64.png'),
|
||||
"sizes" => "64x64",
|
||||
"type" => "image/png"
|
||||
],
|
||||
[
|
||||
"src" => setting('app-icon-128') ?: url('/icon-128.png'),
|
||||
"sizes" => "128x128",
|
||||
"type" => "image/png"
|
||||
],
|
||||
[
|
||||
"src" => setting('app-icon-180') ?: url('/icon-180.png'),
|
||||
"sizes" => "180x180",
|
||||
"type" => "image/png"
|
||||
],
|
||||
[
|
||||
"src" => setting('app-icon') ?: url('/icon.png'),
|
||||
"sizes" => "256x256",
|
||||
"type" => "image/png"
|
||||
],
|
||||
[
|
||||
"src" => url('favicon.ico'),
|
||||
"sizes" => "48x48",
|
||||
"type" => "image/vnd.microsoft.icon"
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -35,23 +35,7 @@ function versioned_asset(string $file = ''): string
|
||||
*/
|
||||
function user(): User
|
||||
{
|
||||
return auth()->user() ?: User::getDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
return auth()->user() ?: User::getGuest();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,11 +45,11 @@ function hasAppAccess(): bool
|
||||
function userCan(string $permission, Model $ownable = null): bool
|
||||
{
|
||||
if ($ownable === null) {
|
||||
return user() && user()->can($permission);
|
||||
return user()->can($permission);
|
||||
}
|
||||
|
||||
// Check permission on ownable item
|
||||
$permissions = app(PermissionApplicator::class);
|
||||
$permissions = app()->make(PermissionApplicator::class);
|
||||
|
||||
return $permissions->checkOwnableUserAccess($ownable, $permission);
|
||||
}
|
||||
@@ -76,7 +60,7 @@ function userCan(string $permission, Model $ownable = null): bool
|
||||
*/
|
||||
function userCanOnAny(string $action, string $entityClass = ''): bool
|
||||
{
|
||||
$permissions = app(PermissionApplicator::class);
|
||||
$permissions = app()->make(PermissionApplicator::class);
|
||||
|
||||
return $permissions->checkUserHasEntityPermissionOnAny($action, $entityClass);
|
||||
}
|
||||
@@ -88,7 +72,7 @@ function userCanOnAny(string $action, string $entityClass = ''): bool
|
||||
*/
|
||||
function setting(string $key = null, $default = null)
|
||||
{
|
||||
$settingService = resolve(SettingService::class);
|
||||
$settingService = app()->make(SettingService::class);
|
||||
|
||||
if (is_null($key)) {
|
||||
return $settingService;
|
||||
@@ -113,39 +97,6 @@ function theme_path(string $path = ''): ?string
|
||||
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.
|
||||
* Works out the logic to set the correct sorting direction
|
||||
|
||||
@@ -83,10 +83,10 @@ return [
|
||||
'timezone' => env('APP_TIMEZONE', 'UTC'),
|
||||
|
||||
// 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'),
|
||||
|
||||
// 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'],
|
||||
'default_locale' => env('APP_LANG', 'en'),
|
||||
|
||||
// Application Fallback Locale
|
||||
'fallback_locale' => 'en',
|
||||
@@ -94,9 +94,6 @@ return [
|
||||
// Faker Locale
|
||||
'faker_locale' => 'en_GB',
|
||||
|
||||
// Enable right-to-left text control.
|
||||
'rtl' => false,
|
||||
|
||||
// Auto-detect the locale for public users
|
||||
// For public users their locale can be guessed by headers sent by their
|
||||
// browser. This is usually set by users in their browser settings.
|
||||
|
||||
@@ -22,7 +22,7 @@ return [
|
||||
|
||||
// Global "From" address & name
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM', 'mail@bookstackapp.com'),
|
||||
'address' => env('MAIL_FROM', 'bookstack@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', 'BookStack'),
|
||||
],
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ return [
|
||||
'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false),
|
||||
|
||||
// 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.
|
||||
'external_id_claim' => env('OIDC_EXTERNAL_ID_CLAIM', 'sub'),
|
||||
|
||||
@@ -35,7 +35,7 @@ class CleanupImagesCommand extends Command
|
||||
|
||||
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");
|
||||
$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) {
|
||||
return 0;
|
||||
}
|
||||
@@ -46,7 +46,7 @@ class CleanupImagesCommand extends Command
|
||||
|
||||
if ($dryRun) {
|
||||
$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->comment('Run with -f or --force to perform deletions');
|
||||
|
||||
@@ -54,7 +54,8 @@ class CleanupImagesCommand extends Command
|
||||
}
|
||||
|
||||
$this->showDeletedImages($deleted);
|
||||
$this->comment($deleteCount . ' images deleted');
|
||||
$this->comment("{$deleteCount} image(s) deleted");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -65,7 +66,7 @@ class CleanupImagesCommand extends Command
|
||||
}
|
||||
|
||||
if (count($paths) > 0) {
|
||||
$this->line('Images to delete:');
|
||||
$this->line('Image(s) to delete:');
|
||||
}
|
||||
|
||||
foreach ($paths as $path) {
|
||||
|
||||
40
app/Console/Commands/HandlesSingleUser.php
Normal file
40
app/Console/Commands/HandlesSingleUser.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Users\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* @mixin Command
|
||||
*/
|
||||
trait HandlesSingleUser
|
||||
{
|
||||
/**
|
||||
* Fetch a user provided to this command.
|
||||
* Expects the command to accept 'id' and 'email' options.
|
||||
* @throws Exception
|
||||
*/
|
||||
private function fetchProvidedUser(): User
|
||||
{
|
||||
$id = $this->option('id');
|
||||
$email = $this->option('email');
|
||||
if (!$id && !$email) {
|
||||
throw new Exception("Either a --id=<number> or --email=<email> option must be provided.\nRun this command with `--help` to show more options.");
|
||||
}
|
||||
|
||||
$field = $id ? 'id' : 'email';
|
||||
$value = $id ?: $email;
|
||||
|
||||
$user = User::query()
|
||||
->where($field, '=', $value)
|
||||
->first();
|
||||
|
||||
if (!$user) {
|
||||
throw new Exception("A user where {$field}={$value} could not be found.");
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
116
app/Console/Commands/RefreshAvatarCommand.php
Normal file
116
app/Console/Commands/RefreshAvatarCommand.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Users\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use BookStack\Uploads\UserAvatars;
|
||||
|
||||
class RefreshAvatarCommand extends Command
|
||||
{
|
||||
use HandlesSingleUser;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bookstack:refresh-avatar
|
||||
{--id= : Numeric ID of the user to refresh avatar for}
|
||||
{--email= : Email address of the user to refresh avatar for}
|
||||
{--users-without-avatars : Refresh avatars for users that currently have no avatar}
|
||||
{--a|all : Refresh avatars for all users}
|
||||
{--f|force : Actually run the update, Defaults to a dry-run}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Refresh avatar for the given user(s)';
|
||||
|
||||
public function handle(UserAvatars $userAvatar): int
|
||||
{
|
||||
if (!$userAvatar->avatarFetchEnabled()) {
|
||||
$this->error("Avatar fetching is disabled on this instance.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->option('users-without-avatars')) {
|
||||
return $this->processUsers(User::query()->whereDoesntHave('avatar')->get()->all(), $userAvatar);
|
||||
}
|
||||
|
||||
if ($this->option('all')) {
|
||||
return $this->processUsers(User::query()->get()->all(), $userAvatar);
|
||||
}
|
||||
|
||||
try {
|
||||
$user = $this->fetchProvidedUser();
|
||||
return $this->processUsers([$user], $userAvatar);
|
||||
} catch (Exception $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param User[] $users
|
||||
*/
|
||||
private function processUsers(array $users, UserAvatars $userAvatar): int
|
||||
{
|
||||
$dryRun = !$this->option('force');
|
||||
$this->info(count($users) . " user(s) found to update avatars for.");
|
||||
|
||||
if (count($users) === 0) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
$fetchHost = parse_url($userAvatar->getAvatarUrl(), PHP_URL_HOST);
|
||||
$this->warn("This will destroy any existing avatar images these users have, and attempt to fetch new avatar images from {$fetchHost}.");
|
||||
$proceed = !$this->input->isInteractive() || $this->confirm('Are you sure you want to proceed?');
|
||||
if (!$proceed) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("");
|
||||
|
||||
$exitCode = self::SUCCESS;
|
||||
foreach ($users as $user) {
|
||||
$linePrefix = "[ID: {$user->id}] $user->email -";
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn("{$linePrefix} Not updated");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->fetchAvatar($userAvatar, $user)) {
|
||||
$this->info("{$linePrefix} Updated");
|
||||
} else {
|
||||
$this->error("{$linePrefix} Not updated");
|
||||
$exitCode = self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->comment("");
|
||||
$this->comment("Dry run, no avatars were updated.");
|
||||
$this->comment('Run with -f or --force to perform the update.');
|
||||
}
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
private function fetchAvatar(UserAvatars $userAvatar, User $user): bool
|
||||
{
|
||||
$oldId = $user->avatar->id ?? 0;
|
||||
|
||||
$userAvatar->fetchAndAssignToUser($user);
|
||||
|
||||
$user->refresh();
|
||||
$newId = $user->avatar->id ?? $oldId;
|
||||
return $oldId !== $newId;
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Users\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ResetMfaCommand extends Command
|
||||
{
|
||||
use HandlesSingleUser;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
@@ -29,25 +31,10 @@ class ResetMfaCommand extends Command
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$id = $this->option('id');
|
||||
$email = $this->option('email');
|
||||
if (!$id && !$email) {
|
||||
$this->error('Either a --id=<number> or --email=<email> option must be provided.');
|
||||
|
||||
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.");
|
||||
|
||||
try {
|
||||
$user = $this->fetchProvidedUser();
|
||||
} catch (Exception $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
return 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,29 +8,21 @@ use Illuminate\View\View;
|
||||
|
||||
class BreadcrumbsViewComposer
|
||||
{
|
||||
protected $entityContextManager;
|
||||
|
||||
/**
|
||||
* BreadcrumbsViewComposer constructor.
|
||||
*
|
||||
* @param ShelfContext $entityContextManager
|
||||
*/
|
||||
public function __construct(ShelfContext $entityContextManager)
|
||||
{
|
||||
$this->entityContextManager = $entityContextManager;
|
||||
public function __construct(
|
||||
protected ShelfContext $shelfContext
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'];
|
||||
$firstCrumb = $crumbs[0] ?? null;
|
||||
|
||||
if ($firstCrumb instanceof Book) {
|
||||
$shelf = $this->entityContextManager->getContextualShelfForBook($firstCrumb);
|
||||
$shelf = $this->shelfContext->getContextualShelfForBook($firstCrumb);
|
||||
if ($shelf) {
|
||||
array_unshift($crumbs, $shelf);
|
||||
$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.
|
||||
*
|
||||
* @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==';
|
||||
if (!$this->image_id) {
|
||||
if (!$this->image_id || !$this->cover) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
try {
|
||||
$cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
|
||||
return $this->cover->getThumb($width, $height, false) ?? $default;
|
||||
} catch (Exception $err) {
|
||||
$cover = $default;
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $cover;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Uploads\Image;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
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.
|
||||
*
|
||||
* @param int $width - Width of the image
|
||||
* @param int $height - Height of the image
|
||||
*
|
||||
* @return string
|
||||
* Returns shelf cover image, if cover not exists return default cover image.
|
||||
*/
|
||||
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
|
||||
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
|
||||
if (!$this->image_id) {
|
||||
if (!$this->image_id || !$this->cover) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
try {
|
||||
$cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
|
||||
} catch (\Exception $err) {
|
||||
$cover = $default;
|
||||
return $this->cover->getThumb($width, $height, false) ?? $default;
|
||||
} catch (Exception $err) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $cover;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@ class RecentlyViewed extends EntityQuery
|
||||
public function run(int $count, int $page): Collection
|
||||
{
|
||||
$user = user();
|
||||
if ($user === null || $user->isDefault()) {
|
||||
if ($user === null || $user->isGuest()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ class TopFavourites extends EntityQuery
|
||||
public function run(int $count, int $skip = 0)
|
||||
{
|
||||
$user = user();
|
||||
if ($user->isDefault()) {
|
||||
if ($user->isGuest()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
|
||||
@@ -16,18 +16,11 @@ use Throwable;
|
||||
|
||||
class ExportFormatter
|
||||
{
|
||||
protected ImageService $imageService;
|
||||
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;
|
||||
public function __construct(
|
||||
protected ImageService $imageService,
|
||||
protected PdfGenerator $pdfGenerator,
|
||||
protected CspService $cspService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,13 +29,14 @@ class ExportFormatter
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function pageToContainedHtml(Page $page)
|
||||
public function pageToContainedHtml(Page $page): string
|
||||
{
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$pageHtml = view('exports.page', [
|
||||
'page' => $page,
|
||||
'format' => 'html',
|
||||
'cspContent' => $this->cspService->getCspMetaTagValue(),
|
||||
'locale' => user()->getLocale(),
|
||||
])->render();
|
||||
|
||||
return $this->containHtml($pageHtml);
|
||||
@@ -53,7 +47,7 @@ class ExportFormatter
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function chapterToContainedHtml(Chapter $chapter)
|
||||
public function chapterToContainedHtml(Chapter $chapter): string
|
||||
{
|
||||
$pages = $chapter->getVisiblePages();
|
||||
$pages->each(function ($page) {
|
||||
@@ -64,6 +58,7 @@ class ExportFormatter
|
||||
'pages' => $pages,
|
||||
'format' => 'html',
|
||||
'cspContent' => $this->cspService->getCspMetaTagValue(),
|
||||
'locale' => user()->getLocale(),
|
||||
])->render();
|
||||
|
||||
return $this->containHtml($html);
|
||||
@@ -74,7 +69,7 @@ class ExportFormatter
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function bookToContainedHtml(Book $book)
|
||||
public function bookToContainedHtml(Book $book): string
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$html = view('exports.book', [
|
||||
@@ -82,6 +77,7 @@ class ExportFormatter
|
||||
'bookChildren' => $bookTree,
|
||||
'format' => 'html',
|
||||
'cspContent' => $this->cspService->getCspMetaTagValue(),
|
||||
'locale' => user()->getLocale(),
|
||||
])->render();
|
||||
|
||||
return $this->containHtml($html);
|
||||
@@ -92,13 +88,14 @@ class ExportFormatter
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function pageToPdf(Page $page)
|
||||
public function pageToPdf(Page $page): string
|
||||
{
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$html = view('exports.page', [
|
||||
'page' => $page,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
'locale' => user()->getLocale(),
|
||||
])->render();
|
||||
|
||||
return $this->htmlToPdf($html);
|
||||
@@ -109,7 +106,7 @@ class ExportFormatter
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function chapterToPdf(Chapter $chapter)
|
||||
public function chapterToPdf(Chapter $chapter): string
|
||||
{
|
||||
$pages = $chapter->getVisiblePages();
|
||||
$pages->each(function ($page) {
|
||||
@@ -121,6 +118,7 @@ class ExportFormatter
|
||||
'pages' => $pages,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
'locale' => user()->getLocale(),
|
||||
])->render();
|
||||
|
||||
return $this->htmlToPdf($html);
|
||||
@@ -131,7 +129,7 @@ class ExportFormatter
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function bookToPdf(Book $book)
|
||||
public function bookToPdf(Book $book): string
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$html = view('exports.book', [
|
||||
@@ -139,6 +137,7 @@ class ExportFormatter
|
||||
'bookChildren' => $bookTree,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
'locale' => user()->getLocale(),
|
||||
])->render();
|
||||
|
||||
return $this->htmlToPdf($html);
|
||||
@@ -194,7 +193,7 @@ class ExportFormatter
|
||||
/** @var DOMElement $iframe */
|
||||
foreach ($iframes as $iframe) {
|
||||
$link = $iframe->getAttribute('src');
|
||||
if (strpos($link, '//') === 0) {
|
||||
if (str_starts_with($link, '//')) {
|
||||
$link = 'https:' . $link;
|
||||
}
|
||||
|
||||
@@ -223,7 +222,7 @@ class ExportFormatter
|
||||
foreach ($imageTagsOutput[0] as $index => $imgMatch) {
|
||||
$oldImgTagString = $imgMatch;
|
||||
$srcString = $imageTagsOutput[2][$index];
|
||||
$imageEncoded = $this->imageService->imageUriToBase64($srcString);
|
||||
$imageEncoded = $this->imageService->imageUrlToBase64($srcString);
|
||||
if ($imageEncoded === null) {
|
||||
$imageEncoded = $srcString;
|
||||
}
|
||||
@@ -240,7 +239,7 @@ class ExportFormatter
|
||||
foreach ($linksOutput[0] as $index => $linkMatch) {
|
||||
$oldLinkString = $linkMatch;
|
||||
$srcString = $linksOutput[2][$index];
|
||||
if (strpos(trim($srcString), 'http') !== 0) {
|
||||
if (!str_starts_with(trim($srcString), 'http')) {
|
||||
$newSrcString = url($srcString);
|
||||
$newLinkString = str_replace($srcString, $newSrcString, $oldLinkString);
|
||||
$htmlContent = str_replace($oldLinkString, $newLinkString, $htmlContent);
|
||||
@@ -255,17 +254,20 @@ class ExportFormatter
|
||||
* Converts the page contents into simple plain text.
|
||||
* 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();
|
||||
$text = strip_tags($html);
|
||||
$html = $pageRendered ? $page->html : (new PageContent($page))->render();
|
||||
// Add proceeding spaces before tags so spaces remain between
|
||||
// text within elements after stripping tags.
|
||||
$html = str_replace('<', " <", $html);
|
||||
$text = trim(strip_tags($html));
|
||||
// Replace multiple spaces with single spaces
|
||||
$text = preg_replace('/\ {2,}/', ' ', $text);
|
||||
$text = preg_replace('/ {2,}/', ' ', $text);
|
||||
// Reduce multiple horrid whitespace characters.
|
||||
$text = preg_replace('/(\x0A|\xA0|\x0A|\r|\n){2,}/su', "\n\n", $text);
|
||||
$text = html_entity_decode($text);
|
||||
// Add title
|
||||
$text = $page->name . "\n\n" . $text;
|
||||
$text = $page->name . ($fromParent ? "\n" : "\n\n") . $text;
|
||||
|
||||
return $text;
|
||||
}
|
||||
@@ -275,13 +277,15 @@ class ExportFormatter
|
||||
*/
|
||||
public function chapterToPlainText(Chapter $chapter): string
|
||||
{
|
||||
$text = $chapter->name . "\n\n";
|
||||
$text .= $chapter->description . "\n\n";
|
||||
$text = $chapter->name . "\n" . $chapter->description;
|
||||
$text = trim($text) . "\n\n";
|
||||
|
||||
$parts = [];
|
||||
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
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, false);
|
||||
$text = $book->name . "\n\n";
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$text = $book->name . "\n" . $book->description;
|
||||
$text = rtrim($text) . "\n\n";
|
||||
|
||||
$parts = [];
|
||||
foreach ($bookTree as $bookChild) {
|
||||
if ($bookChild->isA('chapter')) {
|
||||
$text .= $this->chapterToPlainText($bookChild);
|
||||
$parts[] = $this->chapterToPlainText($bookChild);
|
||||
} else {
|
||||
$text .= $this->pageToPlainText($bookChild);
|
||||
$parts[] = $this->pageToPlainText($bookChild, true, true);
|
||||
}
|
||||
}
|
||||
|
||||
return $text;
|
||||
return $text . implode("\n\n", $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
39
app/Entities/Tools/MixedEntityRequestHelper.php
Normal file
39
app/Entities/Tools/MixedEntityRequestHelper.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
|
||||
class MixedEntityRequestHelper
|
||||
{
|
||||
public function __construct(
|
||||
protected EntityProvider $entities,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Query out an entity, visible to the current user, for the given
|
||||
* entity request details (this provided in a request validated by
|
||||
* this classes' validationRules method).
|
||||
* @param array{type: string, id: string} $requestData
|
||||
*/
|
||||
public function getVisibleEntityFromRequestData(array $requestData): Entity
|
||||
{
|
||||
$entityType = $this->entities->get($requestData['type']);
|
||||
|
||||
return $entityType->newQuery()->scopes(['visible'])->findOrFail($requestData['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules for an abstract entity request.
|
||||
* @return array{type: string[], id: string[]}
|
||||
*/
|
||||
public function validationRules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', 'string'],
|
||||
'id' => ['required', 'integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -197,7 +197,7 @@ class TrashCan
|
||||
$page->allRevisions()->delete();
|
||||
|
||||
// Delete Attached Files
|
||||
$attachmentService = app(AttachmentService::class);
|
||||
$attachmentService = app()->make(AttachmentService::class);
|
||||
foreach ($page->attachments as $attachment) {
|
||||
$attachmentService->deleteFile($attachment);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ use Exception;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
|
||||
use Illuminate\Http\Exceptions\PostTooLargeException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\ErrorHandler\Error\FatalError;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
|
||||
use Throwable;
|
||||
|
||||
@@ -35,6 +37,15 @@ class Handler extends ExceptionHandler
|
||||
'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.
|
||||
*
|
||||
@@ -59,6 +70,17 @@ class Handler extends ExceptionHandler
|
||||
*/
|
||||
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)) {
|
||||
return $this->renderApiException($e);
|
||||
}
|
||||
@@ -66,12 +88,30 @@ class Handler extends ExceptionHandler
|
||||
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.
|
||||
*/
|
||||
protected function isApiRequest(Request $request): bool
|
||||
{
|
||||
return strpos($request->path(), 'api/') === 0;
|
||||
return str_starts_with($request->path(), 'api/');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
7
app/Exceptions/ThemeException.php
Normal file
7
app/Exceptions/ThemeException.php
Normal file
@@ -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
|
||||
{
|
||||
if (!signedInUser()) {
|
||||
if (user()->isGuest()) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
}
|
||||
|
||||
33
app/Http/HttpClientHistory.php
Normal file
33
app/Http/HttpClientHistory.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http;
|
||||
|
||||
use GuzzleHttp\Psr7\Request as GuzzleRequest;
|
||||
|
||||
class HttpClientHistory
|
||||
{
|
||||
public function __construct(
|
||||
protected &$container
|
||||
) {
|
||||
}
|
||||
|
||||
public function requestCount(): int
|
||||
{
|
||||
return count($this->container);
|
||||
}
|
||||
|
||||
public function requestAt(int $index): ?GuzzleRequest
|
||||
{
|
||||
return $this->container[$index]['request'] ?? null;
|
||||
}
|
||||
|
||||
public function latestRequest(): ?GuzzleRequest
|
||||
{
|
||||
return $this->requestAt($this->requestCount() - 1);
|
||||
}
|
||||
|
||||
public function all(): array
|
||||
{
|
||||
return $this->container;
|
||||
}
|
||||
}
|
||||
70
app/Http/HttpRequestService.php
Normal file
70
app/Http/HttpRequestService.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Handler\MockHandler;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Middleware;
|
||||
use GuzzleHttp\Psr7\Request as GuzzleRequest;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
|
||||
class HttpRequestService
|
||||
{
|
||||
protected ?HandlerStack $handler = null;
|
||||
|
||||
/**
|
||||
* Build a new http client for sending requests on.
|
||||
*/
|
||||
public function buildClient(int $timeout, array $options = []): ClientInterface
|
||||
{
|
||||
$defaultOptions = [
|
||||
'timeout' => $timeout,
|
||||
'handler' => $this->handler,
|
||||
];
|
||||
|
||||
return new Client(array_merge($options, $defaultOptions));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new JSON http request for use with a client.
|
||||
*/
|
||||
public function jsonRequest(string $method, string $uri, array $data): GuzzleRequest
|
||||
{
|
||||
$headers = ['Content-Type' => 'application/json'];
|
||||
return new GuzzleRequest($method, $uri, $headers, json_encode($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock any http clients built from this service, and response with the given responses.
|
||||
* Returns history which can then be queried.
|
||||
* @link https://docs.guzzlephp.org/en/stable/testing.html#history-middleware
|
||||
*/
|
||||
public function mockClient(array $responses = [], bool $pad = true): HttpClientHistory
|
||||
{
|
||||
// By default, we pad out the responses with 10 successful values so that requests will be
|
||||
// properly recorded for inspection. Otherwise, we can't later check if we're received
|
||||
// too many requests.
|
||||
if ($pad) {
|
||||
$response = new Response(200, [], 'success');
|
||||
$responses = array_merge($responses, array_fill(0, 10, $response));
|
||||
}
|
||||
|
||||
$container = [];
|
||||
$history = Middleware::history($container);
|
||||
$mock = new MockHandler($responses);
|
||||
$this->handler = HandlerStack::create($mock);
|
||||
$this->handler->push($history, 'history');
|
||||
|
||||
return new HttpClientHistory($container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear mocking that has been set up for clients.
|
||||
*/
|
||||
public function clearMocking(): void
|
||||
{
|
||||
$this->handler = null;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class Kernel extends HttpKernel
|
||||
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
|
||||
\BookStack\Http\Middleware\TrimStrings::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\View\Middleware\ShareErrorsFromSession::class,
|
||||
\BookStack\Http\Middleware\VerifyCsrfToken::class,
|
||||
\BookStack\Http\Middleware\PreventAuthenticatedResponseCaching::class,
|
||||
\BookStack\Http\Middleware\CheckEmailConfirmed::class,
|
||||
\BookStack\Http\Middleware\RunThemeActions::class,
|
||||
\BookStack\Http\Middleware\Localization::class,
|
||||
@@ -40,7 +40,6 @@ class Kernel extends HttpKernel
|
||||
\BookStack\Http\Middleware\EncryptCookies::class,
|
||||
\BookStack\Http\Middleware\StartSessionIfCookieExists::class,
|
||||
\BookStack\Http\Middleware\ApiAuthenticate::class,
|
||||
\BookStack\Http\Middleware\PreventAuthenticatedResponseCaching::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.
|
||||
// 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()) {
|
||||
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
|
||||
}
|
||||
@@ -53,6 +53,6 @@ class ApiAuthenticate
|
||||
{
|
||||
$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)
|
||||
{
|
||||
if (!hasAppAccess()) {
|
||||
if (!user()->hasAppAccess()) {
|
||||
if ($request->ajax()) {
|
||||
return response('Unauthorized.', 401);
|
||||
}
|
||||
|
||||
@@ -2,17 +2,14 @@
|
||||
|
||||
namespace BookStack\Http\Middleware;
|
||||
|
||||
use BookStack\Translation\LanguageManager;
|
||||
use Carbon\Carbon;
|
||||
use BookStack\Translation\LocaleManager;
|
||||
use Closure;
|
||||
|
||||
class Localization
|
||||
{
|
||||
protected LanguageManager $languageManager;
|
||||
|
||||
public function __construct(LanguageManager $languageManager)
|
||||
{
|
||||
$this->languageManager = $languageManager;
|
||||
public function __construct(
|
||||
protected LocaleManager $localeManager
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,22 +22,12 @@ class Localization
|
||||
*/
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
// Get and record the default language in the config
|
||||
$defaultLang = config('app.locale');
|
||||
config()->set('app.default_locale', $defaultLang);
|
||||
// Share details of the user's locale for use in views
|
||||
$userLocale = $this->localeManager->getForUser(user());
|
||||
view()->share('locale', $userLocale);
|
||||
|
||||
// Get the user's language and record that in the config for use in views
|
||||
$userLang = $this->languageManager->getUserLanguage($request, $defaultLang);
|
||||
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);
|
||||
// Set locale for system components
|
||||
app()->setLocale($userLocale->appLocale());
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace BookStack\Http\Middleware;
|
||||
use Closure;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PreventAuthenticatedResponseCaching
|
||||
class PreventResponseCaching
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
@@ -20,11 +20,8 @@ class PreventAuthenticatedResponseCaching
|
||||
/** @var Response $response */
|
||||
$response = $next($request);
|
||||
|
||||
if (signedInUser()) {
|
||||
$response->headers->set('Cache-Control', 'max-age=0, no-store, private');
|
||||
$response->headers->set('Pragma', 'no-cache');
|
||||
$response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');
|
||||
}
|
||||
$response->headers->set('Cache-Control', 'no-cache, no-store, private');
|
||||
$response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');
|
||||
|
||||
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']);
|
||||
|
||||
$parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? '');
|
||||
$instance->searches = $parsedStandardTerms['terms'];
|
||||
$instance->exacts = $parsedStandardTerms['exacts'];
|
||||
$instance->searches = array_filter($parsedStandardTerms['terms']);
|
||||
$instance->exacts = array_filter($parsedStandardTerms['exacts']);
|
||||
|
||||
array_push($instance->exacts, ...array_filter($inputs['exact'] ?? []));
|
||||
|
||||
@@ -78,7 +78,7 @@ class SearchOptions
|
||||
];
|
||||
|
||||
$patterns = [
|
||||
'exacts' => '/"(.*?)"/',
|
||||
'exacts' => '/"((?:\\\\.|[^"\\\\])*)"/',
|
||||
'tags' => '/\[(.*?)\]/',
|
||||
'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
|
||||
$parsedStandardTerms = static::parseStandardTermString($searchString);
|
||||
array_push($terms['searches'], ...$parsedStandardTerms['terms']);
|
||||
@@ -106,12 +111,41 @@ class SearchOptions
|
||||
}
|
||||
$terms['filters'] = $splitFilters;
|
||||
|
||||
// Filter down terms where required
|
||||
$terms['exacts'] = array_filter($terms['exacts']);
|
||||
$terms['searches'] = array_filter($terms['searches']);
|
||||
|
||||
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
|
||||
* 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>}
|
||||
*/
|
||||
@@ -129,8 +163,8 @@ class SearchOptions
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsedList = (strpbrk($searchTerm, $indexDelimiters) === false) ? 'terms' : 'exacts';
|
||||
$parsed[$parsedList][] = $searchTerm;
|
||||
$becomeExact = (strpbrk($searchTerm, $indexDelimiters) !== false);
|
||||
$parsed[$becomeExact ? 'exacts' : 'terms'][] = $searchTerm;
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
@@ -141,20 +175,22 @@ class SearchOptions
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
$string = implode(' ', $this->searches ?? []);
|
||||
$parts = $this->searches;
|
||||
|
||||
foreach ($this->exacts as $term) {
|
||||
$string .= ' "' . $term . '"';
|
||||
$escaped = str_replace('\\', '\\\\', $term);
|
||||
$escaped = str_replace('"', '\"', $escaped);
|
||||
$parts[] = '"' . $escaped . '"';
|
||||
}
|
||||
|
||||
foreach ($this->tags as $term) {
|
||||
$string .= " [{$term}]";
|
||||
$parts[] = "[{$term}]";
|
||||
}
|
||||
|
||||
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\Entities\Tools\TrashCan;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Notifications\TestEmail;
|
||||
use BookStack\References\ReferenceStore;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -69,7 +68,7 @@ class MaintenanceController extends Controller
|
||||
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email');
|
||||
|
||||
try {
|
||||
user()->notifyNow(new TestEmail());
|
||||
user()->notifyNow(new TestEmailNotification());
|
||||
$this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
|
||||
} catch (\Exception $exception) {
|
||||
$errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();
|
||||
|
||||
@@ -34,7 +34,7 @@ class SettingController extends Controller
|
||||
return view('settings.' . $category, [
|
||||
'category' => $category,
|
||||
'version' => $version,
|
||||
'guestUser' => User::getDefault(),
|
||||
'guestUser' => User::getGuest(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class SettingService
|
||||
$default = config('setting-defaults.user.' . $key, false);
|
||||
}
|
||||
|
||||
if ($user->isDefault()) {
|
||||
if ($user->isGuest()) {
|
||||
return $this->getFromSession($key, $default);
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ class SettingService
|
||||
*/
|
||||
public function putUser(User $user, string $key, string $value): bool
|
||||
{
|
||||
if ($user->isDefault()) {
|
||||
if ($user->isGuest()) {
|
||||
session()->put($key, $value);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Notifications;
|
||||
namespace BookStack\Settings;
|
||||
|
||||
use BookStack\App\MailNotification;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class TestEmail extends MailNotification
|
||||
class TestEmailNotification extends MailNotification
|
||||
{
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
@@ -3,19 +3,23 @@
|
||||
namespace BookStack\Theming;
|
||||
|
||||
use BookStack\Access\SocialAuthService;
|
||||
use BookStack\Exceptions\ThemeException;
|
||||
use Illuminate\Console\Application;
|
||||
use Illuminate\Console\Application as Artisan;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
|
||||
class ThemeService
|
||||
{
|
||||
protected $listeners = [];
|
||||
/**
|
||||
* @var array<string, callable[]>
|
||||
*/
|
||||
protected array $listeners = [];
|
||||
|
||||
/**
|
||||
* Listen to a given custom theme event,
|
||||
* 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])) {
|
||||
$this->listeners[$event] = [];
|
||||
@@ -31,10 +35,8 @@ class ThemeService
|
||||
*
|
||||
* If a callback returns a non-null value, this method will
|
||||
* 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) {
|
||||
$result = call_user_func_array($action, $args);
|
||||
@@ -49,7 +51,7 @@ class ThemeService
|
||||
/**
|
||||
* 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) {
|
||||
$application->addCommands([$command]);
|
||||
@@ -59,18 +61,22 @@ class ThemeService
|
||||
/**
|
||||
* 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');
|
||||
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
|
||||
*/
|
||||
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->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));
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/Translation/LocaleDefinition.php
Normal file
53
app/Translation/LocaleDefinition.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Translation;
|
||||
|
||||
class LocaleDefinition
|
||||
{
|
||||
public function __construct(
|
||||
protected string $appName,
|
||||
protected string $isoName,
|
||||
protected bool $isRtl
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the BookStack-specific locale name.
|
||||
*/
|
||||
public function appLocale(): string
|
||||
{
|
||||
return $this->appName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the ISO-aligned locale name.
|
||||
*/
|
||||
public function isoLocale(): string
|
||||
{
|
||||
return $this->isoName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string suitable for the HTML "lang" attribute.
|
||||
*/
|
||||
public function htmlLang(): string
|
||||
{
|
||||
return str_replace('_', '-', $this->isoName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string suitable for the HTML "dir" attribute.
|
||||
*/
|
||||
public function htmlDirection(): string
|
||||
{
|
||||
return $this->isRtl ? 'rtl' : 'ltr';
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate using this locate.
|
||||
*/
|
||||
public function trans(string $key, array $replace = []): string
|
||||
{
|
||||
return trans($key, $replace, $this->appLocale());
|
||||
}
|
||||
}
|
||||
122
app/Translation/LocaleManager.php
Normal file
122
app/Translation/LocaleManager.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Translation;
|
||||
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class LocaleManager
|
||||
{
|
||||
/**
|
||||
* Array of right-to-left locale options.
|
||||
*/
|
||||
protected array $rtlLocales = ['ar', 'fa', 'he'];
|
||||
|
||||
/**
|
||||
* Map of BookStack locale names to best-estimate ISO locale names.
|
||||
* Locales can often be found by running `locale -a` on a linux system.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected array $localeMap = [
|
||||
'ar' => 'ar',
|
||||
'bg' => 'bg_BG',
|
||||
'bs' => 'bs_BA',
|
||||
'ca' => 'ca',
|
||||
'cs' => 'cs_CZ',
|
||||
'cy' => 'cy_GB',
|
||||
'da' => 'da_DK',
|
||||
'de' => 'de_DE',
|
||||
'de_informal' => 'de_DE',
|
||||
'el' => 'el_GR',
|
||||
'en' => 'en_GB',
|
||||
'es' => 'es_ES',
|
||||
'es_AR' => 'es_AR',
|
||||
'et' => 'et_EE',
|
||||
'eu' => 'eu_ES',
|
||||
'fa' => 'fa_IR',
|
||||
'fi' => 'fi_FI',
|
||||
'fr' => 'fr_FR',
|
||||
'he' => 'he_IL',
|
||||
'hr' => 'hr_HR',
|
||||
'hu' => 'hu_HU',
|
||||
'id' => 'id_ID',
|
||||
'it' => 'it_IT',
|
||||
'ja' => 'ja',
|
||||
'ka' => 'ka_GE',
|
||||
'ko' => 'ko_KR',
|
||||
'lt' => 'lt_LT',
|
||||
'lv' => 'lv_LV',
|
||||
'nb' => 'nb_NO',
|
||||
'nl' => 'nl_NL',
|
||||
'nn' => 'nn_NO',
|
||||
'pl' => 'pl_PL',
|
||||
'pt' => 'pt_PT',
|
||||
'pt_BR' => 'pt_BR',
|
||||
'ro' => 'ro_RO',
|
||||
'ru' => 'ru',
|
||||
'sk' => 'sk_SK',
|
||||
'sl' => 'sl_SI',
|
||||
'sq' => 'sq_AL',
|
||||
'sv' => 'sv_SE',
|
||||
'tr' => 'tr_TR',
|
||||
'uk' => 'uk_UA',
|
||||
'uz' => 'uz_UZ',
|
||||
'vi' => 'vi_VN',
|
||||
'zh_CN' => 'zh_CN',
|
||||
'zh_TW' => 'zh_TW',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the BookStack locale string for the given user.
|
||||
*/
|
||||
protected function getLocaleForUser(User $user): string
|
||||
{
|
||||
$default = config('app.default_locale');
|
||||
|
||||
if ($user->isGuest() && config('app.auto_detect_locale')) {
|
||||
return $this->autoDetectLocale(request(), $default);
|
||||
}
|
||||
|
||||
return setting()->getUser($user, 'language', $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a locale definition for the current user.
|
||||
*/
|
||||
public function getForUser(User $user): LocaleDefinition
|
||||
{
|
||||
$localeString = $this->getLocaleForUser($user);
|
||||
|
||||
return new LocaleDefinition(
|
||||
$localeString,
|
||||
$this->localeMap[$localeString] ?? $localeString,
|
||||
in_array($localeString, $this->rtlLocales),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Autodetect the visitors locale by matching locales in their headers
|
||||
* against the locales supported by BookStack.
|
||||
*/
|
||||
protected function autoDetectLocale(Request $request, string $default): string
|
||||
{
|
||||
$availableLocales = $this->getAllAppLocales();
|
||||
|
||||
foreach ($request->getLanguages() as $lang) {
|
||||
if (in_array($lang, $availableLocales)) {
|
||||
return $lang;
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the available app-specific level locale strings.
|
||||
*/
|
||||
public function getAllAppLocales(): array
|
||||
{
|
||||
return array_keys($this->localeMap);
|
||||
}
|
||||
}
|
||||
@@ -5,23 +5,23 @@ namespace BookStack\Uploads\Controllers;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Uploads\ImageResizer;
|
||||
use BookStack\Util\OutOfMemoryHandler;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class DrawioImageController extends Controller
|
||||
{
|
||||
protected $imageRepo;
|
||||
|
||||
public function __construct(ImageRepo $imageRepo)
|
||||
{
|
||||
$this->imageRepo = $imageRepo;
|
||||
public function __construct(
|
||||
protected ImageRepo $imageRepo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of gallery images, in a list.
|
||||
* Can be paged and filtered by entity.
|
||||
*/
|
||||
public function list(Request $request)
|
||||
public function list(Request $request, ImageResizer $resizer)
|
||||
{
|
||||
$page = $request->get('page', 1);
|
||||
$searchTerm = $request->get('search', null);
|
||||
@@ -29,11 +29,20 @@ class DrawioImageController extends Controller
|
||||
$parentTypeFilter = $request->get('filter_type', null);
|
||||
|
||||
$imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
|
||||
|
||||
return view('pages.parts.image-manager-list', [
|
||||
$viewData = [
|
||||
'warning' => '',
|
||||
'images' => $imgData['images'],
|
||||
'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\Http\Controller;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Uploads\ImageResizer;
|
||||
use BookStack\Util\OutOfMemoryHandler;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GalleryImageController extends Controller
|
||||
@@ -19,7 +23,7 @@ class GalleryImageController extends Controller
|
||||
* Get a list of gallery images, in a list.
|
||||
* Can be paged and filtered by entity.
|
||||
*/
|
||||
public function list(Request $request)
|
||||
public function list(Request $request, ImageResizer $resizer)
|
||||
{
|
||||
$page = $request->get('page', 1);
|
||||
$searchTerm = $request->get('search', null);
|
||||
@@ -27,11 +31,20 @@ class GalleryImageController extends Controller
|
||||
$parentTypeFilter = $request->get('filter_type', null);
|
||||
|
||||
$imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 30, $uploadedToFilter, $searchTerm);
|
||||
|
||||
return view('pages.parts.image-manager-list', [
|
||||
$viewData = [
|
||||
'warning' => '',
|
||||
'images' => $imgData['images'],
|
||||
'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']));
|
||||
}
|
||||
|
||||
new OutOfMemoryHandler(function () {
|
||||
return $this->jsonError(trans('errors.image_upload_memory_limit'));
|
||||
});
|
||||
|
||||
try {
|
||||
$imageUpload = $request->file('file');
|
||||
$uploadedTo = $request->get('uploaded_to', 0);
|
||||
|
||||
@@ -4,19 +4,22 @@ namespace BookStack\Uploads\Controllers;
|
||||
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Uploads\ImageResizer;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Util\OutOfMemoryHandler;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ImageController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ImageRepo $imageRepo,
|
||||
protected ImageService $imageService
|
||||
protected ImageService $imageService,
|
||||
protected ImageResizer $imageResizer,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -38,13 +41,10 @@ class ImageController extends Controller
|
||||
|
||||
/**
|
||||
* Update image details.
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$this->validate($request, [
|
||||
$data = $this->validate($request, [
|
||||
'name' => ['required', 'min:2', 'string'],
|
||||
]);
|
||||
|
||||
@@ -52,9 +52,7 @@ class ImageController extends Controller
|
||||
$this->checkImagePermission($image);
|
||||
$this->checkOwnablePermission('image-update', $image);
|
||||
|
||||
$image = $this->imageRepo->updateImageDetails($image, $request->all());
|
||||
|
||||
$this->imageRepo->loadThumbs($image);
|
||||
$image = $this->imageRepo->updateImageDetails($image, $data);
|
||||
|
||||
return view('pages.parts.image-manager-form', [
|
||||
'image' => $image,
|
||||
@@ -76,6 +74,10 @@ class ImageController extends Controller
|
||||
$this->checkOwnablePermission('image-update', $image);
|
||||
$file = $request->file('file');
|
||||
|
||||
new OutOfMemoryHandler(function () {
|
||||
return $this->jsonError(trans('errors.image_upload_memory_limit'));
|
||||
});
|
||||
|
||||
try {
|
||||
$this->imageRepo->updateImageFile($image, $file);
|
||||
} catch (ImageUploadException $exception) {
|
||||
@@ -99,12 +101,20 @@ class ImageController extends Controller
|
||||
$dependantPages = $this->imageRepo->getPagesUsingImage($image);
|
||||
}
|
||||
|
||||
$this->imageRepo->loadThumbs($image);
|
||||
|
||||
return view('pages.parts.image-manager-form', [
|
||||
$viewData = [
|
||||
'image' => $image,
|
||||
'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') {
|
||||
$this->showPermissionError();
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\Entities\Models\Page;
|
||||
use BookStack\Http\ApiController;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Uploads\ImageResizer;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ImageGalleryApiController extends ApiController
|
||||
@@ -15,7 +16,8 @@ class ImageGalleryApiController extends ApiController
|
||||
];
|
||||
|
||||
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
|
||||
{
|
||||
$this->imageRepo->loadThumbs($image);
|
||||
$this->imageResizer->loadGalleryThumbnailsForImage($image, false);
|
||||
$data = $image->toArray();
|
||||
$data['created_by'] = $image->createdBy;
|
||||
$data['updated_by'] = $image->updatedBy;
|
||||
@@ -138,6 +140,7 @@ class ImageGalleryApiController extends ApiController
|
||||
|
||||
$escapedUrl = htmlentities($image->url);
|
||||
$escapedName = htmlentities($image->name);
|
||||
|
||||
if ($image->type === 'drawio') {
|
||||
$data['content']['html'] = "<div drawio-diagram=\"{$image->id}\"><img src=\"{$escapedUrl}\"></div>";
|
||||
$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
|
||||
*/
|
||||
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(
|
||||
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.
|
||||
* 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();
|
||||
$hasMore = count($images) > $pageSize;
|
||||
|
||||
$returnImages = $images->take($pageSize);
|
||||
$returnImages->each(function (Image $image) {
|
||||
$this->loadThumbs($image);
|
||||
});
|
||||
|
||||
return [
|
||||
'images' => $returnImages,
|
||||
'has_more' => $hasMore,
|
||||
'images' => $images->take($pageSize),
|
||||
'has_more' => count($images) > $pageSize,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -119,7 +114,7 @@ class ImageRepo
|
||||
$image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio);
|
||||
|
||||
if ($type !== 'system') {
|
||||
$this->loadThumbs($image);
|
||||
$this->imageResizer->loadGalleryThumbnailsForImage($image, true);
|
||||
}
|
||||
|
||||
return $image;
|
||||
@@ -133,7 +128,7 @@ class ImageRepo
|
||||
public function saveNewFromData(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
|
||||
{
|
||||
$image = $this->imageService->saveNew($imageName, $imageData, $type, $uploadedTo);
|
||||
$this->loadThumbs($image);
|
||||
$this->imageResizer->loadGalleryThumbnailsForImage($image, true);
|
||||
|
||||
return $image;
|
||||
}
|
||||
@@ -160,7 +155,7 @@ class ImageRepo
|
||||
$image->fill($updateDetails);
|
||||
$image->updated_by = user()->id;
|
||||
$image->save();
|
||||
$this->loadThumbs($image);
|
||||
$this->imageResizer->loadGalleryThumbnailsForImage($image, false);
|
||||
|
||||
return $image;
|
||||
}
|
||||
@@ -179,8 +174,9 @@ class ImageRepo
|
||||
$image->updated_by = user()->id;
|
||||
$image->touch();
|
||||
$image->save();
|
||||
|
||||
$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.
|
||||
*/
|
||||
|
||||
206
app/Uploads/ImageResizer.php
Normal file
206
app/Uploads/ImageResizer.php
Normal file
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use Exception;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Intervention\Image\Image as InterventionImage;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
class ImageResizer
|
||||
{
|
||||
protected const THUMBNAIL_CACHE_TIME = 604_800; // 1 week
|
||||
|
||||
public function __construct(
|
||||
protected ImageManager $intervention,
|
||||
protected ImageStorage $storage,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load gallery thumbnails for a set of images.
|
||||
* @param iterable<Image> $images
|
||||
*/
|
||||
public function loadGalleryThumbnailsForMany(iterable $images, bool $shouldCreate = false): void
|
||||
{
|
||||
foreach ($images as $image) {
|
||||
$this->loadGalleryThumbnailsForImage($image, $shouldCreate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load gallery thumbnails into the given image instance.
|
||||
*/
|
||||
public function loadGalleryThumbnailsForImage(Image $image, bool $shouldCreate): void
|
||||
{
|
||||
$thumbs = ['gallery' => null, 'display' => null];
|
||||
|
||||
try {
|
||||
$thumbs['gallery'] = $this->resizeToThumbnailUrl($image, 150, 150, false, $shouldCreate);
|
||||
$thumbs['display'] = $this->resizeToThumbnailUrl($image, 1680, null, true, $shouldCreate);
|
||||
} catch (Exception $exception) {
|
||||
// Prevent thumbnail errors from stopping execution
|
||||
}
|
||||
|
||||
$image->setAttribute('thumbs', $thumbs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the thumbnail for an image.
|
||||
* If $keepRatio is true only the width will be used.
|
||||
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function resizeToThumbnailUrl(
|
||||
Image $image,
|
||||
?int $width,
|
||||
?int $height,
|
||||
bool $keepRatio = false,
|
||||
bool $shouldCreate = false
|
||||
): ?string {
|
||||
// Do not resize GIF images where we're not cropping
|
||||
if ($keepRatio && $this->isGif($image)) {
|
||||
return $this->storage->getPublicUrl($image->path);
|
||||
}
|
||||
|
||||
$thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
|
||||
$imagePath = $image->path;
|
||||
$thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
|
||||
|
||||
$thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
|
||||
|
||||
// Return path if in cache
|
||||
$cachedThumbPath = Cache::get($thumbCacheKey);
|
||||
if ($cachedThumbPath && !$shouldCreate) {
|
||||
return $this->storage->getPublicUrl($cachedThumbPath);
|
||||
}
|
||||
|
||||
// If thumbnail has already been generated, serve that and cache path
|
||||
$disk = $this->storage->getDisk($image->type);
|
||||
if (!$shouldCreate && $disk->exists($thumbFilePath)) {
|
||||
Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
|
||||
|
||||
return $this->storage->getPublicUrl($thumbFilePath);
|
||||
}
|
||||
|
||||
$imageData = $disk->get($imagePath);
|
||||
|
||||
// Do not resize apng images where we're not cropping
|
||||
if ($keepRatio && $this->isApngData($image, $imageData)) {
|
||||
Cache::put($thumbCacheKey, $image->path, static::THUMBNAIL_CACHE_TIME);
|
||||
|
||||
return $this->storage->getPublicUrl($image->path);
|
||||
}
|
||||
|
||||
// If not in cache and thumbnail does not exist, generate thumb and cache path
|
||||
$thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio);
|
||||
$disk->put($thumbFilePath, $thumbData, true);
|
||||
Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
|
||||
|
||||
return $this->storage->getPublicUrl($thumbFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the image of given data to the specified size, and return the new image data.
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
*/
|
||||
public function resizeImageData(string $imageData, ?int $width, ?int $height, bool $keepRatio): string
|
||||
{
|
||||
try {
|
||||
$thumb = $this->intervention->make($imageData);
|
||||
} catch (Exception $e) {
|
||||
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
|
||||
}
|
||||
|
||||
$this->orientImageToOriginalExif($thumb, $imageData);
|
||||
|
||||
if ($keepRatio) {
|
||||
$thumb->resize($width, $height, function ($constraint) {
|
||||
$constraint->aspectRatio();
|
||||
$constraint->upsize();
|
||||
});
|
||||
} else {
|
||||
$thumb->fit($width, $height);
|
||||
}
|
||||
|
||||
$thumbData = (string) $thumb->encode();
|
||||
|
||||
// Use original image data if we're keeping the ratio
|
||||
// and the resizing does not save any space.
|
||||
if ($keepRatio && strlen($thumbData) > strlen($imageData)) {
|
||||
return $imageData;
|
||||
}
|
||||
|
||||
return $thumbData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orientate the given intervention image based upon the given original image data.
|
||||
* Intervention does have an `orientate` method but the exif data it needs is lost before it
|
||||
* can be used (At least when created using binary string data) so we need to do some
|
||||
* implementation on our side to use the original image data.
|
||||
* Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
|
||||
* Copyright (c) Oliver Vogel, MIT License.
|
||||
*/
|
||||
protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
|
||||
{
|
||||
if (!extension_loaded('exif')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$stream = Utils::streamFor($originalData)->detach();
|
||||
$exif = @exif_read_data($stream);
|
||||
$orientation = $exif ? ($exif['Orientation'] ?? null) : null;
|
||||
|
||||
switch ($orientation) {
|
||||
case 2:
|
||||
$image->flip();
|
||||
break;
|
||||
case 3:
|
||||
$image->rotate(180);
|
||||
break;
|
||||
case 4:
|
||||
$image->rotate(180)->flip();
|
||||
break;
|
||||
case 5:
|
||||
$image->rotate(270)->flip();
|
||||
break;
|
||||
case 6:
|
||||
$image->rotate(270);
|
||||
break;
|
||||
case 7:
|
||||
$image->rotate(90)->flip();
|
||||
break;
|
||||
case 8:
|
||||
$image->rotate(90);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the image is a gif. Returns true if it is, else false.
|
||||
*/
|
||||
protected function isGif(Image $image): bool
|
||||
{
|
||||
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given image and image data is apng.
|
||||
*/
|
||||
protected function isApngData(Image $image, string &$imageData): bool
|
||||
{
|
||||
$isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
|
||||
if (!$isPng) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
|
||||
|
||||
return str_contains($initialHeader, 'acTL');
|
||||
}
|
||||
}
|
||||
@@ -6,109 +6,27 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use ErrorException;
|
||||
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\Log;
|
||||
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\StreamedResponse;
|
||||
|
||||
class ImageService
|
||||
{
|
||||
protected ImageManager $imageTool;
|
||||
protected Cache $cache;
|
||||
protected FilesystemManager $fileSystem;
|
||||
|
||||
protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
|
||||
public function __construct(ImageManager $imageTool, FilesystemManager $fileSystem, Cache $cache)
|
||||
{
|
||||
$this->imageTool = $imageTool;
|
||||
$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;
|
||||
public function __construct(
|
||||
protected ImageStorage $storage,
|
||||
protected ImageResizer $resizer,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a new image from an upload.
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function saveNewFromUpload(
|
||||
UploadedFile $uploadedFile,
|
||||
@@ -117,12 +35,12 @@ class ImageService
|
||||
int $resizeWidth = null,
|
||||
int $resizeHeight = null,
|
||||
bool $keepRatio = true
|
||||
) {
|
||||
): Image {
|
||||
$imageName = $uploadedFile->getClientOriginalName();
|
||||
$imageData = file_get_contents($uploadedFile->getRealPath());
|
||||
|
||||
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);
|
||||
@@ -151,13 +69,13 @@ class ImageService
|
||||
*/
|
||||
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');
|
||||
$fileName = $this->cleanImageFileName($imageName);
|
||||
$fileName = $this->storage->cleanImageFileName($imageName);
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -167,7 +85,7 @@ class ImageService
|
||||
}
|
||||
|
||||
try {
|
||||
$this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($fullPath, $type), $imageData);
|
||||
$disk->put($fullPath, $imageData, true);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Error when attempting image upload:' . $e->getMessage());
|
||||
|
||||
@@ -177,7 +95,7 @@ class ImageService
|
||||
$imageDetails = [
|
||||
'name' => $imageName,
|
||||
'path' => $fullPath,
|
||||
'url' => $this->getPublicUrl($fullPath),
|
||||
'url' => $this->storage->getPublicUrl($fullPath),
|
||||
'type' => $type,
|
||||
'uploaded_to' => $uploadedTo,
|
||||
];
|
||||
@@ -194,214 +112,26 @@ class ImageService
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an existing image file in the system using the given file.
|
||||
*/
|
||||
public function replaceExistingFromUpload(string $path, string $type, UploadedFile $file): void
|
||||
{
|
||||
$imageData = file_get_contents($file->getRealPath());
|
||||
$storage = $this->getStorageDisk($type);
|
||||
$adjustedPath = $this->adjustPathForStorageDisk($path, $type);
|
||||
$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;
|
||||
}
|
||||
$disk = $this->storage->getDisk($type);
|
||||
$disk->put($path, $imageData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw data content from an image.
|
||||
*
|
||||
* @throws FileNotFoundException
|
||||
* @throws Exception
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = true)
|
||||
public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = true): array
|
||||
{
|
||||
$types = ['gallery', 'drawio'];
|
||||
$deletedPaths = [];
|
||||
@@ -499,36 +189,32 @@ class ImageService
|
||||
* Attempts to convert the URL to a system storage url then
|
||||
* fetch the data from the disk or storage location.
|
||||
* 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);
|
||||
if (empty($uri) || is_null($storagePath)) {
|
||||
$storagePath = $this->storage->urlToPath($url);
|
||||
if (empty($url) || is_null($storagePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$storagePath = $this->adjustPathForStorageDisk($storagePath);
|
||||
|
||||
// Apply access control when local_secure_restricted images are active
|
||||
if ($this->usingSecureRestrictedImages()) {
|
||||
if ($this->storage->usingSecureRestrictedImages()) {
|
||||
if (!$this->checkUserHasAccessToRelationOfImageAtPath($storagePath)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$storage = $this->getStorageDisk();
|
||||
$disk = $this->storage->getDisk();
|
||||
$imageData = null;
|
||||
if ($storage->exists($storagePath)) {
|
||||
$imageData = $storage->get($storagePath);
|
||||
if ($disk->exists($storagePath)) {
|
||||
$imageData = $disk->get($storagePath);
|
||||
}
|
||||
|
||||
if (is_null($imageData)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$extension = pathinfo($uri, PATHINFO_EXTENSION);
|
||||
$extension = pathinfo($url, PATHINFO_EXTENSION);
|
||||
if ($extension === 'svg') {
|
||||
$extension = 'svg+xml';
|
||||
}
|
||||
@@ -543,20 +229,18 @@ class ImageService
|
||||
*/
|
||||
public function pathAccessibleInLocalSecure(string $imagePath): bool
|
||||
{
|
||||
/** @var FilesystemAdapter $disk */
|
||||
$disk = $this->getStorageDisk('gallery');
|
||||
$disk = $this->storage->getDisk('gallery');
|
||||
|
||||
if ($this->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
|
||||
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check local_secure is active
|
||||
return $this->usingSecureImages()
|
||||
&& $disk instanceof FilesystemAdapter
|
||||
return $disk->usingSecureImages()
|
||||
// Check the image file exists
|
||||
&& $disk->exists($imagePath)
|
||||
// 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
|
||||
{
|
||||
if (strpos($path, '/uploads/images/') === 0) {
|
||||
if (str_starts_with($path, 'uploads/images/')) {
|
||||
$path = substr($path, 15);
|
||||
}
|
||||
|
||||
// Strip thumbnail element from path if existing
|
||||
$originalPathSplit = array_filter(explode('/', $path), function (string $part) {
|
||||
$resizedDir = (strpos($part, 'thumbs-') === 0 || strpos($part, 'scaled-') === 0);
|
||||
$missingExtension = strpos($part, '.') === false;
|
||||
$resizedDir = (str_starts_with($part, 'thumbs-') || str_starts_with($part, 'scaled-'));
|
||||
$missingExtension = !str_contains($part, '.');
|
||||
|
||||
return !($resizedDir && $missingExtension);
|
||||
});
|
||||
@@ -613,7 +297,7 @@ class ImageService
|
||||
*/
|
||||
public function streamImageFromStorageResponse(string $imageType, string $path): StreamedResponse
|
||||
{
|
||||
$disk = $this->getStorageDisk($imageType);
|
||||
$disk = $this->storage->getDisk($imageType);
|
||||
|
||||
return $disk->response($path);
|
||||
}
|
||||
@@ -627,64 +311,4 @@ class ImageService
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
136
app/Uploads/ImageStorage.php
Normal file
136
app/Uploads/ImageStorage.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ImageStorage
|
||||
{
|
||||
public function __construct(
|
||||
protected FilesystemManager $fileSystem,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage disk for the given image type.
|
||||
*/
|
||||
public function getDisk(string $imageType = ''): ImageStorageDisk
|
||||
{
|
||||
$diskName = $this->getDiskName($imageType);
|
||||
|
||||
return new ImageStorageDisk(
|
||||
$diskName,
|
||||
$this->fileSystem->disk($diskName),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if "local secure restricted" (Fetched behind auth, with permissions enforced)
|
||||
* is currently active in the instance.
|
||||
*/
|
||||
public function usingSecureRestrictedImages(): bool
|
||||
{
|
||||
return config('filesystems.images') === 'local_secure_restricted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up an image file name to be both URL and storage safe.
|
||||
*/
|
||||
public function cleanImageFileName(string $name): string
|
||||
{
|
||||
$name = str_replace(' ', '-', $name);
|
||||
$nameParts = explode('.', $name);
|
||||
$extension = array_pop($nameParts);
|
||||
$name = implode('-', $nameParts);
|
||||
$name = Str::slug($name);
|
||||
|
||||
if (strlen($name) === 0) {
|
||||
$name = Str::random(10);
|
||||
}
|
||||
|
||||
return $name . '.' . $extension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the storage disk to use.
|
||||
*/
|
||||
protected function getDiskName(string $imageType): string
|
||||
{
|
||||
$storageType = strtolower(config('filesystems.images'));
|
||||
$localSecureInUse = ($storageType === 'local_secure' || $storageType === 'local_secure_restricted');
|
||||
|
||||
// Ensure system images (App logo) are uploaded to a public space
|
||||
if ($imageType === 'system' && $localSecureInUse) {
|
||||
return 'local';
|
||||
}
|
||||
|
||||
// Rename local_secure options to get our image specific storage driver which
|
||||
// is scoped to the relevant image directories.
|
||||
if ($localSecureInUse) {
|
||||
return 'local_secure_images';
|
||||
}
|
||||
|
||||
return $storageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a storage path for the given image URL.
|
||||
* Ensures the path will start with "uploads/images".
|
||||
* Returns null if the url cannot be resolved to a local URL.
|
||||
*/
|
||||
public function urlToPath(string $url): ?string
|
||||
{
|
||||
$url = ltrim(trim($url), '/');
|
||||
|
||||
// Handle potential relative paths
|
||||
$isRelative = !str_starts_with($url, 'http');
|
||||
if ($isRelative) {
|
||||
if (str_starts_with(strtolower($url), 'uploads/images')) {
|
||||
return trim($url, '/');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle local images based on paths on the same domain
|
||||
$potentialHostPaths = [
|
||||
url('uploads/images/'),
|
||||
$this->getPublicUrl('/uploads/images/'),
|
||||
];
|
||||
|
||||
foreach ($potentialHostPaths as $potentialBasePath) {
|
||||
$potentialBasePath = strtolower($potentialBasePath);
|
||||
if (str_starts_with(strtolower($url), $potentialBasePath)) {
|
||||
return 'uploads/images/' . trim(substr($url, strlen($potentialBasePath)), '/');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a public facing url for an image by checking relevant environment variables.
|
||||
* If s3-style store is in use it will default to guessing a public bucket URL.
|
||||
*/
|
||||
public function getPublicUrl(string $filePath): string
|
||||
{
|
||||
$storageUrl = config('filesystems.url');
|
||||
|
||||
// Get the standard public s3 url if s3 is set as storage type
|
||||
// Uses the nice, short URL if bucket name has no periods in otherwise the longer
|
||||
// region-based url will be used to prevent http issues.
|
||||
if (!$storageUrl && config('filesystems.images') === 's3') {
|
||||
$storageDetails = config('filesystems.disks.s3');
|
||||
if (!str_contains($storageDetails['bucket'], '.')) {
|
||||
$storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com';
|
||||
} else {
|
||||
$storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
|
||||
}
|
||||
}
|
||||
|
||||
$basePath = $storageUrl ?: url('/');
|
||||
|
||||
return rtrim($basePath, '/') . $filePath;
|
||||
}
|
||||
}
|
||||
140
app/Uploads/ImageStorageDisk.php
Normal file
140
app/Uploads/ImageStorageDisk.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use League\Flysystem\WhitespacePathNormalizer;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ImageStorageDisk
|
||||
{
|
||||
public function __construct(
|
||||
protected string $diskName,
|
||||
protected Filesystem $filesystem,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if local secure image storage (Fetched behind authentication)
|
||||
* is currently active in the instance.
|
||||
*/
|
||||
public function usingSecureImages(): bool
|
||||
{
|
||||
return $this->diskName === 'local_secure_images';
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the originally provided path to fit any disk-specific requirements.
|
||||
* This also ensures the path is kept to the expected root folders.
|
||||
*/
|
||||
protected function adjustPathForDisk(string $path): string
|
||||
{
|
||||
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/images/', '', $path));
|
||||
|
||||
if ($this->usingSecureImages()) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return 'uploads/images/' . $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file at the given path exists.
|
||||
*/
|
||||
public function exists(string $path): bool
|
||||
{
|
||||
return $this->filesystem->exists($this->adjustPathForDisk($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file at the given path.
|
||||
*/
|
||||
public function get(string $path): ?string
|
||||
{
|
||||
return $this->filesystem->get($this->adjustPathForDisk($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the given image data at the given path. Can choose to set
|
||||
* the image as public which will update its visibility after saving.
|
||||
*/
|
||||
public function put(string $path, string $data, bool $makePublic = false): void
|
||||
{
|
||||
$path = $this->adjustPathForDisk($path);
|
||||
$this->filesystem->put($path, $data);
|
||||
|
||||
// Set visibility when a non-AWS-s3, s3-like storage option is in use.
|
||||
// Done since this call can break s3-like services but desired for other image stores.
|
||||
// Attempting to set ACL during above put request requires different permissions
|
||||
// hence would technically be a breaking change for actual s3 usage.
|
||||
if ($makePublic && !$this->isS3Like()) {
|
||||
$this->filesystem->setVisibility($path, 'public');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys an image at the given path.
|
||||
* Searches for image thumbnails in addition to main provided path.
|
||||
*/
|
||||
public function destroyAllMatchingNameFromPath(string $path): void
|
||||
{
|
||||
$path = $this->adjustPathForDisk($path);
|
||||
|
||||
$imageFolder = dirname($path);
|
||||
$imageFileName = basename($path);
|
||||
$allImages = collect($this->filesystem->allFiles($imageFolder));
|
||||
|
||||
// Delete image files
|
||||
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
|
||||
return basename($imagePath) === $imageFileName;
|
||||
});
|
||||
$this->filesystem->delete($imagesToDelete->all());
|
||||
|
||||
// Cleanup of empty folders
|
||||
$foldersInvolved = array_merge([$imageFolder], $this->filesystem->directories($imageFolder));
|
||||
foreach ($foldersInvolved as $directory) {
|
||||
if ($this->isFolderEmpty($directory)) {
|
||||
$this->filesystem->deleteDirectory($directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mime type of the file at the given path.
|
||||
* Only works for local filesystem adapters.
|
||||
*/
|
||||
public function mimeType(string $path): string
|
||||
{
|
||||
$path = $this->adjustPathForDisk($path);
|
||||
return $this->filesystem instanceof FilesystemAdapter ? $this->filesystem->mimeType($path) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a stream response for the image at the given path.
|
||||
*/
|
||||
public function response(string $path): StreamedResponse
|
||||
{
|
||||
return $this->filesystem->response($this->adjustPathForDisk($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the image storage in use is an S3-like (but not likely S3) external system.
|
||||
*/
|
||||
protected function isS3Like(): bool
|
||||
{
|
||||
$usingS3 = $this->diskName === 's3';
|
||||
return $usingS3 && !is_null(config('filesystems.disks.s3.endpoint'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a folder is empty.
|
||||
*/
|
||||
protected function isFolderEmpty(string $path): bool
|
||||
{
|
||||
$files = $this->filesystem->files($path);
|
||||
$folders = $this->filesystem->directories($path);
|
||||
|
||||
return count($files) === 0 && count($folders) === 0;
|
||||
}
|
||||
}
|
||||
@@ -3,20 +3,20 @@
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Exceptions\HttpFetchException;
|
||||
use BookStack\Http\HttpRequestService;
|
||||
use BookStack\Users\Models\User;
|
||||
use Exception;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
|
||||
class UserAvatars
|
||||
{
|
||||
protected $imageService;
|
||||
protected $http;
|
||||
|
||||
public function __construct(ImageService $imageService, HttpFetcher $http)
|
||||
{
|
||||
$this->imageService = $imageService;
|
||||
$this->http = $http;
|
||||
public function __construct(
|
||||
protected ImageService $imageService,
|
||||
protected HttpRequestService $http
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +56,7 @@ class UserAvatars
|
||||
/**
|
||||
* 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')
|
||||
->where('uploaded_to', '=', $user->id)
|
||||
@@ -70,7 +70,7 @@ class UserAvatars
|
||||
/**
|
||||
* Save an avatar image from an external service.
|
||||
*
|
||||
* @throws Exception
|
||||
* @throws HttpFetchException
|
||||
*/
|
||||
protected function saveAvatarImage(User $user, int $size = 500): Image
|
||||
{
|
||||
@@ -112,28 +112,32 @@ class UserAvatars
|
||||
protected function getAvatarImageData(string $url): string
|
||||
{
|
||||
try {
|
||||
$imageData = $this->http->fetch($url);
|
||||
} catch (HttpFetchException $exception) {
|
||||
$client = $this->http->buildClient(5);
|
||||
$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);
|
||||
}
|
||||
|
||||
return $imageData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if fetching external avatars is enabled.
|
||||
*/
|
||||
protected function avatarFetchEnabled(): bool
|
||||
public function avatarFetchEnabled(): bool
|
||||
{
|
||||
$fetchUrl = $this->getAvatarUrl();
|
||||
|
||||
return is_string($fetchUrl) && strpos($fetchUrl, 'http') === 0;
|
||||
return str_starts_with($fetchUrl, 'http');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL to fetch avatars from.
|
||||
*/
|
||||
protected function getAvatarUrl(): string
|
||||
public function getAvatarUrl(): string
|
||||
{
|
||||
$configOption = config('services.avatar_url');
|
||||
if ($configOption === false) {
|
||||
|
||||
227
app/Users/Controllers/UserAccountController.php
Normal file
227
app/Users/Controllers/UserAccountController.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Users\Controllers;
|
||||
|
||||
use BookStack\Access\SocialAuthService;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Settings\UserNotificationPreferences;
|
||||
use BookStack\Settings\UserShortcutMap;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Users\UserRepo;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class UserAccountController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected UserRepo $userRepo,
|
||||
) {
|
||||
$this->middleware(function (Request $request, Closure $next) {
|
||||
$this->preventGuestAccess();
|
||||
return $next($request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the root my-account path to the main/first category.
|
||||
* Required as a controller method, instead of the Route::redirect helper,
|
||||
* to ensure the URL is generated correctly.
|
||||
*/
|
||||
public function redirect()
|
||||
{
|
||||
return redirect('/my-account/profile');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the profile form interface.
|
||||
*/
|
||||
public function showProfile()
|
||||
{
|
||||
$this->setPageTitle(trans('preferences.profile'));
|
||||
|
||||
return view('users.account.profile', [
|
||||
'model' => user(),
|
||||
'category' => 'profile',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the submission of the user profile form.
|
||||
*/
|
||||
public function updateProfile(Request $request, ImageRepo $imageRepo)
|
||||
{
|
||||
$this->preventAccessInDemoMode();
|
||||
|
||||
$user = user();
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['min:2', 'max:100'],
|
||||
'email' => ['min:2', 'email', 'unique:users,email,' . $user->id],
|
||||
'language' => ['string', 'max:15', 'alpha_dash'],
|
||||
'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
]);
|
||||
|
||||
$this->userRepo->update($user, $validated, userCan('users-manage'));
|
||||
|
||||
// Save profile image if in request
|
||||
if ($request->hasFile('profile_image')) {
|
||||
$imageUpload = $request->file('profile_image');
|
||||
$imageRepo->destroyImage($user->avatar);
|
||||
$image = $imageRepo->saveNew($imageUpload, 'user', $user->id);
|
||||
$user->image_id = $image->id;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
// Delete the profile image if reset option is in request
|
||||
if ($request->has('profile_image_reset')) {
|
||||
$imageRepo->destroyImage($user->avatar);
|
||||
$user->image_id = 0;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
return redirect('/my-account/profile');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the user-specific interface shortcuts.
|
||||
*/
|
||||
public function showShortcuts()
|
||||
{
|
||||
$shortcuts = UserShortcutMap::fromUserPreferences();
|
||||
$enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false);
|
||||
|
||||
$this->setPageTitle(trans('preferences.shortcuts_interface'));
|
||||
|
||||
return view('users.account.shortcuts', [
|
||||
'category' => 'shortcuts',
|
||||
'shortcuts' => $shortcuts,
|
||||
'enabled' => $enabled,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user-specific interface shortcuts.
|
||||
*/
|
||||
public function updateShortcuts(Request $request)
|
||||
{
|
||||
$enabled = $request->get('enabled') === 'true';
|
||||
$providedShortcuts = $request->get('shortcut', []);
|
||||
$shortcuts = new UserShortcutMap($providedShortcuts);
|
||||
|
||||
setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson());
|
||||
setting()->putForCurrentUser('ui-shortcuts-enabled', $enabled);
|
||||
|
||||
$this->showSuccessNotification(trans('preferences.shortcuts_update_success'));
|
||||
|
||||
return redirect('/my-account/shortcuts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the notification preferences for the current user.
|
||||
*/
|
||||
public function showNotifications(PermissionApplicator $permissions)
|
||||
{
|
||||
$this->checkPermission('receive-notifications');
|
||||
|
||||
$preferences = (new UserNotificationPreferences(user()));
|
||||
|
||||
$query = user()->watches()->getQuery();
|
||||
$query = $permissions->restrictEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
|
||||
$query = $permissions->filterDeletedFromEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
|
||||
$watches = $query->with('watchable')->paginate(20);
|
||||
|
||||
$this->setPageTitle(trans('preferences.notifications'));
|
||||
return view('users.account.notifications', [
|
||||
'category' => 'notifications',
|
||||
'preferences' => $preferences,
|
||||
'watches' => $watches,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the notification preferences for the current user.
|
||||
*/
|
||||
public function updateNotifications(Request $request)
|
||||
{
|
||||
$this->preventAccessInDemoMode();
|
||||
$this->checkPermission('receive-notifications');
|
||||
$data = $this->validate($request, [
|
||||
'preferences' => ['required', 'array'],
|
||||
'preferences.*' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$preferences = (new UserNotificationPreferences(user()));
|
||||
$preferences->updateFromSettingsArray($data['preferences']);
|
||||
$this->showSuccessNotification(trans('preferences.notifications_update_success'));
|
||||
|
||||
return redirect('/my-account/notifications');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view for the "Access & Security" account options.
|
||||
*/
|
||||
public function showAuth(SocialAuthService $socialAuthService)
|
||||
{
|
||||
$mfaMethods = user()->mfaValues()->get()->groupBy('method');
|
||||
|
||||
$this->setPageTitle(trans('preferences.auth'));
|
||||
|
||||
return view('users.account.auth', [
|
||||
'category' => 'auth',
|
||||
'mfaMethods' => $mfaMethods,
|
||||
'authMethod' => config('auth.method'),
|
||||
'activeSocialDrivers' => $socialAuthService->getActiveDrivers(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the submission for the auth change password form.
|
||||
*/
|
||||
public function updatePassword(Request $request)
|
||||
{
|
||||
$this->preventAccessInDemoMode();
|
||||
|
||||
if (config('auth.method') !== 'standard') {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
|
||||
$validated = $this->validate($request, [
|
||||
'password' => ['required_with:password_confirm', Password::default()],
|
||||
'password-confirm' => ['same:password', 'required_with:password'],
|
||||
]);
|
||||
|
||||
$this->userRepo->update(user(), $validated, false);
|
||||
|
||||
$this->showSuccessNotification(trans('preferences.auth_change_password_success'));
|
||||
|
||||
return redirect('/my-account/auth');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the user self-delete page.
|
||||
*/
|
||||
public function delete()
|
||||
{
|
||||
$this->setPageTitle(trans('preferences.delete_my_account'));
|
||||
|
||||
return view('users.account.delete', [
|
||||
'category' => 'profile',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the current user from the system.
|
||||
*/
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
$this->preventAccessInDemoMode();
|
||||
|
||||
$requestNewOwnerId = intval($request->get('new_owner_id')) ?: null;
|
||||
$newOwnerId = userCan('users-manage') ? $requestNewOwnerId : null;
|
||||
|
||||
$this->userRepo->destroy(user(), $newOwnerId);
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
@@ -103,8 +103,7 @@ class UserController extends Controller
|
||||
*/
|
||||
public function edit(int $id, SocialAuthService $socialAuthService)
|
||||
{
|
||||
$this->preventGuestAccess();
|
||||
$this->checkPermissionOrCurrentUser('users-manage', $id);
|
||||
$this->checkPermission('users-manage');
|
||||
|
||||
$user = $this->userRepo->getById($id);
|
||||
$user->load(['apiTokens', 'mfaValues']);
|
||||
@@ -134,8 +133,7 @@ class UserController extends Controller
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$this->preventAccessInDemoMode();
|
||||
$this->preventGuestAccess();
|
||||
$this->checkPermissionOrCurrentUser('users-manage', $id);
|
||||
$this->checkPermission('users-manage');
|
||||
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['min:2', 'max:100'],
|
||||
@@ -150,7 +148,7 @@ class UserController extends Controller
|
||||
]);
|
||||
|
||||
$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
|
||||
if ($request->hasFile('profile_image')) {
|
||||
@@ -168,9 +166,7 @@ class UserController extends Controller
|
||||
$user->save();
|
||||
}
|
||||
|
||||
$redirectUrl = userCan('users-manage') ? '/settings/users' : "/settings/users/{$user->id}";
|
||||
|
||||
return redirect($redirectUrl);
|
||||
return redirect('/settings/users');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,8 +174,7 @@ class UserController extends Controller
|
||||
*/
|
||||
public function delete(int $id)
|
||||
{
|
||||
$this->preventGuestAccess();
|
||||
$this->checkPermissionOrCurrentUser('users-manage', $id);
|
||||
$this->checkPermission('users-manage');
|
||||
|
||||
$user = $this->userRepo->getById($id);
|
||||
$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)
|
||||
{
|
||||
$this->preventAccessInDemoMode();
|
||||
$this->preventGuestAccess();
|
||||
$this->checkPermissionOrCurrentUser('users-manage', $id);
|
||||
$this->checkPermission('users-manage');
|
||||
|
||||
$user = $this->userRepo->getById($id);
|
||||
$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.
|
||||
*/
|
||||
@@ -145,7 +63,7 @@ class UserPreferencesController extends Controller
|
||||
*/
|
||||
public function toggleDarkMode()
|
||||
{
|
||||
$enabled = setting()->getForCurrentUser('dark-mode-enabled', false);
|
||||
$enabled = setting()->getForCurrentUser('dark-mode-enabled');
|
||||
setting()->putForCurrentUser('dark-mode-enabled', $enabled ? 'false' : 'true');
|
||||
|
||||
return redirect()->back();
|
||||
|
||||
@@ -14,7 +14,7 @@ class UserSearchController extends Controller
|
||||
*/
|
||||
public function forSelect(Request $request)
|
||||
{
|
||||
$hasPermission = signedInUser() && (
|
||||
$hasPermission = !user()->isGuest() && (
|
||||
userCan('users-manage')
|
||||
|| userCan('restrictions-manage-own')
|
||||
|| userCan('restrictions-manage-all')
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Users\Models;
|
||||
|
||||
use BookStack\Access\Mfa\MfaValue;
|
||||
use BookStack\Access\Notifications\ResetPasswordNotification;
|
||||
use BookStack\Access\SocialAccount;
|
||||
use BookStack\Activity\Models\Favourite;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
@@ -11,8 +12,8 @@ use BookStack\Api\ApiToken;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\App\Sluggable;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Notifications\ResetPassword;
|
||||
use BookStack\Translation\LanguageManager;
|
||||
use BookStack\Translation\LocaleDefinition;
|
||||
use BookStack\Translation\LocaleManager;
|
||||
use BookStack\Uploads\Image;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
@@ -88,38 +89,31 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
*/
|
||||
protected string $avatarUrl = '';
|
||||
|
||||
/**
|
||||
* This holds the default user when loaded.
|
||||
*/
|
||||
protected static ?User $defaultUser = null;
|
||||
|
||||
/**
|
||||
* 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 static::$defaultUser;
|
||||
}
|
||||
|
||||
static::$defaultUser = static::query()->where('system_name', '=', 'public')->first();
|
||||
|
||||
return static::$defaultUser;
|
||||
}
|
||||
|
||||
public static function clearDefault(): void
|
||||
{
|
||||
static::$defaultUser = null;
|
||||
return app()->make('users.default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is the default public user.
|
||||
*/
|
||||
public function isDefault(): bool
|
||||
public function isGuest(): bool
|
||||
{
|
||||
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.
|
||||
*
|
||||
@@ -250,7 +244,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
}
|
||||
|
||||
try {
|
||||
$avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default;
|
||||
$avatar = $this->avatar?->getThumb($size, $size, false) ?? $default;
|
||||
} catch (Exception $err) {
|
||||
$avatar = $default;
|
||||
}
|
||||
@@ -345,15 +339,15 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
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)
|
||||
{
|
||||
$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
|
||||
{
|
||||
$this->slug = app(SlugGenerator::class)->generate($this);
|
||||
$this->slug = app()->make(SlugGenerator::class)->generate($this);
|
||||
|
||||
return $this->slug;
|
||||
}
|
||||
|
||||
58
app/Util/OutOfMemoryHandler.php
Normal file
58
app/Util/OutOfMemoryHandler.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Util;
|
||||
|
||||
use BookStack\Exceptions\Handler;
|
||||
use Illuminate\Contracts\Debug\ExceptionHandler;
|
||||
|
||||
/**
|
||||
* Create a handler which runs the provided actions upon an
|
||||
* out-of-memory event. This allows reserving of memory to allow
|
||||
* the desired action to run as needed.
|
||||
*
|
||||
* Essentially provides a wrapper and memory reserving around the
|
||||
* memory handling added to the default app error handler.
|
||||
*/
|
||||
class OutOfMemoryHandler
|
||||
{
|
||||
protected $onOutOfMemory;
|
||||
protected string $memoryReserve = '';
|
||||
|
||||
public function __construct(callable $onOutOfMemory, int $memoryReserveMB = 4)
|
||||
{
|
||||
$this->onOutOfMemory = $onOutOfMemory;
|
||||
|
||||
$this->memoryReserve = str_repeat('x', $memoryReserveMB * 1_000_000);
|
||||
$this->getHandler()->prepareForOutOfMemory(function () {
|
||||
return $this->handle();
|
||||
});
|
||||
}
|
||||
|
||||
protected function handle(): mixed
|
||||
{
|
||||
$result = null;
|
||||
$this->memoryReserve = '';
|
||||
|
||||
if ($this->onOutOfMemory) {
|
||||
$result = call_user_func($this->onOutOfMemory);
|
||||
$this->forget();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the handler so no action is taken place on out of memory.
|
||||
*/
|
||||
public function forget(): void
|
||||
{
|
||||
$this->memoryReserve = '';
|
||||
$this->onOutOfMemory = null;
|
||||
$this->getHandler()->forgetOutOfMemoryHandler();
|
||||
}
|
||||
|
||||
protected function getHandler(): Handler
|
||||
{
|
||||
return app()->make(ExceptionHandler::class);
|
||||
}
|
||||
}
|
||||
39
app/Util/SvgIcon.php
Normal file
39
app/Util/SvgIcon.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Util;
|
||||
|
||||
class SvgIcon
|
||||
{
|
||||
public function __construct(
|
||||
protected string $name,
|
||||
protected array $attrs = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function toHtml(): string
|
||||
{
|
||||
$attrs = array_merge([
|
||||
'class' => 'svg-icon',
|
||||
'data-icon' => $this->name,
|
||||
'role' => 'presentation',
|
||||
], $this->attrs);
|
||||
|
||||
$attrString = ' ';
|
||||
foreach ($attrs as $attrName => $attr) {
|
||||
$attrString .= $attrName . '="' . $attr . '" ';
|
||||
}
|
||||
|
||||
$iconPath = resource_path('icons/' . $this->name . '.svg');
|
||||
$themeIconPath = theme_path('icons/' . $this->name . '.svg');
|
||||
|
||||
if ($themeIconPath && file_exists($themeIconPath)) {
|
||||
$iconPath = $themeIconPath;
|
||||
} elseif (!file_exists($iconPath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$fileContents = file_get_contents($iconPath);
|
||||
|
||||
return str_replace('<svg', '<svg' . $attrString, $fileContents);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
"guzzlehttp/guzzle": "^7.4",
|
||||
"intervention/image": "^2.7",
|
||||
"laravel/framework": "^9.0",
|
||||
"laravel/socialite": "^5.2",
|
||||
"laravel/socialite": "^5.8",
|
||||
"laravel/tinker": "^2.6",
|
||||
"league/commonmark": "^2.3",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
@@ -37,7 +37,6 @@
|
||||
"socialiteproviders/gitlab": "^4.1",
|
||||
"socialiteproviders/microsoft-azure": "^5.1",
|
||||
"socialiteproviders/okta": "^4.2",
|
||||
"socialiteproviders/slack": "^4.1",
|
||||
"socialiteproviders/twitch": "^5.3",
|
||||
"ssddanbrown/htmldiff": "^1.0.2"
|
||||
},
|
||||
|
||||
575
composer.lock
generated
575
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -23,13 +23,13 @@ npm run production
|
||||
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
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user