mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-05 08:39:55 +03:00
Compare commits
417 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
606f9d92d0 | ||
|
|
a5e25abb9c | ||
|
|
3e23f456fe | ||
|
|
b9e2d33ed4 | ||
|
|
19f78dbe6c | ||
|
|
a33dbcb04a | ||
|
|
58f6219cb3 | ||
|
|
b310e87e4c | ||
|
|
425baf9d6e | ||
|
|
0d3de40459 | ||
|
|
3619f79ca6 | ||
|
|
c9d9ad10f2 | ||
|
|
d5a689366c | ||
|
|
bc24a1360f | ||
|
|
77f125208e | ||
|
|
b7d4bd5bce | ||
|
|
5a5f0b8de9 | ||
|
|
825c369ad9 | ||
|
|
10bab70438 | ||
|
|
8e01345f14 | ||
|
|
f5f96f84e7 | ||
|
|
2009d4d6a8 | ||
|
|
4ccfde6d02 | ||
|
|
c4279c9697 | ||
|
|
350e0b281b | ||
|
|
08805ea3c8 | ||
|
|
48ea0bc291 | ||
|
|
a75d5b8bc1 | ||
|
|
055bbf17de | ||
|
|
be3423a16e | ||
|
|
bbb41e8b5c | ||
|
|
c290d01adb | ||
|
|
16327cf40c | ||
|
|
999d41a7f5 | ||
|
|
9ff9b9c805 | ||
|
|
8f1d8cef9e | ||
|
|
8688ad99b6 | ||
|
|
ed0718d3f7 | ||
|
|
c53c9f6866 | ||
|
|
3fdee6a93b | ||
|
|
cafea1c02d | ||
|
|
32e20e5059 | ||
|
|
c66b8ad842 | ||
|
|
c9a5c29abf | ||
|
|
12daa1c2b9 | ||
|
|
ff8daad22b | ||
|
|
1ea2ac864a | ||
|
|
ed9c013f6e | ||
|
|
ed21a6d798 | ||
|
|
b77ab6f3af | ||
|
|
546cfb0dcc | ||
|
|
483410749b | ||
|
|
c95f4ca40f | ||
|
|
222c665018 | ||
|
|
8e78b4c43e | ||
|
|
05ac0fcd1d | ||
|
|
9fa68fd8ab | ||
|
|
3886aedf54 | ||
|
|
1559b0acd1 | ||
|
|
a70ed81908 | ||
|
|
2460e7c56e | ||
|
|
779f09bff6 | ||
|
|
43a72fb9a5 | ||
|
|
4137cf9c8f | ||
|
|
16af833124 | ||
|
|
47f082c085 | ||
|
|
fee9045dac | ||
|
|
06901b878f | ||
|
|
e9a19d5878 | ||
|
|
adf0baebb9 | ||
|
|
5c92b72fdd | ||
|
|
24e6dc4b37 | ||
|
|
4a8f70240f | ||
|
|
64c783c6f8 | ||
|
|
2a849894be | ||
|
|
415663a9bc | ||
|
|
1dc094ffaf | ||
|
|
3e9e196cda | ||
|
|
5903823eed | ||
|
|
9441e32c69 | ||
|
|
530fc37067 | ||
|
|
8fb9d9d4c2 | ||
|
|
eff7aa0f73 | ||
|
|
14ecb19b05 | ||
|
|
0fc02a2532 | ||
|
|
8c6b116472 | ||
|
|
69c8ff5c2d | ||
|
|
788327fffb | ||
|
|
655ae5ecae | ||
|
|
d5a91d0d35 | ||
|
|
369e499dce | ||
|
|
655815de6d | ||
|
|
a4fd825fe2 | ||
|
|
496b4264d9 | ||
|
|
57284bb869 | ||
|
|
adf1806fea | ||
|
|
2dc454d206 | ||
|
|
c1552fb799 | ||
|
|
91d8d6eaaa | ||
|
|
afbbcafd44 | ||
|
|
d94762549a | ||
|
|
b4d9029dc3 | ||
|
|
70bfebcd7c | ||
|
|
457adc1fee | ||
|
|
e86a90967e | ||
|
|
b191d8f99f | ||
|
|
c017f5bed1 | ||
|
|
5b1929a39a | ||
|
|
02d94c8798 | ||
|
|
88ee33ee49 | ||
|
|
529f7bd1bc | ||
|
|
3668949705 | ||
|
|
7cd0629a75 | ||
|
|
fb3cfaf7c7 | ||
|
|
2a7a81e749 | ||
|
|
00ae04e0bd | ||
|
|
ed5d67e609 | ||
|
|
a21ca44633 | ||
|
|
7fd6d5b2cc | ||
|
|
077b9709d4 | ||
|
|
2fbed3919b | ||
|
|
c07aa056c2 | ||
|
|
bc354e8b12 | ||
|
|
307fae39c4 | ||
|
|
c622b785a9 | ||
|
|
569542f0bb | ||
|
|
fc2e8ed315 | ||
|
|
0c4dd7874c | ||
|
|
7250671889 | ||
|
|
5395ca2f00 | ||
|
|
56d07f1909 | ||
|
|
4896c4047f | ||
|
|
3af07addf6 | ||
|
|
2f3806244c | ||
|
|
2081a783f3 | ||
|
|
d75eb06777 | ||
|
|
4017048555 | ||
|
|
7ebe7d4e58 | ||
|
|
d61f42a377 | ||
|
|
968bc8cdf3 | ||
|
|
c13fd2a9e6 | ||
|
|
45ce7a7126 | ||
|
|
11955e270c | ||
|
|
33374524bf | ||
|
|
8cbaa3e27c | ||
|
|
4c0b7f3123 | ||
|
|
7312300d53 | ||
|
|
81d256aebd | ||
|
|
a72e0fee70 | ||
|
|
f32cfb4292 | ||
|
|
bba7dcce49 | ||
|
|
cc10d1ddfc | ||
|
|
0254527bd9 | ||
|
|
11853361b0 | ||
|
|
596f7314cd | ||
|
|
1011d61713 | ||
|
|
652d5417bf | ||
|
|
b569827114 | ||
|
|
71c93c8878 | ||
|
|
4874dc1304 | ||
|
|
c88eb729a4 | ||
|
|
75936454cc | ||
|
|
04d21c8a97 | ||
|
|
5d08f7cf14 | ||
|
|
8744eb2d62 | ||
|
|
15d7161428 | ||
|
|
9b1f820596 | ||
|
|
2fb873f7ef | ||
|
|
22a9cf1e48 | ||
|
|
37a17e858a | ||
|
|
eab9c1081e | ||
|
|
db7b11fe93 | ||
|
|
3a6f50e668 | ||
|
|
76417efd6f | ||
|
|
d41fd7a8dd | ||
|
|
65ac197be4 | ||
|
|
bff1f502bb | ||
|
|
f8ebbb7553 | ||
|
|
d8383cfa80 | ||
|
|
4626278447 | ||
|
|
48f115291a | ||
|
|
6cd38a8ace | ||
|
|
fa6ac211b6 | ||
|
|
1310db19ca | ||
|
|
ea0469e61a | ||
|
|
c61af9c22b | ||
|
|
72521d0906 | ||
|
|
889b0dae3b | ||
|
|
48bda115aa | ||
|
|
9dd05b8751 | ||
|
|
02d140120a | ||
|
|
38ac3c959b | ||
|
|
324e403ae5 | ||
|
|
fce7190257 | ||
|
|
c640db8434 | ||
|
|
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 | ||
|
|
bde66a1396 | ||
|
|
4de5a2d9bf | ||
|
|
2abbcf5c0f | ||
|
|
7a48516bf4 | ||
|
|
e31b50dabd | ||
|
|
817581aa0c | ||
|
|
1cd19c76ba | ||
|
|
5d38ae3c97 | ||
|
|
a720b3725d | ||
|
|
3847a76134 | ||
|
|
f91049a3f2 | ||
|
|
4e6b74f2a1 | ||
|
|
976f241ae0 | ||
|
|
415dab9936 | ||
|
|
54715d40ef | ||
|
|
27bf4299cf | ||
|
|
164f01bb25 | ||
|
|
c6d0e690f9 | ||
|
|
77d65d1ca1 | ||
|
|
dc77233ec3 | ||
|
|
3622c440d7 | ||
|
|
a0942ef441 | ||
|
|
6b55104ecb | ||
|
|
642210ab4c | ||
|
|
e176aae940 | ||
|
|
903895814a | ||
|
|
c324ad928d | ||
|
|
9100a82b47 | ||
|
|
32516f7b68 | ||
|
|
69ac425903 | ||
|
|
3917e50c90 | ||
|
|
dd71658d70 | ||
|
|
a4fbde9185 | ||
|
|
cbcec189fd | ||
|
|
0628c28f66 | ||
|
|
391478465a | ||
|
|
9ca1139ab0 | ||
|
|
7bf5425c6b | ||
|
|
e44ef57219 | ||
|
|
fef433a9cb | ||
|
|
e709caa005 | ||
|
|
38829f8a38 | ||
|
|
ee9e342b58 | ||
|
|
79470ea4b7 | ||
|
|
565908ef52 | ||
|
|
bc6e19b2a1 | ||
|
|
615741af9d | ||
|
|
371779205a | ||
|
|
d9fdecd902 | ||
|
|
2b604b5af9 | ||
|
|
08ea97fd83 | ||
|
|
601491b275 | ||
|
|
88e148ba00 | ||
|
|
c47b3f805a | ||
|
|
ecab2c8e42 | ||
|
|
18ae67a138 | ||
|
|
9779c1a357 | ||
|
|
9d149e4d36 | ||
|
|
8cdf3203ef | ||
|
|
6100b99828 | ||
|
|
730f539029 | ||
|
|
ff2674c464 | ||
|
|
100b28707c | ||
|
|
45e75edf05 | ||
|
|
1c922be4c7 | ||
|
|
0359e2490a | ||
|
|
422e50302a | ||
|
|
f563a005f5 | ||
|
|
a14d8e30cc | ||
|
|
7504ad32a7 | ||
|
|
fca18862d2 | ||
|
|
ae834050f5 | ||
|
|
a83150131a | ||
|
|
3a36d3c847 | ||
|
|
4d399f6ba7 | ||
|
|
b1b8067cbe | ||
|
|
a9194ffb63 | ||
|
|
2f9c1b7127 | ||
|
|
18979e84d6 | ||
|
|
bf5e886d76 | ||
|
|
e04a1af444 | ||
|
|
eb2c5d00cb | ||
|
|
96819b7bd9 | ||
|
|
18ee80a743 | ||
|
|
1a56de6cb4 | ||
|
|
465989efa9 | ||
|
|
d293171da2 | ||
|
|
174cd5a893 | ||
|
|
ccfe38e963 | ||
|
|
23ae332c1b | ||
|
|
3a39f13420 | ||
|
|
ca2d2c97d4 | ||
|
|
e47870794d | ||
|
|
e43d85b801 | ||
|
|
bb3ce845b4 | ||
|
|
458cea3644 | ||
|
|
ac519b3009 | ||
|
|
ec3b06d83f | ||
|
|
99ae759eff | ||
|
|
1dbc3588cf | ||
|
|
3599a962a3 |
@@ -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
|
||||
@@ -273,6 +273,7 @@ OIDC_USER_TO_GROUPS=false
|
||||
OIDC_GROUPS_CLAIM=groups
|
||||
OIDC_REMOVE_FROM_GROUPS=false
|
||||
OIDC_EXTERNAL_ID_CLAIM=sub
|
||||
OIDC_END_SESSION_ENDPOINT=false
|
||||
|
||||
# Disable default third-party services such as Gravatar and Draw.IO
|
||||
# Service-specific options will override this option
|
||||
@@ -359,6 +360,15 @@ ALLOWED_IFRAME_HOSTS=null
|
||||
# Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
|
||||
ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com"
|
||||
|
||||
# A list of the sources/hostnames that can be reached by application SSR calls.
|
||||
# This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
|
||||
# Host-specific functionality (usually controlled via other options) like auth
|
||||
# or user avatars for example, won't use this list.
|
||||
# Space seperated if multiple. Can use '*' as a wildcard.
|
||||
# Values will be compared prefix-matched, case-insensitive, against called SSR urls.
|
||||
# Defaults to allow all hosts.
|
||||
ALLOWED_SSR_HOSTS="*"
|
||||
|
||||
# The default and maximum item-counts for listing API requests.
|
||||
API_DEFAULT_ITEM_COUNT=100
|
||||
API_MAX_ITEM_COUNT=500
|
||||
|
||||
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!
|
||||
|
||||
77
.github/translators.txt
vendored
77
.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; Portuguese, Brazilian; Portuguese; 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
|
||||
@@ -323,7 +324,7 @@ Robin Flikkema (RobinFlikkema) :: Dutch
|
||||
Michal Gurcik (mgurcik) :: Slovak
|
||||
Pooyan Arab (pooyanarab) :: Persian
|
||||
Ochi Darma Putra (troke12) :: Indonesian
|
||||
H.-H. Peng (Hsins) :: Chinese Traditional
|
||||
Hsin-Hsiang Peng (Hsins) :: Chinese Traditional
|
||||
Mosi Wang (mosiwang) :: Chinese Traditional
|
||||
骆言 (LawssssCat) :: Chinese Simplified
|
||||
Stickers Gaming Shøw (StickerSGSHOW) :: French
|
||||
@@ -341,3 +342,71 @@ Ingus Rūķis (ingus.rukis) :: Latvian
|
||||
Eugene Pershin (SilentEugene) :: Russian
|
||||
周盛道 (zhoushengdao) :: Chinese Simplified
|
||||
hamidreza amini (hamidrezaamini2022) :: Persian
|
||||
Tomislav Kraljević (tomislav.kraljevic) :: Croatian
|
||||
Taygun Yıldırım (yildirimtaygun) :: Turkish
|
||||
robing29 :: German
|
||||
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
|
||||
Igor V Belousov (biv) :: Russian
|
||||
David Bauer (davbauer) :: German
|
||||
Guttorm Hveem (guttormhveem) :: Norwegian Nynorsk; Norwegian Bokmal
|
||||
Minh Giang Truong (minhgiang1204) :: Vietnamese
|
||||
Ioannis Ioannides (i.ioannides) :: Greek
|
||||
Vadim (vadrozh) :: Russian
|
||||
Flip333 :: German Informal; German
|
||||
Paulo Henrique (paulohsantos114) :: Portuguese, Brazilian
|
||||
Dženan (Dzenan) :: Swedish
|
||||
Péter Péli (peter.peli) :: Hungarian
|
||||
TWME :: Chinese Traditional
|
||||
Sascha (Man-in-Black) :: German; German Informal
|
||||
Mohammadreza Madadi (madadi.efl) :: Persian
|
||||
Konstantin (kkovacheli) :: Ukrainian; Russian
|
||||
link1183 :: French
|
||||
Renan (rfpe) :: Portuguese, Brazilian
|
||||
Lowkey (bbsweb) :: Chinese Simplified
|
||||
ZZnOB (zznobzz) :: Russian
|
||||
rupus :: Swedish
|
||||
developernecsys :: Norwegian Nynorsk
|
||||
xuan LI (xuanli233) :: Chinese Simplified
|
||||
LameeQS :: Latvian
|
||||
Sorin T. (trimbitassorin) :: Romanian
|
||||
poesty :: Chinese Simplified
|
||||
balmag :: Hungarian
|
||||
Antti-Jussi Nygård (ajnyga) :: Finnish
|
||||
Eduard Ereza Martínez (Ereza) :: Catalan
|
||||
Jabir Lang (amar.almrad) :: Arabic
|
||||
Jaroslav Kobližek (foretix) :: Czech; French
|
||||
Wiktor Adamczyk (adamczyk.wiktor) :: Polish
|
||||
Abdulmajeed Alshuaibi (4Majeed) :: Arabic
|
||||
NotSmartZakk :: Czech
|
||||
HyoungMin Lee (ddokkaebi) :: Korean
|
||||
Dasferco :: Chinese Simplified
|
||||
Marcus Teräs (mteras) :: Finnish
|
||||
Serkan Yardim (serkanzz) :: Turkish
|
||||
Y (cnsr) :: Ukrainian
|
||||
ZY ZV (vy0b0x) :: Chinese Simplified
|
||||
diegobenitez :: Spanish
|
||||
Marc Hagen (MarcHagen) :: Dutch
|
||||
Kasper Alsøe (zeonos) :: Danish
|
||||
sultani :: Persian
|
||||
renge :: Korean
|
||||
TheGatesDev (thegatesdev) :: Dutch
|
||||
Irdi (irdiOL) :: Albanian
|
||||
KateBarber :: Welsh
|
||||
Twister (theuncles75) :: Hebrew
|
||||
algernon19 :: Hungarian
|
||||
Ivan Krstic (ikrstic) :: Serbian (Cyrillic)
|
||||
Show :: Russian
|
||||
xBahamut :: Portuguese, Brazilian
|
||||
Pavle Knežević (pavleknezzevic) :: Serbian (Cyrillic)
|
||||
Vanja Cvelbar (b100w11) :: Slovenian
|
||||
simonpct :: French
|
||||
Honza Nagy (honza.nagy) :: Czech
|
||||
asd20752 :: Norwegian Bokmal
|
||||
Jan Picka (polipones) :: Czech
|
||||
diogoalex991 :: Portuguese
|
||||
Ehsan Sadeghi (ehsansadeghi) :: Persian
|
||||
ka_picit :: Danish
|
||||
cracrayol :: French
|
||||
CapuaSC :: Dutch
|
||||
Guardian75 :: German Informal
|
||||
mr-kanister :: German
|
||||
|
||||
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:
|
||||
|
||||
12
.github/workflows/test-migrations.yml
vendored
12
.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:
|
||||
@@ -8,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.0', '8.1', '8.2']
|
||||
php: ['8.0', '8.1', '8.2', '8.3']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
|
||||
12
.github/workflows/test-php.yml
vendored
12
.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:
|
||||
@@ -8,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.0', '8.1', '8.2']
|
||||
php: ['8.0', '8.1', '8.2', '8.3']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -29,4 +29,5 @@ webpack-stats.json
|
||||
.phpunit.result.cache
|
||||
.DS_Store
|
||||
phpstan.neon
|
||||
esbuild-meta.json
|
||||
esbuild-meta.json
|
||||
.phpactor.json
|
||||
|
||||
@@ -9,11 +9,6 @@ use Illuminate\Support\Facades\Password;
|
||||
|
||||
class ForgotPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('guest');
|
||||
@@ -30,10 +25,6 @@ class ForgotPasswordController extends Controller
|
||||
|
||||
/**
|
||||
* Send a reset link to the given user.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*
|
||||
* @return \Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
public function sendResetLinkEmail(Request $request)
|
||||
{
|
||||
@@ -56,13 +47,13 @@ class ForgotPasswordController extends Controller
|
||||
$message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
|
||||
$this->showSuccessNotification($message);
|
||||
|
||||
return back()->with('status', trans($response));
|
||||
return redirect('/password/email')->with('status', trans($response));
|
||||
}
|
||||
|
||||
// If an error was returned by the password broker, we will get this message
|
||||
// translated so we can notify a user of the problem. We'll redirect back
|
||||
// to where the users came from so they can attempt this process again.
|
||||
return back()->withErrors(
|
||||
return redirect('/password/email')->withErrors(
|
||||
['email' => trans($response)]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,34 +3,26 @@
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Access\SocialAuthService;
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
use ThrottlesLogins;
|
||||
|
||||
protected SocialAuthService $socialAuthService;
|
||||
protected LoginService $loginService;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct(SocialAuthService $socialAuthService, LoginService $loginService)
|
||||
{
|
||||
public function __construct(
|
||||
protected SocialDriverManager $socialDriverManager,
|
||||
protected LoginService $loginService,
|
||||
) {
|
||||
$this->middleware('guest', ['only' => ['getLogin', 'login']]);
|
||||
$this->middleware('guard:standard,ldap', ['only' => ['login']]);
|
||||
$this->middleware('guard:standard,ldap,oidc', ['only' => ['logout']]);
|
||||
|
||||
$this->socialAuthService = $socialAuthService;
|
||||
$this->loginService = $loginService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,7 +30,7 @@ class LoginController extends Controller
|
||||
*/
|
||||
public function getLogin(Request $request)
|
||||
{
|
||||
$socialDrivers = $this->socialAuthService->getActiveDrivers();
|
||||
$socialDrivers = $this->socialDriverManager->getActive();
|
||||
$authMethod = config('auth.method');
|
||||
$preventInitiation = $request->get('prevent_auto_init') === 'true';
|
||||
|
||||
@@ -52,7 +44,7 @@ class LoginController extends Controller
|
||||
// Store the previous location for redirect after login
|
||||
$this->updateIntendedFromPrevious();
|
||||
|
||||
if (!$preventInitiation && $this->shouldAutoInitiate()) {
|
||||
if (!$preventInitiation && $this->loginService->shouldAutoInitiate()) {
|
||||
return view('auth.login-initiate', [
|
||||
'authMethod' => $authMethod,
|
||||
]);
|
||||
@@ -101,15 +93,9 @@ class LoginController extends Controller
|
||||
/**
|
||||
* Logout user and perform subsequent redirect.
|
||||
*/
|
||||
public function logout(Request $request)
|
||||
public function logout()
|
||||
{
|
||||
Auth::guard()->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
|
||||
|
||||
return redirect($redirectUri);
|
||||
return redirect($this->loginService->logout());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -200,7 +186,7 @@ class LoginController extends Controller
|
||||
{
|
||||
// Store the previous location for redirect after login
|
||||
$previous = url()->previous('');
|
||||
$isPreviousFromInstance = (strpos($previous, url('/')) === 0);
|
||||
$isPreviousFromInstance = str_starts_with($previous, url('/'));
|
||||
if (!$previous || !setting('app-public') || !$isPreviousFromInstance) {
|
||||
return;
|
||||
}
|
||||
@@ -211,23 +197,11 @@ class LoginController extends Controller
|
||||
];
|
||||
|
||||
foreach ($ignorePrefixList as $ignorePrefix) {
|
||||
if (strpos($previous, url($ignorePrefix)) === 0) {
|
||||
if (str_starts_with($previous, url($ignorePrefix))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
redirect()->setIntendedUrl($previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if login auto-initiate should be valid based upon authentication config.
|
||||
*/
|
||||
protected function shouldAutoInitiate(): bool
|
||||
{
|
||||
$socialDrivers = $this->socialAuthService->getActiveDrivers();
|
||||
$authMethod = config('auth.method');
|
||||
$autoRedirect = config('auth.auto_initiate');
|
||||
|
||||
return $autoRedirect && count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,6 @@ class OidcController extends Controller
|
||||
{
|
||||
protected OidcService $oidcService;
|
||||
|
||||
/**
|
||||
* OpenIdController constructor.
|
||||
*/
|
||||
public function __construct(OidcService $oidcService)
|
||||
{
|
||||
$this->oidcService = $oidcService;
|
||||
@@ -63,4 +60,12 @@ class OidcController extends Controller
|
||||
|
||||
return redirect()->intended();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out then start the OIDC RP-initiated logout process.
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
return redirect($this->oidcService->logout());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Access\RegistrationService;
|
||||
use BookStack\Access\SocialAuthService;
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Http\Controller;
|
||||
@@ -15,7 +15,7 @@ use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
protected SocialAuthService $socialAuthService;
|
||||
protected SocialDriverManager $socialDriverManager;
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
|
||||
@@ -23,14 +23,14 @@ class RegisterController extends Controller
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct(
|
||||
SocialAuthService $socialAuthService,
|
||||
SocialDriverManager $socialDriverManager,
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService
|
||||
) {
|
||||
$this->middleware('guest');
|
||||
$this->middleware('guard:standard');
|
||||
|
||||
$this->socialAuthService = $socialAuthService;
|
||||
$this->socialDriverManager = $socialDriverManager;
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
}
|
||||
@@ -43,7 +43,7 @@ class RegisterController extends Controller
|
||||
public function getRegister()
|
||||
{
|
||||
$this->registrationService->ensureRegistrationAllowed();
|
||||
$socialDrivers = $this->socialAuthService->getActiveDrivers();
|
||||
$socialDrivers = $this->socialDriverManager->getActive();
|
||||
|
||||
return view('auth.register', [
|
||||
'socialDrivers' => $socialDrivers,
|
||||
|
||||
@@ -66,7 +66,7 @@ class ResetPasswordController extends Controller
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $response === Password::PASSWORD_RESET
|
||||
? $this->sendResetResponse()
|
||||
: $this->sendResetFailedResponse($request, $response);
|
||||
: $this->sendResetFailedResponse($request, $response, $request->get('token'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +83,7 @@ class ResetPasswordController extends Controller
|
||||
/**
|
||||
* Get the response for a failed password reset.
|
||||
*/
|
||||
protected function sendResetFailedResponse(Request $request, string $response): RedirectResponse
|
||||
protected function sendResetFailedResponse(Request $request, string $response, string $token): RedirectResponse
|
||||
{
|
||||
// We show invalid users as invalid tokens as to not leak what
|
||||
// users may exist in the system.
|
||||
@@ -91,7 +91,7 @@ class ResetPasswordController extends Controller
|
||||
$response = Password::INVALID_TOKEN;
|
||||
}
|
||||
|
||||
return redirect()->back()
|
||||
return redirect("/password/reset/{$token}")
|
||||
->withInput($request->only('email'))
|
||||
->withErrors(['email' => trans($response)]);
|
||||
}
|
||||
|
||||
@@ -9,14 +9,9 @@ use Illuminate\Support\Str;
|
||||
|
||||
class Saml2Controller extends Controller
|
||||
{
|
||||
protected Saml2Service $samlService;
|
||||
|
||||
/**
|
||||
* Saml2Controller constructor.
|
||||
*/
|
||||
public function __construct(Saml2Service $samlService)
|
||||
{
|
||||
$this->samlService = $samlService;
|
||||
public function __construct(
|
||||
protected Saml2Service $samlService
|
||||
) {
|
||||
$this->middleware('guard:saml2');
|
||||
}
|
||||
|
||||
@@ -36,7 +31,12 @@ class Saml2Controller extends Controller
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
$logoutDetails = $this->samlService->logout(auth()->user());
|
||||
$user = user();
|
||||
if ($user->isGuest()) {
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
$logoutDetails = $this->samlService->logout($user);
|
||||
|
||||
if ($logoutDetails['id']) {
|
||||
session()->flash('saml2_logout_request_id', $logoutDetails['id']);
|
||||
@@ -64,7 +64,7 @@ class Saml2Controller extends Controller
|
||||
public function sls()
|
||||
{
|
||||
$requestId = session()->pull('saml2_logout_request_id', null);
|
||||
$redirect = $this->samlService->processSlsResponse($requestId) ?? '/';
|
||||
$redirect = $this->samlService->processSlsResponse($requestId);
|
||||
|
||||
return redirect($redirect);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,7 +79,7 @@ class SocialController extends Controller
|
||||
try {
|
||||
return $this->socialAuthService->handleLoginCallback($socialDriver, $socialUser);
|
||||
} catch (SocialSignInAccountNotUsed $exception) {
|
||||
if ($this->socialAuthService->driverAutoRegisterEnabled($socialDriver)) {
|
||||
if ($this->socialAuthService->drivers()->isAutoRegisterEnabled($socialDriver)) {
|
||||
return $this->socialRegisterCallback($socialDriver, $socialUser);
|
||||
}
|
||||
|
||||
@@ -101,7 +91,7 @@ class SocialController extends Controller
|
||||
return $this->socialRegisterCallback($socialDriver, $socialUser);
|
||||
}
|
||||
|
||||
return redirect()->back();
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,7 +114,7 @@ class SocialController extends Controller
|
||||
{
|
||||
$socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser);
|
||||
$socialAccount = $this->socialAuthService->newSocialAccount($socialDriver, $socialUser);
|
||||
$emailVerified = $this->socialAuthService->driverAutoConfirmEmailEnabled($socialDriver);
|
||||
$emailVerified = $this->socialAuthService->drivers()->isAutoConfirmEmailEnabled($socialDriver);
|
||||
|
||||
// Create an array of the user data to create a new user instance
|
||||
$userData = [
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,13 +16,11 @@ class LoginService
|
||||
{
|
||||
protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted';
|
||||
|
||||
protected $mfaSession;
|
||||
protected $emailConfirmationService;
|
||||
|
||||
public function __construct(MfaSession $mfaSession, EmailConfirmationService $emailConfirmationService)
|
||||
{
|
||||
$this->mfaSession = $mfaSession;
|
||||
$this->emailConfirmationService = $emailConfirmationService;
|
||||
public function __construct(
|
||||
protected MfaSession $mfaSession,
|
||||
protected EmailConfirmationService $emailConfirmationService,
|
||||
protected SocialDriverManager $socialDriverManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -163,4 +161,33 @@ class LoginService
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the current user out of the application.
|
||||
* Returns an app post-redirect path.
|
||||
*/
|
||||
public function logout(): string
|
||||
{
|
||||
auth()->logout();
|
||||
session()->invalidate();
|
||||
session()->regenerateToken();
|
||||
|
||||
return $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if login auto-initiate should be active based upon authentication config.
|
||||
*/
|
||||
public function shouldAutoInitiate(): bool
|
||||
{
|
||||
$autoRedirect = config('auth.auto_initiate');
|
||||
if (!$autoRedirect) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$socialDrivers = $this->socialDriverManager->getActive();
|
||||
$authMethod = config('auth.method');
|
||||
|
||||
return count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
|
||||
}
|
||||
}
|
||||
|
||||
26
app/Access/Notifications/ConfirmEmailNotification.php
Normal file
26
app/Access/Notifications/ConfirmEmailNotification.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Notifications;
|
||||
|
||||
use BookStack\App\MailNotification;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class ConfirmEmailNotification extends MailNotification
|
||||
{
|
||||
public function __construct(
|
||||
public string $token
|
||||
) {
|
||||
}
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
$appName = ['appName' => setting('app-name')];
|
||||
|
||||
return $this->newMailMessage()
|
||||
->subject(trans('auth.email_confirm_subject', $appName))
|
||||
->greeting(trans('auth.email_confirm_greeting', $appName))
|
||||
->line(trans('auth.email_confirm_text'))
|
||||
->action(trans('auth.email_confirm_action'), url('/register/confirm/' . $this->token));
|
||||
}
|
||||
}
|
||||
24
app/Access/Notifications/ResetPasswordNotification.php
Normal file
24
app/Access/Notifications/ResetPasswordNotification.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access\Notifications;
|
||||
|
||||
use BookStack\App\MailNotification;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class ResetPasswordNotification extends MailNotification
|
||||
{
|
||||
public function __construct(
|
||||
public string $token
|
||||
) {
|
||||
}
|
||||
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
return $this->newMailMessage()
|
||||
->subject(trans('auth.email_reset_subject', ['appName' => setting('app-name')]))
|
||||
->line(trans('auth.email_reset_text'))
|
||||
->action(trans('auth.reset_password'), url('password/reset/' . $this->token))
|
||||
->line(trans('auth.email_reset_not_requested'));
|
||||
}
|
||||
}
|
||||
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
|
||||
{
|
||||
@@ -90,15 +83,9 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
|
||||
/**
|
||||
* Checks a provider response for errors.
|
||||
*
|
||||
* @param ResponseInterface $response
|
||||
* @param array|string $data Parsed response data
|
||||
*
|
||||
* @throws IdentityProviderException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function checkResponse(ResponseInterface $response, $data)
|
||||
protected function checkResponse(ResponseInterface $response, $data): void
|
||||
{
|
||||
if ($response->getStatusCode() >= 400 || isset($data['error'])) {
|
||||
throw new IdentityProviderException(
|
||||
@@ -112,13 +99,8 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
/**
|
||||
* Generates a resource owner object from a successful resource owner
|
||||
* details request.
|
||||
*
|
||||
* @param array $response
|
||||
* @param AccessToken $token
|
||||
*
|
||||
* @return ResourceOwnerInterface
|
||||
*/
|
||||
protected function createResourceOwner(array $response, AccessToken $token)
|
||||
protected function createResourceOwner(array $response, AccessToken $token): ResourceOwnerInterface
|
||||
{
|
||||
return new GenericResourceOwner($response, '');
|
||||
}
|
||||
@@ -128,14 +110,18 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
*
|
||||
* The grant that was used to fetch the response can be used to provide
|
||||
* additional context.
|
||||
*
|
||||
* @param array $response
|
||||
* @param AbstractGrant $grant
|
||||
*
|
||||
* @return OidcAccessToken
|
||||
*/
|
||||
protected function createAccessToken(array $response, AbstractGrant $grant)
|
||||
protected function createAccessToken(array $response, AbstractGrant $grant): OidcAccessToken
|
||||
{
|
||||
return new OidcAccessToken($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the method used for PKCE code verifier hashing, which is passed
|
||||
* in the "code_challenge_method" parameter in the authorization request.
|
||||
*/
|
||||
protected function getPkceMethod(): string
|
||||
{
|
||||
return static::PKCE_METHOD_S256;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class OidcProviderSettings
|
||||
public ?string $redirectUri;
|
||||
public ?string $authorizationEndpoint;
|
||||
public ?string $tokenEndpoint;
|
||||
public ?string $endSessionEndpoint;
|
||||
|
||||
/**
|
||||
* @var string[]|array[]
|
||||
@@ -59,7 +60,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://');
|
||||
}
|
||||
}
|
||||
@@ -132,6 +133,10 @@ class OidcProviderSettings
|
||||
$discoveredSettings['keys'] = $this->filterKeys($keys);
|
||||
}
|
||||
|
||||
if (!empty($result['end_session_endpoint'])) {
|
||||
$discoveredSettings['endSessionEndpoint'] = $result['end_session_endpoint'];
|
||||
}
|
||||
|
||||
return $discoveredSettings;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,13 +26,15 @@ class OidcService
|
||||
public function __construct(
|
||||
protected RegistrationService $registrationService,
|
||||
protected LoginService $loginService,
|
||||
protected HttpClient $httpClient,
|
||||
protected HttpRequestService $http,
|
||||
protected GroupSyncService $groupService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate an authorization flow.
|
||||
* Provides back an authorize redirect URL, in addition to other
|
||||
* details which may be required for the auth flow.
|
||||
*
|
||||
* @throws OidcException
|
||||
*
|
||||
@@ -42,8 +44,12 @@ class OidcService
|
||||
{
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
|
||||
$url = $provider->getAuthorizationUrl();
|
||||
session()->put('oidc_pkce_code', $provider->getPkceCode() ?? '');
|
||||
|
||||
return [
|
||||
'url' => $provider->getAuthorizationUrl(),
|
||||
'url' => $url,
|
||||
'state' => $provider->getState(),
|
||||
];
|
||||
}
|
||||
@@ -63,6 +69,10 @@ class OidcService
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
|
||||
// Set PKCE code flashed at login
|
||||
$pkceCode = session()->pull('oidc_pkce_code', '');
|
||||
$provider->setPkceCode($pkceCode);
|
||||
|
||||
// Try to exchange authorization code for access token
|
||||
$accessToken = $provider->getAccessToken('authorization_code', [
|
||||
'code' => $authorizationCode,
|
||||
@@ -84,6 +94,7 @@ class OidcService
|
||||
'redirectUri' => url('/oidc/callback'),
|
||||
'authorizationEndpoint' => $config['authorization_endpoint'],
|
||||
'tokenEndpoint' => $config['token_endpoint'],
|
||||
'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null,
|
||||
]);
|
||||
|
||||
// Use keys if configured
|
||||
@@ -94,12 +105,20 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent use of RP-initiated logout if specifically disabled
|
||||
// Or force use of a URL if specifically set.
|
||||
if ($config['end_session_endpoint'] === false) {
|
||||
$settings->endSessionEndpoint = null;
|
||||
} else if (is_string($config['end_session_endpoint'])) {
|
||||
$settings->endSessionEndpoint = $config['end_session_endpoint'];
|
||||
}
|
||||
|
||||
$settings->validate();
|
||||
|
||||
return $settings;
|
||||
@@ -111,7 +130,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 +161,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;
|
||||
@@ -216,6 +236,8 @@ class OidcService
|
||||
$settings->keys,
|
||||
);
|
||||
|
||||
session()->put("oidc_id_token", $idTokenText);
|
||||
|
||||
$returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [
|
||||
'access_token' => $accessToken->getToken(),
|
||||
'expires_in' => $accessToken->getExpires(),
|
||||
@@ -283,4 +305,30 @@ class OidcService
|
||||
{
|
||||
return $this->config()['user_to_groups'] !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the RP-initiated logout flow if active, otherwise start a standard logout flow.
|
||||
* Returns a post-app-logout redirect URL.
|
||||
* Reference: https://openid.net/specs/openid-connect-rpinitiated-1_0.html
|
||||
* @throws OidcException
|
||||
*/
|
||||
public function logout(): string
|
||||
{
|
||||
$oidcToken = session()->pull("oidc_id_token");
|
||||
$defaultLogoutUrl = url($this->loginService->logout());
|
||||
$oidcSettings = $this->getProviderSettings();
|
||||
|
||||
if (!$oidcSettings->endSessionEndpoint) {
|
||||
return $defaultLogoutUrl;
|
||||
}
|
||||
|
||||
$endpointParams = [
|
||||
'id_token_hint' => $oidcToken,
|
||||
'post_logout_redirect_uri' => $defaultLogoutUrl,
|
||||
];
|
||||
|
||||
$joiner = str_contains($oidcSettings->endSessionEndpoint, '?') ? '&' : '?';
|
||||
|
||||
return $oidcSettings->endSessionEndpoint . $joiner . http_build_query($endpointParams);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,20 +14,14 @@ use Illuminate\Support\Str;
|
||||
|
||||
class RegistrationService
|
||||
{
|
||||
protected $userRepo;
|
||||
protected $emailConfirmationService;
|
||||
|
||||
/**
|
||||
* RegistrationService constructor.
|
||||
*/
|
||||
public function __construct(UserRepo $userRepo, EmailConfirmationService $emailConfirmationService)
|
||||
{
|
||||
$this->userRepo = $userRepo;
|
||||
$this->emailConfirmationService = $emailConfirmationService;
|
||||
public function __construct(
|
||||
protected UserRepo $userRepo,
|
||||
protected EmailConfirmationService $emailConfirmationService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not registrations are allowed in the app settings.
|
||||
* Check if registrations are allowed in the app settings.
|
||||
*
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
@@ -84,6 +78,7 @@ class RegistrationService
|
||||
public function registerUser(array $userData, ?SocialAccount $socialAccount = null, bool $emailConfirmed = false): User
|
||||
{
|
||||
$userEmail = $userData['email'];
|
||||
$authSystem = $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver();
|
||||
|
||||
// Email restriction
|
||||
$this->ensureEmailDomainAllowed($userEmail);
|
||||
@@ -94,6 +89,12 @@ class RegistrationService
|
||||
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login');
|
||||
}
|
||||
|
||||
/** @var ?bool $shouldRegister */
|
||||
$shouldRegister = Theme::dispatch(ThemeEvents::AUTH_PRE_REGISTER, $authSystem, $userData);
|
||||
if ($shouldRegister === false) {
|
||||
throw new UserRegistrationException(trans('errors.auth_pre_register_theme_prevention'), '/login');
|
||||
}
|
||||
|
||||
// Create the user
|
||||
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
|
||||
$newUser->attachDefaultRole();
|
||||
@@ -104,7 +105,7 @@ class RegistrationService
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
|
||||
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver(), $newUser);
|
||||
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $authSystem, $newUser);
|
||||
|
||||
// Start email confirmation flow if required
|
||||
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
|
||||
@@ -138,7 +139,7 @@ class RegistrationService
|
||||
}
|
||||
|
||||
$restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
|
||||
$userEmailDomain = $domain = mb_substr(mb_strrchr($userEmail, '@'), 1);
|
||||
$userEmailDomain = mb_substr(mb_strrchr($userEmail, '@'), 1);
|
||||
if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
|
||||
$redirect = $this->registrationAllowed() ? '/register' : '/login';
|
||||
|
||||
|
||||
@@ -21,19 +21,13 @@ use OneLogin\Saml2\ValidationError;
|
||||
class Saml2Service
|
||||
{
|
||||
protected array $config;
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
protected GroupSyncService $groupSyncService;
|
||||
|
||||
public function __construct(
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService,
|
||||
GroupSyncService $groupSyncService
|
||||
protected RegistrationService $registrationService,
|
||||
protected LoginService $loginService,
|
||||
protected GroupSyncService $groupSyncService
|
||||
) {
|
||||
$this->config = config('saml2');
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
$this->groupSyncService = $groupSyncService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,20 +48,23 @@ class Saml2Service
|
||||
|
||||
/**
|
||||
* Initiate a logout flow.
|
||||
* Returns the SAML2 request ID, and the URL to redirect the user to.
|
||||
*
|
||||
* @throws Error
|
||||
* @returns array{url: string, id: ?string}
|
||||
*/
|
||||
public function logout(User $user): array
|
||||
{
|
||||
$toolKit = $this->getToolkit();
|
||||
$returnRoute = url('/');
|
||||
$sessionIndex = session()->get('saml2_session_index');
|
||||
$returnUrl = url($this->loginService->logout());
|
||||
|
||||
try {
|
||||
$url = $toolKit->logout(
|
||||
$returnRoute,
|
||||
$returnUrl,
|
||||
[],
|
||||
$user->email,
|
||||
session()->get('saml2_session_index'),
|
||||
$sessionIndex,
|
||||
true,
|
||||
Constants::NAMEID_EMAIL_ADDRESS
|
||||
);
|
||||
@@ -77,8 +74,7 @@ class Saml2Service
|
||||
throw $error;
|
||||
}
|
||||
|
||||
$this->actionLogout();
|
||||
$url = '/';
|
||||
$url = $returnUrl;
|
||||
$id = null;
|
||||
}
|
||||
|
||||
@@ -128,7 +124,7 @@ class Saml2Service
|
||||
*
|
||||
* @throws Error
|
||||
*/
|
||||
public function processSlsResponse(?string $requestId): ?string
|
||||
public function processSlsResponse(?string $requestId): string
|
||||
{
|
||||
$toolkit = $this->getToolkit();
|
||||
|
||||
@@ -137,7 +133,7 @@ class Saml2Service
|
||||
// value so that the exact encoding format is matched when checking the signature.
|
||||
// This is primarily due to ADFS encoding query params with lowercase percent encoding while
|
||||
// PHP (And most other sensible providers) standardise on uppercase.
|
||||
$redirect = $toolkit->processSLO(true, $requestId, true, null, true);
|
||||
$samlRedirect = $toolkit->processSLO(true, $requestId, true, null, true);
|
||||
$errors = $toolkit->getErrors();
|
||||
|
||||
if (!empty($errors)) {
|
||||
@@ -146,18 +142,9 @@ class Saml2Service
|
||||
);
|
||||
}
|
||||
|
||||
$this->actionLogout();
|
||||
$defaultBookStackRedirect = $this->loginService->logout();
|
||||
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do the required actions to log a user out.
|
||||
*/
|
||||
protected function actionLogout()
|
||||
{
|
||||
auth()->logout();
|
||||
session()->invalidate();
|
||||
return $samlRedirect ?? $defaultBookStackRedirect;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -357,6 +344,10 @@ class Saml2Service
|
||||
$userDetails = $this->getUserDetails($samlID, $samlAttributes);
|
||||
$isLoggedIn = auth()->check();
|
||||
|
||||
if ($this->shouldSyncGroups()) {
|
||||
$userDetails['groups'] = $this->getUserGroups($samlAttributes);
|
||||
}
|
||||
|
||||
if ($this->config['dump_user_details']) {
|
||||
throw new JsonDebugException([
|
||||
'id_from_idp' => $samlID,
|
||||
@@ -379,13 +370,8 @@ class Saml2Service
|
||||
$userDetails['external_id']
|
||||
);
|
||||
|
||||
if ($user === null) {
|
||||
throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
|
||||
}
|
||||
|
||||
if ($this->shouldSyncGroups()) {
|
||||
$groups = $this->getUserGroups($samlAttributes);
|
||||
$this->groupSyncService->syncUserWithFoundGroups($user, $groups, $this->config['remove_from_groups']);
|
||||
$this->groupSyncService->syncUserWithFoundGroups($user, $userDetails['groups'], $this->config['remove_from_groups']);
|
||||
}
|
||||
|
||||
$this->loginService->login($user, 'saml2');
|
||||
|
||||
@@ -2,69 +2,24 @@
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Auth\Access\handler;
|
||||
use BookStack\Exceptions\SocialDriverNotConfigured;
|
||||
use BookStack\Exceptions\SocialSignInAccountNotUsed;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Contracts\Factory as Socialite;
|
||||
use Laravel\Socialite\Contracts\Provider;
|
||||
use Laravel\Socialite\Contracts\User as SocialUser;
|
||||
use Laravel\Socialite\Two\GoogleProvider;
|
||||
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
||||
class SocialAuthService
|
||||
{
|
||||
/**
|
||||
* The core socialite library used.
|
||||
*
|
||||
* @var Socialite
|
||||
*/
|
||||
protected $socialite;
|
||||
|
||||
/**
|
||||
* @var LoginService
|
||||
*/
|
||||
protected $loginService;
|
||||
|
||||
/**
|
||||
* The default built-in social drivers we support.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $validSocialDrivers = [
|
||||
'google',
|
||||
'github',
|
||||
'facebook',
|
||||
'slack',
|
||||
'twitter',
|
||||
'azure',
|
||||
'okta',
|
||||
'gitlab',
|
||||
'twitch',
|
||||
'discord',
|
||||
];
|
||||
|
||||
/**
|
||||
* Callbacks to run when configuring a social driver
|
||||
* for an initial redirect action.
|
||||
* Array is keyed by social driver name.
|
||||
* Callbacks are passed an instance of the driver.
|
||||
*
|
||||
* @var array<string, callable>
|
||||
*/
|
||||
protected $configureForRedirectCallbacks = [];
|
||||
|
||||
/**
|
||||
* SocialAuthService constructor.
|
||||
*/
|
||||
public function __construct(Socialite $socialite, LoginService $loginService)
|
||||
{
|
||||
$this->socialite = $socialite;
|
||||
$this->loginService = $loginService;
|
||||
public function __construct(
|
||||
protected Socialite $socialite,
|
||||
protected LoginService $loginService,
|
||||
protected SocialDriverManager $driverManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,9 +29,10 @@ class SocialAuthService
|
||||
*/
|
||||
public function startLogIn(string $socialDriver): RedirectResponse
|
||||
{
|
||||
$driver = $this->validateDriver($socialDriver);
|
||||
$socialDriver = trim(strtolower($socialDriver));
|
||||
$this->driverManager->ensureDriverActive($socialDriver);
|
||||
|
||||
return $this->getDriverForRedirect($driver)->redirect();
|
||||
return $this->getDriverForRedirect($socialDriver)->redirect();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,9 +42,10 @@ class SocialAuthService
|
||||
*/
|
||||
public function startRegister(string $socialDriver): RedirectResponse
|
||||
{
|
||||
$driver = $this->validateDriver($socialDriver);
|
||||
$socialDriver = trim(strtolower($socialDriver));
|
||||
$this->driverManager->ensureDriverActive($socialDriver);
|
||||
|
||||
return $this->getDriverForRedirect($driver)->redirect();
|
||||
return $this->getDriverForRedirect($socialDriver)->redirect();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,9 +76,10 @@ class SocialAuthService
|
||||
*/
|
||||
public function getSocialUser(string $socialDriver): SocialUser
|
||||
{
|
||||
$driver = $this->validateDriver($socialDriver);
|
||||
$socialDriver = trim(strtolower($socialDriver));
|
||||
$this->driverManager->ensureDriverActive($socialDriver);
|
||||
|
||||
return $this->socialite->driver($driver)->user();
|
||||
return $this->socialite->driver($socialDriver)->user();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,6 +89,7 @@ class SocialAuthService
|
||||
*/
|
||||
public function handleLoginCallback(string $socialDriver, SocialUser $socialUser)
|
||||
{
|
||||
$socialDriver = trim(strtolower($socialDriver));
|
||||
$socialId = $socialUser->getId();
|
||||
|
||||
// Get any attached social accounts or users
|
||||
@@ -154,21 +113,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.
|
||||
@@ -181,75 +140,11 @@ class SocialAuthService
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the social driver is correct and supported.
|
||||
*
|
||||
* @throws SocialDriverNotConfigured
|
||||
* Get the social driver manager used by this service.
|
||||
*/
|
||||
protected function validateDriver(string $socialDriver): string
|
||||
public function drivers(): SocialDriverManager
|
||||
{
|
||||
$driver = trim(strtolower($socialDriver));
|
||||
|
||||
if (!in_array($driver, $this->validSocialDrivers)) {
|
||||
abort(404, trans('errors.social_driver_not_found'));
|
||||
}
|
||||
|
||||
if (!$this->checkDriverConfigured($driver)) {
|
||||
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($socialDriver)]));
|
||||
}
|
||||
|
||||
return $driver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a social driver has been configured correctly.
|
||||
*/
|
||||
protected function checkDriverConfigured(string $driver): bool
|
||||
{
|
||||
$lowerName = strtolower($driver);
|
||||
$configPrefix = 'services.' . $lowerName . '.';
|
||||
$config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
|
||||
|
||||
return !in_array(false, $config) && !in_array(null, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the names of the active social drivers.
|
||||
*/
|
||||
public function getActiveDrivers(): array
|
||||
{
|
||||
$activeDrivers = [];
|
||||
|
||||
foreach ($this->validSocialDrivers as $driverKey) {
|
||||
if ($this->checkDriverConfigured($driverKey)) {
|
||||
$activeDrivers[$driverKey] = $this->getDriverName($driverKey);
|
||||
}
|
||||
}
|
||||
|
||||
return $activeDrivers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the presentational name for a driver.
|
||||
*/
|
||||
public function getDriverName(string $driver): string
|
||||
{
|
||||
return config('services.' . strtolower($driver) . '.name');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current config for the given driver allows auto-registration.
|
||||
*/
|
||||
public function driverAutoRegisterEnabled(string $driver): bool
|
||||
{
|
||||
return config('services.' . strtolower($driver) . '.auto_register') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current config for the given driver allow email address auto-confirmation.
|
||||
*/
|
||||
public function driverAutoConfirmEmailEnabled(string $driver): bool
|
||||
{
|
||||
return config('services.' . strtolower($driver) . '.auto_confirm') === true;
|
||||
return $this->driverManager;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,33 +178,8 @@ class SocialAuthService
|
||||
$driver->with(['prompt' => 'select_account']);
|
||||
}
|
||||
|
||||
if (isset($this->configureForRedirectCallbacks[$driverName])) {
|
||||
$this->configureForRedirectCallbacks[$driverName]($driver);
|
||||
}
|
||||
$this->driverManager->getConfigureForRedirectCallback($driverName)($driver);
|
||||
|
||||
return $driver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom socialite driver to be used.
|
||||
* Driver name should be lower_snake_case.
|
||||
* Config array should mirror the structure of a service
|
||||
* within the `Config/services.php` file.
|
||||
* Handler should be a Class@method handler to the SocialiteWasCalled event.
|
||||
*/
|
||||
public function addSocialDriver(
|
||||
string $driverName,
|
||||
array $config,
|
||||
string $socialiteHandler,
|
||||
callable $configureForRedirect = null
|
||||
) {
|
||||
$this->validSocialDrivers[] = $driverName;
|
||||
config()->set('services.' . $driverName, $config);
|
||||
config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
|
||||
config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
|
||||
Event::listen(SocialiteWasCalled::class, $socialiteHandler);
|
||||
if (!is_null($configureForRedirect)) {
|
||||
$this->configureForRedirectCallbacks[$driverName] = $configureForRedirect;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
147
app/Access/SocialDriverManager.php
Normal file
147
app/Access/SocialDriverManager.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Exceptions\SocialDriverNotConfigured;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Str;
|
||||
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||
|
||||
class SocialDriverManager
|
||||
{
|
||||
/**
|
||||
* The default built-in social drivers we support.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $validDrivers = [
|
||||
'google',
|
||||
'github',
|
||||
'facebook',
|
||||
'slack',
|
||||
'twitter',
|
||||
'azure',
|
||||
'okta',
|
||||
'gitlab',
|
||||
'twitch',
|
||||
'discord',
|
||||
];
|
||||
|
||||
/**
|
||||
* Callbacks to run when configuring a social driver
|
||||
* for an initial redirect action.
|
||||
* Array is keyed by social driver name.
|
||||
* Callbacks are passed an instance of the driver.
|
||||
*
|
||||
* @var array<string, callable>
|
||||
*/
|
||||
protected array $configureForRedirectCallbacks = [];
|
||||
|
||||
/**
|
||||
* Check if the current config for the given driver allows auto-registration.
|
||||
*/
|
||||
public function isAutoRegisterEnabled(string $driver): bool
|
||||
{
|
||||
return $this->getDriverConfigProperty($driver, 'auto_register') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current config for the given driver allow email address auto-confirmation.
|
||||
*/
|
||||
public function isAutoConfirmEmailEnabled(string $driver): bool
|
||||
{
|
||||
return $this->getDriverConfigProperty($driver, 'auto_confirm') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the names of the active social drivers, keyed by driver id.
|
||||
* @returns array<string, string>
|
||||
*/
|
||||
public function getActive(): array
|
||||
{
|
||||
$activeDrivers = [];
|
||||
|
||||
foreach ($this->validDrivers as $driverKey) {
|
||||
if ($this->checkDriverConfigured($driverKey)) {
|
||||
$activeDrivers[$driverKey] = $this->getName($driverKey);
|
||||
}
|
||||
}
|
||||
|
||||
return $activeDrivers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configure-for-redirect callback for the given driver.
|
||||
* This is a callable that allows modification of the driver at redirect time.
|
||||
* Commonly used to perform custom dynamic configuration where required.
|
||||
* The callback is passed a \Laravel\Socialite\Contracts\Provider instance.
|
||||
*/
|
||||
public function getConfigureForRedirectCallback(string $driver): callable
|
||||
{
|
||||
return $this->configureForRedirectCallbacks[$driver] ?? (fn() => true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom socialite driver to be used.
|
||||
* Driver name should be lower_snake_case.
|
||||
* Config array should mirror the structure of a service
|
||||
* within the `Config/services.php` file.
|
||||
* Handler should be a Class@method handler to the SocialiteWasCalled event.
|
||||
*/
|
||||
public function addSocialDriver(
|
||||
string $driverName,
|
||||
array $config,
|
||||
string $socialiteHandler,
|
||||
callable $configureForRedirect = null
|
||||
) {
|
||||
$this->validDrivers[] = $driverName;
|
||||
config()->set('services.' . $driverName, $config);
|
||||
config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
|
||||
config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
|
||||
Event::listen(SocialiteWasCalled::class, $socialiteHandler);
|
||||
if (!is_null($configureForRedirect)) {
|
||||
$this->configureForRedirectCallbacks[$driverName] = $configureForRedirect;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the presentational name for a driver.
|
||||
*/
|
||||
protected function getName(string $driver): string
|
||||
{
|
||||
return $this->getDriverConfigProperty($driver, 'name') ?? '';
|
||||
}
|
||||
|
||||
protected function getDriverConfigProperty(string $driver, string $property): mixed
|
||||
{
|
||||
return config("services.{$driver}.{$property}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the social driver is correct and supported.
|
||||
*
|
||||
* @throws SocialDriverNotConfigured
|
||||
*/
|
||||
public function ensureDriverActive(string $driverName): void
|
||||
{
|
||||
if (!in_array($driverName, $this->validDrivers)) {
|
||||
abort(404, trans('errors.social_driver_not_found'));
|
||||
}
|
||||
|
||||
if (!$this->checkDriverConfigured($driverName)) {
|
||||
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($driverName)]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a social driver has been configured correctly.
|
||||
*/
|
||||
protected function checkDriverConfigured(string $driver): bool
|
||||
{
|
||||
$lowerName = strtolower($driver);
|
||||
$configPrefix = 'services.' . $lowerName . '.';
|
||||
$config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
|
||||
|
||||
return !in_array(false, $config) && !in_array(null, $config);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\MixedEntityListLoader;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -14,11 +15,10 @@ use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class ActivityQueries
|
||||
{
|
||||
protected PermissionApplicator $permissions;
|
||||
|
||||
public function __construct(PermissionApplicator $permissions)
|
||||
{
|
||||
$this->permissions = $permissions;
|
||||
public function __construct(
|
||||
protected PermissionApplicator $permissions,
|
||||
protected MixedEntityListLoader $listLoader,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,11 +29,13 @@ class ActivityQueries
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->with(['user', 'entity'])
|
||||
->with(['user'])
|
||||
->skip($count * $page)
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
$this->listLoader->loadIntoRelations($activityList->all(), 'entity', false);
|
||||
|
||||
return $this->filterSimilar($activityList);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ class ActivityType
|
||||
const BOOKSHELF_DELETE = 'bookshelf_delete';
|
||||
|
||||
const COMMENTED_ON = 'commented_on';
|
||||
const COMMENT_CREATE = 'comment_create';
|
||||
const COMMENT_UPDATE = 'comment_update';
|
||||
const COMMENT_DELETE = 'comment_delete';
|
||||
|
||||
const PERMISSIONS_UPDATE = 'permissions_update';
|
||||
|
||||
const REVISION_RESTORE = 'revision_restore';
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace BookStack\Activity;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Facades\Activity as ActivityService;
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
use BookStack\Util\HtmlDescriptionFilter;
|
||||
|
||||
class CommentRepo
|
||||
{
|
||||
@@ -20,19 +20,19 @@ class CommentRepo
|
||||
/**
|
||||
* Create a new comment on an entity.
|
||||
*/
|
||||
public function create(Entity $entity, string $text, ?int $parent_id): Comment
|
||||
public function create(Entity $entity, string $html, ?int $parent_id): Comment
|
||||
{
|
||||
$userId = user()->id;
|
||||
$comment = new Comment();
|
||||
|
||||
$comment->text = $text;
|
||||
$comment->html = $this->commentToHtml($text);
|
||||
$comment->html = HtmlDescriptionFilter::filterFromString($html);
|
||||
$comment->created_by = $userId;
|
||||
$comment->updated_by = $userId;
|
||||
$comment->local_id = $this->getNextLocalId($entity);
|
||||
$comment->parent_id = $parent_id;
|
||||
|
||||
$entity->comments()->save($comment);
|
||||
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
|
||||
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
|
||||
|
||||
return $comment;
|
||||
@@ -41,13 +41,14 @@ class CommentRepo
|
||||
/**
|
||||
* Update an existing comment.
|
||||
*/
|
||||
public function update(Comment $comment, string $text): Comment
|
||||
public function update(Comment $comment, string $html): Comment
|
||||
{
|
||||
$comment->updated_by = user()->id;
|
||||
$comment->text = $text;
|
||||
$comment->html = $this->commentToHtml($text);
|
||||
$comment->html = HtmlDescriptionFilter::filterFromString($html);
|
||||
$comment->save();
|
||||
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
@@ -57,20 +58,8 @@ class CommentRepo
|
||||
public function delete(Comment $comment): void
|
||||
{
|
||||
$comment->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given comment Markdown to HTML.
|
||||
*/
|
||||
public function commentToHtml(string $commentText): string
|
||||
{
|
||||
$converter = new CommonMarkConverter([
|
||||
'html_input' => 'strip',
|
||||
'max_nesting_level' => 10,
|
||||
'allow_unsafe_links' => false,
|
||||
]);
|
||||
|
||||
return $converter->convert($commentText);
|
||||
ActivityService::add(ActivityType::COMMENT_DELETE, $comment);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\CommentRepo;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -11,7 +11,8 @@ use Illuminate\Validation\ValidationException;
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected CommentRepo $commentRepo
|
||||
protected CommentRepo $commentRepo,
|
||||
protected PageQueries $pageQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -22,12 +23,12 @@ class CommentController extends Controller
|
||||
*/
|
||||
public function savePageComment(Request $request, int $pageId)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'text' => ['required', 'string'],
|
||||
$input = $this->validate($request, [
|
||||
'html' => ['required', 'string'],
|
||||
'parent_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$page = Page::visible()->find($pageId);
|
||||
$page = $this->pageQueries->findVisibleById($pageId);
|
||||
if ($page === null) {
|
||||
return response('Not found', 404);
|
||||
}
|
||||
@@ -39,7 +40,7 @@ class CommentController extends Controller
|
||||
|
||||
// Create a new comment.
|
||||
$this->checkPermission('comment-create-all');
|
||||
$comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id'));
|
||||
$comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null);
|
||||
|
||||
return view('comments.comment-branch', [
|
||||
'readOnly' => false,
|
||||
@@ -57,17 +58,20 @@ class CommentController extends Controller
|
||||
*/
|
||||
public function update(Request $request, int $commentId)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'text' => ['required', 'string'],
|
||||
$input = $this->validate($request, [
|
||||
'html' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$comment = $this->commentRepo->getById($commentId);
|
||||
$this->checkOwnablePermission('page-view', $comment->entity);
|
||||
$this->checkOwnablePermission('comment-update', $comment);
|
||||
|
||||
$comment = $this->commentRepo->update($comment, $request->get('text'));
|
||||
$comment = $this->commentRepo->update($comment, $input['html']);
|
||||
|
||||
return view('comments.comment', ['comment' => $comment, 'readOnly' => false]);
|
||||
return view('comments.comment', [
|
||||
'comment' => $comment,
|
||||
'readOnly' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,23 +2,26 @@
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\Models\Favouritable;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Queries\TopFavourites;
|
||||
use BookStack\Entities\Queries\QueryTopFavourites;
|
||||
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.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
public function index(Request $request, QueryTopFavourites $topFavourites)
|
||||
{
|
||||
$viewCount = 20;
|
||||
$page = intval($request->get('page', 1));
|
||||
$favourites = (new TopFavourites())->run($viewCount + 1, (($page - 1) * $viewCount));
|
||||
$favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount));
|
||||
|
||||
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
|
||||
|
||||
@@ -36,16 +39,17 @@ 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();
|
||||
return redirect($entity->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,48 +57,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;
|
||||
return redirect($entity->getUrl());
|
||||
}
|
||||
}
|
||||
|
||||
29
app/Activity/Controllers/WatchController.php
Normal file
29
app/Activity/Controllers/WatchController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Tools\MixedEntityRequestHelper;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WatchController extends Controller
|
||||
{
|
||||
public function update(Request $request, MixedEntityRequestHelper $entityHelper)
|
||||
{
|
||||
$this->checkPermission('receive-notifications');
|
||||
$this->preventGuestAccess();
|
||||
|
||||
$requestData = $this->validate($request, array_merge([
|
||||
'level' => ['required', 'string'],
|
||||
], $entityHelper->validationRules()));
|
||||
|
||||
$watchable = $entityHelper->getVisibleEntityFromRequestData($requestData);
|
||||
$watchOptions = new UserEntityWatchOptions(user(), $watchable);
|
||||
$watchOptions->updateLevelByName($requestData['level']);
|
||||
|
||||
$this->showSuccessNotification(trans('activities.watch_update_level_notification'));
|
||||
|
||||
return redirect($watchable->getUrl());
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,15 @@ 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;
|
||||
use Illuminate\Bus\Queueable;
|
||||
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
|
||||
@@ -24,27 +25,23 @@ class DispatchWebhookJob implements ShouldQueue
|
||||
use SerializesModels;
|
||||
|
||||
protected Webhook $webhook;
|
||||
protected string $event;
|
||||
protected User $initiator;
|
||||
protected int $initiatedTime;
|
||||
|
||||
/**
|
||||
* @var string|Loggable
|
||||
*/
|
||||
protected $detail;
|
||||
protected array $webhookData;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Webhook $webhook, string $event, $detail)
|
||||
public function __construct(Webhook $webhook, string $event, Loggable|string $detail)
|
||||
{
|
||||
$this->webhook = $webhook;
|
||||
$this->event = $event;
|
||||
$this->detail = $detail;
|
||||
$this->initiator = user();
|
||||
$this->initiatedTime = time();
|
||||
|
||||
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $event, $this->webhook, $detail, $this->initiator, $this->initiatedTime);
|
||||
$this->webhookData = $themeResponse ?? WebhookFormatter::getDefault($event, $this->webhook, $detail, $this->initiator, $this->initiatedTime)->format();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,25 +49,28 @@ class DispatchWebhookJob implements ShouldQueue
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle()
|
||||
public function handle(HttpRequestService $http)
|
||||
{
|
||||
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime);
|
||||
$webhookData = $themeResponse ?? WebhookFormatter::getDefault($this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime)->format();
|
||||
$lastError = null;
|
||||
|
||||
try {
|
||||
$response = Http::asJson()
|
||||
->withOptions(['allow_redirects' => ['strict' => true]])
|
||||
->timeout($this->webhook->timeout)
|
||||
->post($this->webhook->endpoint, $webhookData);
|
||||
} catch (\Exception $exception) {
|
||||
$lastError = $exception->getMessage();
|
||||
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
|
||||
}
|
||||
(new SsrUrlValidator())->ensureAllowed($this->webhook->endpoint);
|
||||
|
||||
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()}");
|
||||
$client = $http->buildClient($this->webhook->timeout, [
|
||||
'connect_timeout' => 10,
|
||||
'allow_redirects' => ['strict' => true],
|
||||
]);
|
||||
|
||||
$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();
|
||||
|
||||
@@ -9,6 +9,7 @@ use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,22 +4,28 @@ namespace BookStack\Activity\Models;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $text
|
||||
* @property string $text - Deprecated & now unused (#4821)
|
||||
* @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
|
||||
class Comment extends Model implements Loggable
|
||||
{
|
||||
use HasFactory;
|
||||
use HasCreatorAndUpdater;
|
||||
|
||||
protected $fillable = ['text', 'parent_id'];
|
||||
protected $fillable = ['parent_id'];
|
||||
protected $appends = ['created', 'updated'];
|
||||
|
||||
/**
|
||||
@@ -30,6 +36,16 @@ class Comment extends Model
|
||||
return $this->morphTo('entity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent comment this is in reply to (if existing).
|
||||
*/
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
|
||||
->where('entity_type', '=', $this->entity_type)
|
||||
->where('entity_id', '=', $this->entity_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a comment has been updated since creation.
|
||||
*/
|
||||
@@ -40,21 +56,27 @@ class Comment extends Model
|
||||
|
||||
/**
|
||||
* Get created date as a relative diff.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getCreatedAttribute()
|
||||
public function getCreatedAttribute(): string
|
||||
{
|
||||
return $this->created_at->diffForHumans();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get updated date as a relative diff.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getUpdatedAttribute()
|
||||
public function getUpdatedAttribute(): string
|
||||
{
|
||||
return $this->updated_at->diffForHumans();
|
||||
}
|
||||
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
|
||||
}
|
||||
|
||||
public function safeHtml(): string
|
||||
{
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
45
app/Activity/Models/Watch.php
Normal file
45
app/Activity/Models/Watch.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Models;
|
||||
|
||||
use BookStack\Activity\WatchLevels;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property int $watchable_id
|
||||
* @property string $watchable_type
|
||||
* @property int $level
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class Watch extends Model
|
||||
{
|
||||
protected $guarded = [];
|
||||
|
||||
public function watchable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'watchable_id')
|
||||
->whereColumn('watches.watchable_type', '=', 'joint_permissions.entity_type');
|
||||
}
|
||||
|
||||
public function getLevelName(): string
|
||||
{
|
||||
return WatchLevels::levelValueToName($this->level);
|
||||
}
|
||||
|
||||
public function ignoring(): bool
|
||||
{
|
||||
return $this->level === WatchLevels::IGNORE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Handlers;
|
||||
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\User;
|
||||
|
||||
abstract class BaseNotificationHandler implements NotificationHandler
|
||||
{
|
||||
/**
|
||||
* @param class-string<BaseActivityNotification> $notification
|
||||
* @param int[] $userIds
|
||||
*/
|
||||
protected function sendNotificationToUserIds(string $notification, array $userIds, User $initiator, string|Loggable $detail, Entity $relatedModel): void
|
||||
{
|
||||
$users = User::query()->whereIn('id', array_unique($userIds))->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
// Prevent sending to the user that initiated the activity
|
||||
if ($user->id === $initiator->id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prevent sending of the user does not have notification permissions
|
||||
if (!$user->can('receive-notifications')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Prevent sending if the user does not have access to the related content
|
||||
$permissions = new PermissionApplicator($user);
|
||||
if (!$permissions->checkOwnableUserAccess($relatedModel, 'view')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Send the notification
|
||||
$user->notify(new $notification($detail, $initiator));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Handlers;
|
||||
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Notifications\Messages\CommentCreationNotification;
|
||||
use BookStack\Activity\Tools\EntityWatchers;
|
||||
use BookStack\Activity\WatchLevels;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Settings\UserNotificationPreferences;
|
||||
use BookStack\Users\Models\User;
|
||||
|
||||
class CommentCreationNotificationHandler extends BaseNotificationHandler
|
||||
{
|
||||
public function handle(Activity $activity, Loggable|string $detail, User $user): void
|
||||
{
|
||||
if (!($detail instanceof Comment)) {
|
||||
throw new \InvalidArgumentException("Detail for comment creation notifications must be a comment");
|
||||
}
|
||||
|
||||
// Main watchers
|
||||
/** @var Page $page */
|
||||
$page = $detail->entity;
|
||||
$watchers = new EntityWatchers($page, WatchLevels::COMMENTS);
|
||||
$watcherIds = $watchers->getWatcherUserIds();
|
||||
|
||||
// Page owner if user preferences allow
|
||||
if (!$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
|
||||
$userNotificationPrefs = new UserNotificationPreferences($page->ownedBy);
|
||||
if ($userNotificationPrefs->notifyOnOwnPageComments()) {
|
||||
$watcherIds[] = $page->owned_by;
|
||||
}
|
||||
}
|
||||
|
||||
// Parent comment creator if preferences allow
|
||||
$parentComment = $detail->parent()->first();
|
||||
if ($parentComment && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
|
||||
$parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy);
|
||||
if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) {
|
||||
$watcherIds[] = $parentComment->created_by;
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendNotificationToUserIds(CommentCreationNotification::class, $watcherIds, $user, $detail, $page);
|
||||
}
|
||||
}
|
||||
17
app/Activity/Notifications/Handlers/NotificationHandler.php
Normal file
17
app/Activity/Notifications/Handlers/NotificationHandler.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Handlers;
|
||||
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Users\Models\User;
|
||||
|
||||
interface NotificationHandler
|
||||
{
|
||||
/**
|
||||
* Run this handler.
|
||||
* Provides the activity, related activity detail/model
|
||||
* along with the user that triggered the activity.
|
||||
*/
|
||||
public function handle(Activity $activity, string|Loggable $detail, User $user): void;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Handlers;
|
||||
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Notifications\Messages\PageCreationNotification;
|
||||
use BookStack\Activity\Tools\EntityWatchers;
|
||||
use BookStack\Activity\WatchLevels;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Users\Models\User;
|
||||
|
||||
class PageCreationNotificationHandler extends BaseNotificationHandler
|
||||
{
|
||||
public function handle(Activity $activity, Loggable|string $detail, User $user): void
|
||||
{
|
||||
if (!($detail instanceof Page)) {
|
||||
throw new \InvalidArgumentException("Detail for page create notifications must be a page");
|
||||
}
|
||||
|
||||
$watchers = new EntityWatchers($detail, WatchLevels::NEW);
|
||||
$this->sendNotificationToUserIds(PageCreationNotification::class, $watchers->getWatcherUserIds(), $user, $detail, $detail);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Handlers;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Notifications\Messages\PageUpdateNotification;
|
||||
use BookStack\Activity\Tools\EntityWatchers;
|
||||
use BookStack\Activity\WatchLevels;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Settings\UserNotificationPreferences;
|
||||
use BookStack\Users\Models\User;
|
||||
|
||||
class PageUpdateNotificationHandler extends BaseNotificationHandler
|
||||
{
|
||||
public function handle(Activity $activity, Loggable|string $detail, User $user): void
|
||||
{
|
||||
if (!($detail instanceof Page)) {
|
||||
throw new \InvalidArgumentException("Detail for page update notifications must be a page");
|
||||
}
|
||||
|
||||
// Get last update from activity
|
||||
$lastUpdate = $detail->activity()
|
||||
->where('type', '=', ActivityType::PAGE_UPDATE)
|
||||
->where('id', '!=', $activity->id)
|
||||
->latest('created_at')
|
||||
->first();
|
||||
|
||||
// Return if the same user has already updated the page in the last 15 mins
|
||||
if ($lastUpdate && $lastUpdate->user_id === $user->id) {
|
||||
if ($lastUpdate->created_at->gt(now()->subMinutes(15))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get active watchers
|
||||
$watchers = new EntityWatchers($detail, WatchLevels::UPDATES);
|
||||
$watcherIds = $watchers->getWatcherUserIds();
|
||||
|
||||
// Add page owner if preferences allow
|
||||
if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
|
||||
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
|
||||
if ($userNotificationPrefs->notifyOnOwnPageChanges()) {
|
||||
$watcherIds[] = $detail->owned_by;
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendNotificationToUserIds(PageUpdateNotification::class, $watcherIds, $user, $detail, $detail);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\MessageParts;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* A link to a specific entity in the system, with the text showing its name.
|
||||
*/
|
||||
class EntityLinkMessageLine implements Htmlable, Stringable
|
||||
{
|
||||
public function __construct(
|
||||
protected Entity $entity,
|
||||
protected int $nameLength = 120,
|
||||
) {
|
||||
}
|
||||
|
||||
public function toHtml(): string
|
||||
{
|
||||
return '<a href="' . e($this->entity->getUrl()) . '">' . e($this->entity->getShortName($this->nameLength)) . '</a>';
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return "{$this->entity->getShortName($this->nameLength)} ({$this->entity->getUrl()})";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\MessageParts;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* A link to a specific entity in the system, with the text showing its name.
|
||||
*/
|
||||
class EntityPathMessageLine implements Htmlable, Stringable
|
||||
{
|
||||
/**
|
||||
* @var EntityLinkMessageLine[]
|
||||
*/
|
||||
protected array $entityLinks;
|
||||
|
||||
public function __construct(
|
||||
protected array $entities
|
||||
) {
|
||||
$this->entityLinks = array_map(fn (Entity $entity) => new EntityLinkMessageLine($entity, 24), $this->entities);
|
||||
}
|
||||
|
||||
public function toHtml(): string
|
||||
{
|
||||
$entityHtmls = array_map(fn (EntityLinkMessageLine $line) => $line->toHtml(), $this->entityLinks);
|
||||
return implode(' > ', $entityHtmls);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return implode(' > ', $this->entityLinks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\MessageParts;
|
||||
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* A line of text with linked text included, intended for use
|
||||
* in MailMessages. The line should have a ':link' placeholder for
|
||||
* where the link should be inserted within the line.
|
||||
*/
|
||||
class LinkedMailMessageLine implements Htmlable, Stringable
|
||||
{
|
||||
public function __construct(
|
||||
protected string $url,
|
||||
protected string $line,
|
||||
protected string $linkText,
|
||||
) {
|
||||
}
|
||||
|
||||
public function toHtml(): string
|
||||
{
|
||||
$link = '<a href="' . e($this->url) . '">' . e($this->linkText) . '</a>';
|
||||
return str_replace(':link', $link, e($this->line));
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
$link = "{$this->linkText} ({$this->url})";
|
||||
return str_replace(':link', $link, $this->line);
|
||||
}
|
||||
}
|
||||
36
app/Activity/Notifications/MessageParts/ListMessageLine.php
Normal file
36
app/Activity/Notifications/MessageParts/ListMessageLine.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\MessageParts;
|
||||
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* A bullet point list of content, where the keys of the given list array
|
||||
* are bolded header elements, and the values follow.
|
||||
*/
|
||||
class ListMessageLine implements Htmlable, Stringable
|
||||
{
|
||||
public function __construct(
|
||||
protected array $list
|
||||
) {
|
||||
}
|
||||
|
||||
public function toHtml(): string
|
||||
{
|
||||
$list = [];
|
||||
foreach ($this->list as $header => $content) {
|
||||
$list[] = '<strong>' . e($header) . '</strong> ' . e($content);
|
||||
}
|
||||
return implode("<br>\n", $list);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
$list = [];
|
||||
foreach ($this->list as $header => $content) {
|
||||
$list[] = $header . ' ' . $content;
|
||||
}
|
||||
return implode("\n", $list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Messages;
|
||||
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Notifications\MessageParts\EntityPathMessageLine;
|
||||
use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine;
|
||||
use BookStack\App\MailNotification;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Translation\LocaleDefinition;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
|
||||
abstract class BaseActivityNotification extends MailNotification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
protected Loggable|string $detail,
|
||||
protected User $user,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @return array
|
||||
*/
|
||||
public function toArray($notifiable)
|
||||
{
|
||||
return [
|
||||
'activity_detail' => $this->detail,
|
||||
'activity_creator' => $this->user,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the common reason footer line used in mail messages.
|
||||
*/
|
||||
protected function buildReasonFooterLine(LocaleDefinition $locale): LinkedMailMessageLine
|
||||
{
|
||||
return new LinkedMailMessageLine(
|
||||
url('/preferences/notifications'),
|
||||
$locale->trans('notifications.footer_reason'),
|
||||
$locale->trans('notifications.footer_reason_link'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a line which provides the book > chapter path to a page.
|
||||
* Takes into account visibility of these parent items.
|
||||
* Returns null if no path items can be used.
|
||||
*/
|
||||
protected function buildPagePathLine(Page $page, User $notifiable): ?EntityPathMessageLine
|
||||
{
|
||||
$permissions = new PermissionApplicator($notifiable);
|
||||
|
||||
$path = array_filter([$page->book, $page->chapter], function (?Entity $entity) use ($permissions) {
|
||||
return !is_null($entity) && $permissions->checkOwnableUserAccess($entity, 'view');
|
||||
});
|
||||
|
||||
return empty($path) ? null : new EntityPathMessageLine($path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Messages;
|
||||
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
|
||||
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class CommentCreationNotification extends BaseActivityNotification
|
||||
{
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
/** @var Comment $comment */
|
||||
$comment = $this->detail;
|
||||
/** @var Page $page */
|
||||
$page = $comment->entity;
|
||||
|
||||
$locale = $notifiable->getLocale();
|
||||
|
||||
$listLines = array_filter([
|
||||
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
||||
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
||||
$locale->trans('notifications.detail_commenter') => $this->user->name,
|
||||
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
|
||||
]);
|
||||
|
||||
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($listLines))
|
||||
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
|
||||
->line($this->buildReasonFooterLine($locale));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Messages;
|
||||
|
||||
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
|
||||
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class PageCreationNotification extends BaseActivityNotification
|
||||
{
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
/** @var Page $page */
|
||||
$page = $this->detail;
|
||||
|
||||
$locale = $notifiable->getLocale();
|
||||
|
||||
$listLines = array_filter([
|
||||
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
||||
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
||||
$locale->trans('notifications.detail_created_by') => $this->user->name,
|
||||
]);
|
||||
|
||||
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')]))
|
||||
->line(new ListMessageLine($listLines))
|
||||
->action($locale->trans('notifications.action_view_page'), $page->getUrl())
|
||||
->line($this->buildReasonFooterLine($locale));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Messages;
|
||||
|
||||
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
|
||||
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class PageUpdateNotification extends BaseActivityNotification
|
||||
{
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
/** @var Page $page */
|
||||
$page = $this->detail;
|
||||
|
||||
$locale = $notifiable->getLocale();
|
||||
|
||||
$listLines = array_filter([
|
||||
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
||||
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
||||
$locale->trans('notifications.detail_updated_by') => $this->user->name,
|
||||
]);
|
||||
|
||||
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($listLines))
|
||||
->line($locale->trans('notifications.updated_page_debounce'))
|
||||
->action($locale->trans('notifications.action_view_page'), $page->getUrl())
|
||||
->line($this->buildReasonFooterLine($locale));
|
||||
}
|
||||
}
|
||||
52
app/Activity/Notifications/NotificationManager.php
Normal file
52
app/Activity/Notifications/NotificationManager.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
|
||||
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
|
||||
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
|
||||
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
|
||||
use BookStack\Users\Models\User;
|
||||
|
||||
class NotificationManager
|
||||
{
|
||||
/**
|
||||
* @var class-string<NotificationHandler>[]
|
||||
*/
|
||||
protected array $handlers = [];
|
||||
|
||||
public function handle(Activity $activity, string|Loggable $detail, User $user): void
|
||||
{
|
||||
$activityType = $activity->type;
|
||||
$handlersToRun = $this->handlers[$activityType] ?? [];
|
||||
foreach ($handlersToRun as $handlerClass) {
|
||||
/** @var NotificationHandler $handler */
|
||||
$handler = new $handlerClass();
|
||||
$handler->handle($activity, $detail, $user);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<NotificationHandler> $handlerClass
|
||||
*/
|
||||
public function registerHandler(string $activityType, string $handlerClass): void
|
||||
{
|
||||
if (!isset($this->handlers[$activityType])) {
|
||||
$this->handlers[$activityType] = [];
|
||||
}
|
||||
|
||||
if (!in_array($handlerClass, $this->handlers[$activityType])) {
|
||||
$this->handlers[$activityType][] = $handlerClass;
|
||||
}
|
||||
}
|
||||
|
||||
public function loadDefaultHandlers(): void
|
||||
{
|
||||
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
|
||||
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
|
||||
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use BookStack\Activity\DispatchWebhookJob;
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Models\Webhook;
|
||||
use BookStack\Activity\Notifications\NotificationManager;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
@@ -14,12 +15,16 @@ use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ActivityLogger
|
||||
{
|
||||
public function __construct(
|
||||
protected NotificationManager $notifications
|
||||
) {
|
||||
$this->notifications->loadDefaultHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a generic activity event to the database.
|
||||
*
|
||||
* @param string|Loggable $detail
|
||||
*/
|
||||
public function add(string $type, $detail = '')
|
||||
public function add(string $type, string|Loggable $detail = ''): void
|
||||
{
|
||||
$detailToStore = ($detail instanceof Loggable) ? $detail->logDescriptor() : $detail;
|
||||
|
||||
@@ -35,6 +40,7 @@ class ActivityLogger
|
||||
|
||||
$this->setNotification($type);
|
||||
$this->dispatchWebhooks($type, $detail);
|
||||
$this->notifications->handle($activity, $detail, user());
|
||||
Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail);
|
||||
}
|
||||
|
||||
@@ -55,7 +61,7 @@ class ActivityLogger
|
||||
* and instead uses the 'extra' field with the entities name.
|
||||
* Used when an entity is deleted.
|
||||
*/
|
||||
public function removeEntity(Entity $entity)
|
||||
public function removeEntity(Entity $entity): void
|
||||
{
|
||||
$entity->activity()->update([
|
||||
'detail' => $entity->name,
|
||||
@@ -76,10 +82,7 @@ class ActivityLogger
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|Loggable $detail
|
||||
*/
|
||||
protected function dispatchWebhooks(string $type, $detail): void
|
||||
protected function dispatchWebhooks(string $type, string|Loggable $detail): void
|
||||
{
|
||||
$webhooks = Webhook::query()
|
||||
->whereHas('trackedEvents', function (Builder $query) use ($type) {
|
||||
@@ -98,7 +101,7 @@ class ActivityLogger
|
||||
* Log out a failed login attempt, Providing the given username
|
||||
* as part of the message if the '%u' string is used.
|
||||
*/
|
||||
public function logFailedLogin(string $username)
|
||||
public function logFailedLogin(string $username): void
|
||||
{
|
||||
$message = config('logging.failed_login.message');
|
||||
if (!$message) {
|
||||
|
||||
@@ -41,6 +41,17 @@ class CommentTree
|
||||
return $this->tree;
|
||||
}
|
||||
|
||||
public function canUpdateAny(): bool
|
||||
{
|
||||
foreach ($this->comments as $comment) {
|
||||
if (userCan('comment-update', $comment)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Comment[] $comments
|
||||
*/
|
||||
|
||||
86
app/Activity/Tools/EntityWatchers.php
Normal file
86
app/Activity/Tools/EntityWatchers.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Tools;
|
||||
|
||||
use BookStack\Activity\Models\Watch;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class EntityWatchers
|
||||
{
|
||||
/**
|
||||
* @var int[]
|
||||
*/
|
||||
protected array $watchers = [];
|
||||
|
||||
/**
|
||||
* @var int[]
|
||||
*/
|
||||
protected array $ignorers = [];
|
||||
|
||||
public function __construct(
|
||||
protected Entity $entity,
|
||||
protected int $watchLevel,
|
||||
) {
|
||||
$this->build();
|
||||
}
|
||||
|
||||
public function getWatcherUserIds(): array
|
||||
{
|
||||
return $this->watchers;
|
||||
}
|
||||
|
||||
public function isUserIgnoring(int $userId): bool
|
||||
{
|
||||
return in_array($userId, $this->ignorers);
|
||||
}
|
||||
|
||||
protected function build(): void
|
||||
{
|
||||
$watches = $this->getRelevantWatches();
|
||||
|
||||
// Sort before de-duping, so that the order looped below follows book -> chapter -> page ordering
|
||||
usort($watches, function (Watch $watchA, Watch $watchB) {
|
||||
$entityTypeDiff = $watchA->watchable_type <=> $watchB->watchable_type;
|
||||
return $entityTypeDiff === 0 ? ($watchA->user_id <=> $watchB->user_id) : $entityTypeDiff;
|
||||
});
|
||||
|
||||
// De-dupe by user id to get their most relevant level
|
||||
$levelByUserId = [];
|
||||
foreach ($watches as $watch) {
|
||||
$levelByUserId[$watch->user_id] = $watch->level;
|
||||
}
|
||||
|
||||
// Populate the class arrays
|
||||
$this->watchers = array_keys(array_filter($levelByUserId, fn(int $level) => $level >= $this->watchLevel));
|
||||
$this->ignorers = array_keys(array_filter($levelByUserId, fn(int $level) => $level === 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Watch[]
|
||||
*/
|
||||
protected function getRelevantWatches(): array
|
||||
{
|
||||
/** @var Entity[] $entitiesInvolved */
|
||||
$entitiesInvolved = array_filter([
|
||||
$this->entity,
|
||||
$this->entity instanceof BookChild ? $this->entity->book : null,
|
||||
$this->entity instanceof Page ? $this->entity->chapter : null,
|
||||
]);
|
||||
|
||||
$query = Watch::query()->where(function (Builder $query) use ($entitiesInvolved) {
|
||||
foreach ($entitiesInvolved as $entity) {
|
||||
$query->orWhere(function (Builder $query) use ($entity) {
|
||||
$query->where('watchable_type', '=', $entity->getMorphClass())
|
||||
->where('watchable_id', '=', $entity->id);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return $query->get([
|
||||
'level', 'watchable_id', 'watchable_type', 'user_id'
|
||||
])->all();
|
||||
}
|
||||
}
|
||||
131
app/Activity/Tools/UserEntityWatchOptions.php
Normal file
131
app/Activity/Tools/UserEntityWatchOptions.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Tools;
|
||||
|
||||
use BookStack\Activity\Models\Watch;
|
||||
use BookStack\Activity\WatchLevels;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class UserEntityWatchOptions
|
||||
{
|
||||
protected ?array $watchMap = null;
|
||||
|
||||
public function __construct(
|
||||
protected User $user,
|
||||
protected Entity $entity,
|
||||
) {
|
||||
}
|
||||
|
||||
public function canWatch(): bool
|
||||
{
|
||||
return $this->user->can('receive-notifications') && !$this->user->isGuest();
|
||||
}
|
||||
|
||||
public function getWatchLevel(): string
|
||||
{
|
||||
return WatchLevels::levelValueToName($this->getWatchLevelValue());
|
||||
}
|
||||
|
||||
public function isWatching(): bool
|
||||
{
|
||||
return $this->getWatchLevelValue() !== WatchLevels::DEFAULT;
|
||||
}
|
||||
|
||||
public function getWatchedParent(): ?WatchedParentDetails
|
||||
{
|
||||
$watchMap = $this->getWatchMap();
|
||||
unset($watchMap[$this->entity->getMorphClass()]);
|
||||
|
||||
if (isset($watchMap['chapter'])) {
|
||||
return new WatchedParentDetails('chapter', $watchMap['chapter']);
|
||||
}
|
||||
|
||||
if (isset($watchMap['book'])) {
|
||||
return new WatchedParentDetails('book', $watchMap['book']);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function updateLevelByName(string $level): void
|
||||
{
|
||||
$levelValue = WatchLevels::levelNameToValue($level);
|
||||
$this->updateLevelByValue($levelValue);
|
||||
}
|
||||
|
||||
public function updateLevelByValue(int $level): void
|
||||
{
|
||||
if ($level < 0) {
|
||||
$this->remove();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->updateLevel($level);
|
||||
}
|
||||
|
||||
public function getWatchMap(): array
|
||||
{
|
||||
if (!is_null($this->watchMap)) {
|
||||
return $this->watchMap;
|
||||
}
|
||||
|
||||
$entities = [$this->entity];
|
||||
if ($this->entity instanceof BookChild) {
|
||||
$entities[] = $this->entity->book;
|
||||
}
|
||||
if ($this->entity instanceof Page && $this->entity->chapter) {
|
||||
$entities[] = $this->entity->chapter;
|
||||
}
|
||||
|
||||
$query = Watch::query()
|
||||
->where('user_id', '=', $this->user->id)
|
||||
->where(function (Builder $subQuery) use ($entities) {
|
||||
foreach ($entities as $entity) {
|
||||
$subQuery->orWhere(function (Builder $whereQuery) use ($entity) {
|
||||
$whereQuery->where('watchable_type', '=', $entity->getMorphClass())
|
||||
->where('watchable_id', '=', $entity->id);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$this->watchMap = $query->get(['watchable_type', 'level'])
|
||||
->pluck('level', 'watchable_type')
|
||||
->toArray();
|
||||
|
||||
return $this->watchMap;
|
||||
}
|
||||
|
||||
protected function getWatchLevelValue()
|
||||
{
|
||||
return $this->getWatchMap()[$this->entity->getMorphClass()] ?? WatchLevels::DEFAULT;
|
||||
}
|
||||
|
||||
protected function updateLevel(int $levelValue): void
|
||||
{
|
||||
Watch::query()->updateOrCreate([
|
||||
'watchable_id' => $this->entity->id,
|
||||
'watchable_type' => $this->entity->getMorphClass(),
|
||||
'user_id' => $this->user->id,
|
||||
], [
|
||||
'level' => $levelValue,
|
||||
]);
|
||||
$this->watchMap = null;
|
||||
}
|
||||
|
||||
protected function remove(): void
|
||||
{
|
||||
$this->entityQuery()->delete();
|
||||
$this->watchMap = null;
|
||||
}
|
||||
|
||||
protected function entityQuery(): Builder
|
||||
{
|
||||
return Watch::query()->where('watchable_id', '=', $this->entity->id)
|
||||
->where('watchable_type', '=', $this->entity->getMorphClass())
|
||||
->where('user_id', '=', $this->user->id);
|
||||
}
|
||||
}
|
||||
19
app/Activity/Tools/WatchedParentDetails.php
Normal file
19
app/Activity/Tools/WatchedParentDetails.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Tools;
|
||||
|
||||
use BookStack\Activity\WatchLevels;
|
||||
|
||||
class WatchedParentDetails
|
||||
{
|
||||
public function __construct(
|
||||
public string $type,
|
||||
public int $level,
|
||||
) {
|
||||
}
|
||||
|
||||
public function ignoring(): bool
|
||||
{
|
||||
return $this->level === WatchLevels::IGNORE;
|
||||
}
|
||||
}
|
||||
@@ -17,18 +17,14 @@ class WebhookFormatter
|
||||
protected string $event;
|
||||
protected User $initiator;
|
||||
protected int $initiatedTime;
|
||||
|
||||
/**
|
||||
* @var string|Loggable
|
||||
*/
|
||||
protected $detail;
|
||||
protected string|Loggable $detail;
|
||||
|
||||
/**
|
||||
* @var array{condition: callable(string, Model):bool, format: callable(Model):void}[]
|
||||
*/
|
||||
protected $modelFormatters = [];
|
||||
|
||||
public function __construct(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime)
|
||||
public function __construct(string $event, Webhook $webhook, string|Loggable $detail, User $initiator, int $initiatedTime)
|
||||
{
|
||||
$this->webhook = $webhook;
|
||||
$this->event = $event;
|
||||
|
||||
91
app/Activity/WatchLevels.php
Normal file
91
app/Activity/WatchLevels.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity;
|
||||
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
|
||||
class WatchLevels
|
||||
{
|
||||
/**
|
||||
* Default level, No specific option set
|
||||
* Typically not a stored status
|
||||
*/
|
||||
const DEFAULT = -1;
|
||||
|
||||
/**
|
||||
* Ignore all notifications.
|
||||
*/
|
||||
const IGNORE = 0;
|
||||
|
||||
/**
|
||||
* Watch for new content.
|
||||
*/
|
||||
const NEW = 1;
|
||||
|
||||
/**
|
||||
* Watch for updates and new content
|
||||
*/
|
||||
const UPDATES = 2;
|
||||
|
||||
/**
|
||||
* Watch for comments, updates and new content.
|
||||
*/
|
||||
const COMMENTS = 3;
|
||||
|
||||
/**
|
||||
* Get all the possible values as an option_name => value array.
|
||||
* @returns array<string, int>
|
||||
*/
|
||||
public static function all(): array
|
||||
{
|
||||
$options = [];
|
||||
foreach ((new \ReflectionClass(static::class))->getConstants() as $name => $value) {
|
||||
$options[strtolower($name)] = $value;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the watch options suited for the given entity.
|
||||
* @returns array<string, int>
|
||||
*/
|
||||
public static function allSuitedFor(Entity $entity): array
|
||||
{
|
||||
$options = static::all();
|
||||
|
||||
if ($entity instanceof Page) {
|
||||
unset($options['new']);
|
||||
} elseif ($entity instanceof Bookshelf) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given name to a level value.
|
||||
* Defaults to default value if the level does not exist.
|
||||
*/
|
||||
public static function levelNameToValue(string $level): int
|
||||
{
|
||||
return static::all()[$level] ?? static::DEFAULT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given int level value to a level name.
|
||||
* Defaults to 'default' level name if not existing.
|
||||
*/
|
||||
public static function levelValueToName(int $level): string
|
||||
{
|
||||
foreach (static::all() as $name => $value) {
|
||||
if ($level === $value) {
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -27,13 +27,16 @@ class ApiDocsGenerator
|
||||
{
|
||||
$appVersion = trim(file_get_contents(base_path('version')));
|
||||
$cacheKey = 'api-docs::' . $appVersion;
|
||||
if (Cache::has($cacheKey) && config('app.env') === 'production') {
|
||||
$docs = Cache::get($cacheKey);
|
||||
} else {
|
||||
$docs = (new ApiDocsGenerator())->generate();
|
||||
Cache::put($cacheKey, $docs, 60 * 24);
|
||||
$isProduction = config('app.env') === 'production';
|
||||
$cacheVal = $isProduction ? Cache::get($cacheKey) : null;
|
||||
|
||||
if (!is_null($cacheVal)) {
|
||||
return $cacheVal;
|
||||
}
|
||||
|
||||
$docs = (new ApiDocsGenerator())->generate();
|
||||
Cache::put($cacheKey, $docs, 60 * 24);
|
||||
|
||||
return $docs;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ class ApiEntityListFormatter
|
||||
* The list to be formatted.
|
||||
* @var Entity[]
|
||||
*/
|
||||
protected $list = [];
|
||||
protected array $list = [];
|
||||
|
||||
/**
|
||||
* The fields to show in the formatted data.
|
||||
@@ -19,9 +19,9 @@ class ApiEntityListFormatter
|
||||
* will be used for the resultant value. A null return value will omit the property.
|
||||
* @var array<string|int, string|callable>
|
||||
*/
|
||||
protected $fields = [
|
||||
'id', 'name', 'slug', 'book_id', 'chapter_id',
|
||||
'draft', 'template', 'created_at', 'updated_at',
|
||||
protected array $fields = [
|
||||
'id', 'name', 'slug', 'book_id', 'chapter_id', 'draft',
|
||||
'template', 'priority', 'created_at', 'updated_at',
|
||||
];
|
||||
|
||||
public function __construct(array $list)
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace BookStack\Api;
|
||||
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -20,6 +21,8 @@ use Illuminate\Support\Carbon;
|
||||
*/
|
||||
class ApiToken extends Model implements Loggable
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'expires_at'];
|
||||
protected $casts = [
|
||||
'expires_at' => 'date:Y-m-d',
|
||||
@@ -49,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
namespace BookStack\App;
|
||||
|
||||
use BookStack\Activity\ActivityQueries;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\RecentlyViewed;
|
||||
use BookStack\Entities\Queries\TopFavourites;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Queries\QueryRecentlyViewed;
|
||||
use BookStack\Entities\Queries\QueryTopFavourites;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Uploads\FaviconHandler;
|
||||
@@ -17,18 +15,25 @@ use Illuminate\Http\Request;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected EntityQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the homepage.
|
||||
*/
|
||||
public function index(Request $request, ActivityQueries $activities)
|
||||
{
|
||||
public function index(
|
||||
Request $request,
|
||||
ActivityQueries $activities,
|
||||
QueryRecentlyViewed $recentlyViewed,
|
||||
QueryTopFavourites $topFavourites,
|
||||
) {
|
||||
$activity = $activities->latest(10);
|
||||
$draftPages = [];
|
||||
|
||||
if ($this->isSignedIn()) {
|
||||
$draftPages = Page::visible()
|
||||
->where('draft', '=', true)
|
||||
->where('created_by', '=', user()->id)
|
||||
$draftPages = $this->queries->pages->currentUserDraftsForList()
|
||||
->orderBy('updated_at', 'desc')
|
||||
->with('book')
|
||||
->take(6)
|
||||
@@ -37,14 +42,13 @@ class HomeController extends Controller
|
||||
|
||||
$recentFactor = count($draftPages) > 0 ? 0.5 : 1;
|
||||
$recents = $this->isSignedIn() ?
|
||||
(new RecentlyViewed())->run(12 * $recentFactor, 1)
|
||||
: Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
|
||||
$favourites = (new TopFavourites())->run(6);
|
||||
$recentlyUpdatedPages = Page::visible()->with('book')
|
||||
$recentlyViewed->run(12 * $recentFactor, 1)
|
||||
: $this->queries->books->visibleForList()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
|
||||
$favourites = $topFavourites->run(6);
|
||||
$recentlyUpdatedPages = $this->queries->pages->visibleForList()
|
||||
->where('draft', false)
|
||||
->orderBy('updated_at', 'desc')
|
||||
->take($favourites->count() > 0 ? 5 : 10)
|
||||
->select(Page::$listAttributes)
|
||||
->get();
|
||||
|
||||
$homepageOptions = ['default', 'books', 'bookshelves', 'page'];
|
||||
@@ -78,14 +82,18 @@ class HomeController extends Controller
|
||||
}
|
||||
|
||||
if ($homepageOption === 'bookshelves') {
|
||||
$shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
|
||||
$shelves = $this->queries->shelves->visibleForListWithCover()
|
||||
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
||||
->paginate(18);
|
||||
$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 = $this->queries->books->visibleForListWithCover()
|
||||
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
||||
->paginate(18);
|
||||
$data = array_merge($commonData, ['books' => $books]);
|
||||
|
||||
return view('home.books', $data);
|
||||
@@ -95,7 +103,7 @@ class HomeController extends Controller
|
||||
$homepageSetting = setting('app-homepage', '0:');
|
||||
$id = intval(explode(':', $homepageSetting)[0]);
|
||||
/** @var Page $customHomepage */
|
||||
$customHomepage = Page::query()->where('draft', '=', false)->findOrFail($id);
|
||||
$customHomepage = $this->queries->pages->start()->where('draft', '=', false)->findOrFail($id);
|
||||
$pageContent = new PageContent($customHomepage);
|
||||
$customHomepage->html = $pageContent->render(false);
|
||||
|
||||
@@ -140,4 +148,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,16 +1,23 @@
|
||||
<?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;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class MailNotification extends Notification implements ShouldQueue
|
||||
abstract class MailNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
abstract public function toMail(User $notifiable): MailMessage;
|
||||
|
||||
/**
|
||||
* Get the notification's channels.
|
||||
*
|
||||
@@ -25,14 +32,14 @@ class MailNotification extends Notification implements ShouldQueue
|
||||
|
||||
/**
|
||||
* Create a new mail message.
|
||||
*
|
||||
* @return MailMessage
|
||||
*/
|
||||
protected function newMailMessage()
|
||||
protected function newMailMessage(?LocaleDefinition $locale = null): MailMessage
|
||||
{
|
||||
$data = ['locale' => $locale ?? user()->getLocale()];
|
||||
|
||||
return (new MailMessage())->view([
|
||||
'html' => 'vendor.notifications.email',
|
||||
'text' => 'vendor.notifications.email-plain',
|
||||
]);
|
||||
], $data);
|
||||
}
|
||||
}
|
||||
@@ -2,22 +2,22 @@
|
||||
|
||||
namespace BookStack\App\Providers;
|
||||
|
||||
use BookStack\Access\SocialAuthService;
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Activity\Tools\ActivityLogger;
|
||||
use BookStack\Entities\Models\Book;
|
||||
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
|
||||
{
|
||||
@@ -36,8 +36,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
public $singletons = [
|
||||
'activity' => ActivityLogger::class,
|
||||
SettingService::class => SettingService::class,
|
||||
SocialAuthService::class => SocialAuthService::class,
|
||||
SocialDriverManager::class => SocialDriverManager::class,
|
||||
CspService::class => CspService::class,
|
||||
HttpRequestService::class => HttpRequestService::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -50,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');
|
||||
}
|
||||
@@ -74,10 +75,8 @@ 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',
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
namespace BookStack\App\Providers;
|
||||
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Router;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -46,8 +49,15 @@ class RouteServiceProvider extends ServiceProvider
|
||||
Route::group([
|
||||
'middleware' => 'web',
|
||||
'namespace' => $this->namespace,
|
||||
], function ($router) {
|
||||
], function (Router $router) {
|
||||
require base_path('routes/web.php');
|
||||
Theme::dispatch(ThemeEvents::ROUTES_REGISTER_WEB, $router);
|
||||
});
|
||||
|
||||
Route::group([
|
||||
'middleware' => ['web', 'auth'],
|
||||
], function (Router $router) {
|
||||
Theme::dispatch(ThemeEvents::ROUTES_REGISTER_WEB_AUTH, $router);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(); ?>";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
64
app/App/PwaManifestBuilder.php
Normal file
64
app/App/PwaManifestBuilder.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\App;
|
||||
|
||||
class PwaManifestBuilder
|
||||
{
|
||||
public function build(): array
|
||||
{
|
||||
// Note, while we attempt to use the user's preference here, the request to the manifest
|
||||
// does not start a session, so we won't have current user context.
|
||||
// This was attempted but removed since manifest calls could affect user session
|
||||
// history tracking and back redirection.
|
||||
// Context: https://github.com/BookStackApp/BookStack/issues/4649
|
||||
$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" => "any",
|
||||
"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
|
||||
|
||||
@@ -66,6 +66,15 @@ return [
|
||||
// Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
|
||||
'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'),
|
||||
|
||||
// A list of the sources/hostnames that can be reached by application SSR calls.
|
||||
// This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
|
||||
// Host-specific functionality (usually controlled via other options) like auth
|
||||
// or user avatars for example, won't use this list.
|
||||
// Space seperated if multiple. Can use '*' as a wildcard.
|
||||
// Values will be compared prefix-matched, case-insensitive, against called SSR urls.
|
||||
// Defaults to allow all hosts.
|
||||
'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'),
|
||||
|
||||
// Alter the precision of IP addresses stored by BookStack.
|
||||
// Integer value between 0 (IP hidden) to 4 (Full IP usage)
|
||||
'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),
|
||||
@@ -74,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',
|
||||
@@ -85,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.
|
||||
@@ -135,7 +141,6 @@ return [
|
||||
// Third party service providers
|
||||
Barryvdh\DomPDF\ServiceProvider::class,
|
||||
Barryvdh\Snappy\ServiceProvider::class,
|
||||
Intervention\Image\ImageServiceProvider::class,
|
||||
SocialiteProviders\Manager\ServiceProvider::class,
|
||||
|
||||
// BookStack custom service providers
|
||||
@@ -155,9 +160,6 @@ return [
|
||||
// Laravel Packages
|
||||
'Socialite' => Laravel\Socialite\Facades\Socialite::class,
|
||||
|
||||
// Third Party
|
||||
'ImageTool' => Intervention\Image\Facades\Image::class,
|
||||
|
||||
// Custom BookStack
|
||||
'Activity' => BookStack\Facades\Activity::class,
|
||||
'Theme' => BookStack\Facades\Theme::class,
|
||||
|
||||
@@ -173,6 +173,8 @@ return [
|
||||
|
||||
// List of URIs that should not be collected
|
||||
'except' => [
|
||||
'/uploads/images/.*', // BookStack image requests
|
||||
|
||||
'/horizon/.*', // Laravel Horizon requests
|
||||
'/telescope/.*', // Laravel Telescope requests
|
||||
'/_debugbar/.*', // Laravel DebugBar requests
|
||||
|
||||
@@ -58,6 +58,7 @@ return [
|
||||
'endpoint' => env('STORAGE_S3_ENDPOINT', null),
|
||||
'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null,
|
||||
'throw' => true,
|
||||
'stream_reads' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
|
||||
@@ -31,7 +31,7 @@ return [
|
||||
'mailers' => [
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl') ? 'smtps' : null,
|
||||
'scheme' => null,
|
||||
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
|
||||
'port' => env('MAIL_PORT', 587),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
@@ -39,6 +39,7 @@ return [
|
||||
'verify_peer' => env('MAIL_VERIFY_SSL', true),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN'),
|
||||
'tls_required' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl'),
|
||||
],
|
||||
|
||||
'sendmail' => [
|
||||
|
||||
@@ -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'),
|
||||
@@ -36,6 +36,12 @@ return [
|
||||
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
|
||||
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
|
||||
|
||||
// OIDC RP-Initiated Logout endpoint URL.
|
||||
// A false value force-disables RP-Initiated Logout.
|
||||
// A true value gets the URL from discovery, if active.
|
||||
// A string value is used as the URL.
|
||||
'end_session_endpoint' => env('OIDC_END_SESSION_ENDPOINT', false),
|
||||
|
||||
// Add extra scopes, upon those required, to the OIDC authentication request
|
||||
// Multiple values can be provided comma seperated.
|
||||
'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
|
||||
@@ -45,6 +51,6 @@ return [
|
||||
'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
|
||||
// Attribute, within a OIDC ID token, to find group names within
|
||||
'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'),
|
||||
// When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups.
|
||||
// When syncing groups, remove any groups that no longer match. Otherwise, sync only adds new groups.
|
||||
'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false),
|
||||
];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@@ -28,7 +28,7 @@ class CopyShelfPermissionsCommand extends Command
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(PermissionsUpdater $permissionsUpdater): int
|
||||
public function handle(PermissionsUpdater $permissionsUpdater, BookshelfQueries $queries): int
|
||||
{
|
||||
$shelfSlug = $this->option('slug');
|
||||
$cascadeAll = $this->option('all');
|
||||
@@ -51,11 +51,11 @@ class CopyShelfPermissionsCommand extends Command
|
||||
return 0;
|
||||
}
|
||||
|
||||
$shelves = Bookshelf::query()->get(['id']);
|
||||
$shelves = $queries->start()->get(['id']);
|
||||
}
|
||||
|
||||
if ($shelfSlug) {
|
||||
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id']);
|
||||
$shelves = $queries->start()->where('slug', '=', $shelfSlug)->get(['id']);
|
||||
if ($shelves->count() === 0) {
|
||||
$this->info('No shelves found with the given slug.');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Activity\CommentRepo;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegenerateCommentContentCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bookstack:regenerate-comment-content
|
||||
{--database= : The database connection to use}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Regenerate the stored HTML of all comments';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(CommentRepo $commentRepo): int
|
||||
{
|
||||
$connection = DB::getDefaultConnection();
|
||||
if ($this->option('database') !== null) {
|
||||
DB::setDefaultConnection($this->option('database'));
|
||||
}
|
||||
|
||||
Comment::query()->chunk(100, function ($comments) use ($commentRepo) {
|
||||
foreach ($comments as $comment) {
|
||||
$comment->html = $commentRepo->commentToHtml($comment->text);
|
||||
$comment->save();
|
||||
}
|
||||
});
|
||||
|
||||
DB::setDefaultConnection($connection);
|
||||
$this->comment('Comment HTML content has been regenerated');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class RegenerateReferencesCommand extends Command
|
||||
DB::setDefaultConnection($this->option('database'));
|
||||
}
|
||||
|
||||
$references->updateForAllPages();
|
||||
$references->updateForAll();
|
||||
|
||||
DB::setDefaultConnection($connection);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,9 @@ class UpdateUrlCommand extends Command
|
||||
$columnsToUpdateByTable = [
|
||||
'attachments' => ['path'],
|
||||
'pages' => ['html', 'text', 'markdown'],
|
||||
'chapters' => ['description_html'],
|
||||
'books' => ['description_html'],
|
||||
'bookshelves' => ['description_html'],
|
||||
'images' => ['url'],
|
||||
'settings' => ['value'],
|
||||
'comments' => ['html', 'text'],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\Api\ApiEntityListFormatter;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Http\ApiController;
|
||||
@@ -14,11 +15,10 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BookApiController extends ApiController
|
||||
{
|
||||
protected BookRepo $bookRepo;
|
||||
|
||||
public function __construct(BookRepo $bookRepo)
|
||||
{
|
||||
$this->bookRepo = $bookRepo;
|
||||
public function __construct(
|
||||
protected BookRepo $bookRepo,
|
||||
protected BookQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,7 +26,9 @@ class BookApiController extends ApiController
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$books = Book::visible();
|
||||
$books = $this->queries
|
||||
->visibleForList()
|
||||
->addSelect(['created_by', 'updated_by']);
|
||||
|
||||
return $this->apiListingResponse($books, [
|
||||
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by',
|
||||
@@ -47,7 +49,7 @@ class BookApiController extends ApiController
|
||||
|
||||
$book = $this->bookRepo->create($requestData);
|
||||
|
||||
return response()->json($book);
|
||||
return response()->json($this->forJsonDisplay($book));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,7 +60,9 @@ class BookApiController extends ApiController
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$book = $this->forJsonDisplay($book);
|
||||
$book->load(['createdBy', 'updatedBy', 'ownedBy']);
|
||||
|
||||
$contents = (new BookContents($book))->getTree(true, false)->all();
|
||||
$contentsApiData = (new ApiEntityListFormatter($contents))
|
||||
@@ -83,13 +87,13 @@ class BookApiController extends ApiController
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
|
||||
$requestData = $this->validate($request, $this->rules()['update']);
|
||||
$book = $this->bookRepo->update($book, $requestData);
|
||||
|
||||
return response()->json($book);
|
||||
return response()->json($this->forJsonDisplay($book));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,7 +104,7 @@ class BookApiController extends ApiController
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission('book-delete', $book);
|
||||
|
||||
$this->bookRepo->destroy($book);
|
||||
@@ -108,20 +112,36 @@ class BookApiController extends ApiController
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
protected function forJsonDisplay(Book $book): Book
|
||||
{
|
||||
$book = clone $book;
|
||||
$book->unsetRelations()->refresh();
|
||||
|
||||
$book->load(['tags', 'cover']);
|
||||
$book->makeVisible('description_html')
|
||||
->setAttribute('description_html', $book->descriptionHtml());
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'create' => [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1900'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
],
|
||||
'update' => [
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1900'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ namespace BookStack\Entities\Controllers;
|
||||
use BookStack\Activity\ActivityQueries;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
@@ -23,15 +25,13 @@ use Throwable;
|
||||
|
||||
class BookController extends Controller
|
||||
{
|
||||
protected BookRepo $bookRepo;
|
||||
protected ShelfContext $shelfContext;
|
||||
protected ReferenceFetcher $referenceFetcher;
|
||||
|
||||
public function __construct(ShelfContext $entityContextManager, BookRepo $bookRepo, ReferenceFetcher $referenceFetcher)
|
||||
{
|
||||
$this->bookRepo = $bookRepo;
|
||||
$this->shelfContext = $entityContextManager;
|
||||
$this->referenceFetcher = $referenceFetcher;
|
||||
public function __construct(
|
||||
protected ShelfContext $shelfContext,
|
||||
protected BookRepo $bookRepo,
|
||||
protected BookQueries $queries,
|
||||
protected BookshelfQueries $shelfQueries,
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,10 +46,12 @@ class BookController extends Controller
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
]);
|
||||
|
||||
$books = $this->bookRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
|
||||
$recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed(4) : false;
|
||||
$popular = $this->bookRepo->getPopular(4);
|
||||
$new = $this->bookRepo->getRecentlyCreated(4);
|
||||
$books = $this->queries->visibleForListWithCover()
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
||||
->paginate(18);
|
||||
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
|
||||
$popular = $this->queries->popularForList()->take(4)->get();
|
||||
$new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get();
|
||||
|
||||
$this->shelfContext->clearShelfContext();
|
||||
|
||||
@@ -74,7 +76,7 @@ class BookController extends Controller
|
||||
|
||||
$bookshelf = null;
|
||||
if ($shelfSlug !== null) {
|
||||
$bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
|
||||
$bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
|
||||
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
|
||||
}
|
||||
|
||||
@@ -95,15 +97,16 @@ class BookController extends Controller
|
||||
{
|
||||
$this->checkPermission('book-create-all');
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$bookshelf = null;
|
||||
if ($shelfSlug !== null) {
|
||||
$bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
|
||||
$bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
|
||||
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
|
||||
}
|
||||
|
||||
@@ -122,7 +125,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($slug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$bookChildren = (new BookContents($book))->getTree(true);
|
||||
$bookParentShelves = $book->shelves()->scopes('visible')->get();
|
||||
|
||||
@@ -138,8 +141,9 @@ class BookController extends Controller
|
||||
'current' => $book,
|
||||
'bookChildren' => $bookChildren,
|
||||
'bookParentShelves' => $bookParentShelves,
|
||||
'watchOptions' => new UserEntityWatchOptions(user(), $book),
|
||||
'activity' => $activities->entityActivity($book, 20, 1),
|
||||
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($book),
|
||||
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($book),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -148,7 +152,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function edit(string $slug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($slug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
$this->setPageTitle(trans('entities.books_edit_named', ['bookName' => $book->getShortName()]));
|
||||
|
||||
@@ -164,14 +168,15 @@ class BookController extends Controller
|
||||
*/
|
||||
public function update(Request $request, string $slug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($slug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
if ($request->has('image_reset')) {
|
||||
@@ -190,7 +195,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function showDelete(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-delete', $book);
|
||||
$this->setPageTitle(trans('entities.books_delete_named', ['bookName' => $book->getShortName()]));
|
||||
|
||||
@@ -204,7 +209,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function destroy(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-delete', $book);
|
||||
|
||||
$this->bookRepo->destroy($book);
|
||||
@@ -219,7 +224,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function showCopy(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-view', $book);
|
||||
|
||||
session()->flashInput(['name' => $book->name]);
|
||||
@@ -236,7 +241,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function copy(Request $request, Cloner $cloner, string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-view', $book);
|
||||
$this->checkPermission('book-create-all');
|
||||
|
||||
@@ -252,7 +257,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function convertToShelf(HierarchyTransformer $transformer, string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
$this->checkOwnablePermission('book-delete', $book);
|
||||
$this->checkPermission('bookshelf-create-all');
|
||||
|
||||
@@ -2,18 +2,17 @@
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Http\ApiController;
|
||||
use Throwable;
|
||||
|
||||
class BookExportApiController extends ApiController
|
||||
{
|
||||
protected $exportFormatter;
|
||||
|
||||
public function __construct(ExportFormatter $exportFormatter)
|
||||
{
|
||||
$this->exportFormatter = $exportFormatter;
|
||||
public function __construct(
|
||||
protected ExportFormatter $exportFormatter,
|
||||
protected BookQueries $queries,
|
||||
) {
|
||||
$this->middleware('can:content-export');
|
||||
}
|
||||
|
||||
@@ -24,7 +23,7 @@ class BookExportApiController extends ApiController
|
||||
*/
|
||||
public function exportPdf(int $id)
|
||||
{
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail($id);
|
||||
$pdfContent = $this->exportFormatter->bookToPdf($book);
|
||||
|
||||
return $this->download()->directly($pdfContent, $book->slug . '.pdf');
|
||||
@@ -37,7 +36,7 @@ class BookExportApiController extends ApiController
|
||||
*/
|
||||
public function exportHtml(int $id)
|
||||
{
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail($id);
|
||||
$htmlContent = $this->exportFormatter->bookToContainedHtml($book);
|
||||
|
||||
return $this->download()->directly($htmlContent, $book->slug . '.html');
|
||||
@@ -48,7 +47,7 @@ class BookExportApiController extends ApiController
|
||||
*/
|
||||
public function exportPlainText(int $id)
|
||||
{
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail($id);
|
||||
$textContent = $this->exportFormatter->bookToPlainText($book);
|
||||
|
||||
return $this->download()->directly($textContent, $book->slug . '.txt');
|
||||
@@ -59,7 +58,7 @@ class BookExportApiController extends ApiController
|
||||
*/
|
||||
public function exportMarkdown(int $id)
|
||||
{
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail($id);
|
||||
$markdown = $this->exportFormatter->bookToMarkdown($book);
|
||||
|
||||
return $this->download()->directly($markdown, $book->slug . '.md');
|
||||
|
||||
@@ -2,23 +2,17 @@
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Http\Controller;
|
||||
use Throwable;
|
||||
|
||||
class BookExportController extends Controller
|
||||
{
|
||||
protected $bookRepo;
|
||||
protected $exportFormatter;
|
||||
|
||||
/**
|
||||
* BookExportController constructor.
|
||||
*/
|
||||
public function __construct(BookRepo $bookRepo, ExportFormatter $exportFormatter)
|
||||
{
|
||||
$this->bookRepo = $bookRepo;
|
||||
$this->exportFormatter = $exportFormatter;
|
||||
public function __construct(
|
||||
protected BookQueries $queries,
|
||||
protected ExportFormatter $exportFormatter,
|
||||
) {
|
||||
$this->middleware('can:content-export');
|
||||
}
|
||||
|
||||
@@ -29,7 +23,7 @@ class BookExportController extends Controller
|
||||
*/
|
||||
public function pdf(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$pdfContent = $this->exportFormatter->bookToPdf($book);
|
||||
|
||||
return $this->download()->directly($pdfContent, $bookSlug . '.pdf');
|
||||
@@ -42,7 +36,7 @@ class BookExportController extends Controller
|
||||
*/
|
||||
public function html(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$htmlContent = $this->exportFormatter->bookToContainedHtml($book);
|
||||
|
||||
return $this->download()->directly($htmlContent, $bookSlug . '.html');
|
||||
@@ -53,7 +47,7 @@ class BookExportController extends Controller
|
||||
*/
|
||||
public function plainText(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$textContent = $this->exportFormatter->bookToPlainText($book);
|
||||
|
||||
return $this->download()->directly($textContent, $bookSlug . '.txt');
|
||||
@@ -64,7 +58,7 @@ class BookExportController extends Controller
|
||||
*/
|
||||
public function markdown(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$textContent = $this->exportFormatter->bookToMarkdown($book);
|
||||
|
||||
return $this->download()->directly($textContent, $bookSlug . '.md');
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\BookSortMap;
|
||||
use BookStack\Facades\Activity;
|
||||
@@ -12,11 +12,9 @@ use Illuminate\Http\Request;
|
||||
|
||||
class BookSortController extends Controller
|
||||
{
|
||||
protected $bookRepo;
|
||||
|
||||
public function __construct(BookRepo $bookRepo)
|
||||
{
|
||||
$this->bookRepo = $bookRepo;
|
||||
public function __construct(
|
||||
protected BookQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,7 +22,7 @@ class BookSortController extends Controller
|
||||
*/
|
||||
public function show(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
|
||||
$bookChildren = (new BookContents($book))->getTree(false);
|
||||
@@ -40,7 +38,7 @@ class BookSortController extends Controller
|
||||
*/
|
||||
public function showItem(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$bookChildren = (new BookContents($book))->getTree();
|
||||
|
||||
return view('books.parts.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
|
||||
@@ -51,7 +49,7 @@ class BookSortController extends Controller
|
||||
*/
|
||||
public function update(Request $request, string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
|
||||
// Return if no map sent
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Http\ApiController;
|
||||
use Exception;
|
||||
@@ -12,11 +13,10 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BookshelfApiController extends ApiController
|
||||
{
|
||||
protected BookshelfRepo $bookshelfRepo;
|
||||
|
||||
public function __construct(BookshelfRepo $bookshelfRepo)
|
||||
{
|
||||
$this->bookshelfRepo = $bookshelfRepo;
|
||||
public function __construct(
|
||||
protected BookshelfRepo $bookshelfRepo,
|
||||
protected BookshelfQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,7 +24,9 @@ class BookshelfApiController extends ApiController
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$shelves = Bookshelf::visible();
|
||||
$shelves = $this->queries
|
||||
->visibleForList()
|
||||
->addSelect(['created_by', 'updated_by']);
|
||||
|
||||
return $this->apiListingResponse($shelves, [
|
||||
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by',
|
||||
@@ -48,7 +50,7 @@ class BookshelfApiController extends ApiController
|
||||
$bookIds = $request->get('books', []);
|
||||
$shelf = $this->bookshelfRepo->create($requestData, $bookIds);
|
||||
|
||||
return response()->json($shelf);
|
||||
return response()->json($this->forJsonDisplay($shelf));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,12 +58,14 @@ class BookshelfApiController extends ApiController
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$shelf = Bookshelf::visible()->with([
|
||||
'tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy',
|
||||
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$shelf = $this->forJsonDisplay($shelf);
|
||||
$shelf->load([
|
||||
'createdBy', 'updatedBy', 'ownedBy',
|
||||
'books' => function (BelongsToMany $query) {
|
||||
$query->scopes('visible')->get(['id', 'name', 'slug']);
|
||||
},
|
||||
])->findOrFail($id);
|
||||
]);
|
||||
|
||||
return response()->json($shelf);
|
||||
}
|
||||
@@ -78,7 +82,7 @@ class BookshelfApiController extends ApiController
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$shelf = Bookshelf::visible()->findOrFail($id);
|
||||
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission('bookshelf-update', $shelf);
|
||||
|
||||
$requestData = $this->validate($request, $this->rules()['update']);
|
||||
@@ -86,7 +90,7 @@ class BookshelfApiController extends ApiController
|
||||
|
||||
$shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
|
||||
|
||||
return response()->json($shelf);
|
||||
return response()->json($this->forJsonDisplay($shelf));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +101,7 @@ class BookshelfApiController extends ApiController
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
$shelf = Bookshelf::visible()->findOrFail($id);
|
||||
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission('bookshelf-delete', $shelf);
|
||||
|
||||
$this->bookshelfRepo->destroy($shelf);
|
||||
@@ -105,22 +109,36 @@ class BookshelfApiController extends ApiController
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
protected function forJsonDisplay(Bookshelf $shelf): Bookshelf
|
||||
{
|
||||
$shelf = clone $shelf;
|
||||
$shelf->unsetRelations()->refresh();
|
||||
|
||||
$shelf->load(['tags', 'cover']);
|
||||
$shelf->makeVisible('description_html')
|
||||
->setAttribute('description_html', $shelf->descriptionHtml());
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'create' => [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'books' => ['array'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1900'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'books' => ['array'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
],
|
||||
'update' => [
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'books' => ['array'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1900'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'books' => ['array'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Activity\ActivityQueries;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Entities\Tools\ShelfContext;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
@@ -18,15 +19,13 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BookshelfController extends Controller
|
||||
{
|
||||
protected BookshelfRepo $shelfRepo;
|
||||
protected ShelfContext $shelfContext;
|
||||
protected ReferenceFetcher $referenceFetcher;
|
||||
|
||||
public function __construct(BookshelfRepo $shelfRepo, ShelfContext $shelfContext, ReferenceFetcher $referenceFetcher)
|
||||
{
|
||||
$this->shelfRepo = $shelfRepo;
|
||||
$this->shelfContext = $shelfContext;
|
||||
$this->referenceFetcher = $referenceFetcher;
|
||||
public function __construct(
|
||||
protected BookshelfRepo $shelfRepo,
|
||||
protected BookshelfQueries $queries,
|
||||
protected BookQueries $bookQueries,
|
||||
protected ShelfContext $shelfContext,
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,10 +40,15 @@ class BookshelfController extends Controller
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
]);
|
||||
|
||||
$shelves = $this->shelfRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
|
||||
$recents = $this->isSignedIn() ? $this->shelfRepo->getRecentlyViewed(4) : false;
|
||||
$popular = $this->shelfRepo->getPopular(4);
|
||||
$new = $this->shelfRepo->getRecentlyCreated(4);
|
||||
$shelves = $this->queries->visibleForListWithCover()
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
||||
->paginate(18);
|
||||
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
|
||||
$popular = $this->queries->popularForList()->get();
|
||||
$new = $this->queries->visibleForList()
|
||||
->orderBy('created_at', 'desc')
|
||||
->take(4)
|
||||
->get();
|
||||
|
||||
$this->shelfContext->clearShelfContext();
|
||||
$this->setPageTitle(trans('entities.shelves'));
|
||||
@@ -65,7 +69,7 @@ class BookshelfController extends Controller
|
||||
public function create()
|
||||
{
|
||||
$this->checkPermission('bookshelf-create-all');
|
||||
$books = Book::visible()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
|
||||
$books = $this->bookQueries->visibleForList()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
|
||||
$this->setPageTitle(trans('entities.shelves_create'));
|
||||
|
||||
return view('shelves.create', ['books' => $books]);
|
||||
@@ -81,10 +85,10 @@ class BookshelfController extends Controller
|
||||
{
|
||||
$this->checkPermission('bookshelf-create-all');
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
]);
|
||||
|
||||
$bookIds = explode(',', $request->get('books', ''));
|
||||
@@ -100,7 +104,7 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('bookshelf-view', $shelf);
|
||||
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
|
||||
@@ -129,7 +133,7 @@ class BookshelfController extends Controller
|
||||
'view' => $view,
|
||||
'activity' => $activities->entityActivity($shelf, 20, 1),
|
||||
'listOptions' => $listOptions,
|
||||
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($shelf),
|
||||
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($shelf),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -138,11 +142,14 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function edit(string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('bookshelf-update', $shelf);
|
||||
|
||||
$shelfBookIds = $shelf->books()->get(['id'])->pluck('id');
|
||||
$books = Book::visible()->whereNotIn('id', $shelfBookIds)->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
|
||||
$books = $this->bookQueries->visibleForList()
|
||||
->whereNotIn('id', $shelfBookIds)
|
||||
->orderBy('name')
|
||||
->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
|
||||
|
||||
$this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()]));
|
||||
|
||||
@@ -161,13 +168,13 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function update(Request $request, string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('bookshelf-update', $shelf);
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
]);
|
||||
|
||||
if ($request->has('image_reset')) {
|
||||
@@ -187,7 +194,7 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function showDelete(string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('bookshelf-delete', $shelf);
|
||||
|
||||
$this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()]));
|
||||
@@ -202,7 +209,7 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function destroy(string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('bookshelf-delete', $shelf);
|
||||
|
||||
$this->shelfRepo->destroy($shelf);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user