mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-05 08:39:55 +03:00
Compare commits
395 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ac8ecad6b | ||
|
|
903e88c700 | ||
|
|
c0d5e158d7 | ||
|
|
99377d43c1 | ||
|
|
ebb1942fb8 | ||
|
|
152f7f3ad0 | ||
|
|
8a03442b5b | ||
|
|
e591f4896e | ||
|
|
6a7bc68b61 | ||
|
|
924f517217 | ||
|
|
150b40edc1 | ||
|
|
ed96aa820e | ||
|
|
63ec079b7b | ||
|
|
c17906c758 | ||
|
|
62d5701578 | ||
|
|
9f1a6947ab | ||
|
|
ae90776927 | ||
|
|
4489f65371 | ||
|
|
ee1e047964 | ||
|
|
8846f7d255 | ||
|
|
2523cee0e2 | ||
|
|
b5cc0a8e38 | ||
|
|
3bcbf6b9c5 | ||
|
|
573bc3ec45 | ||
|
|
d485fcb3db | ||
|
|
0f895668a4 | ||
|
|
57bdd83d8c | ||
|
|
ce0b75294f | ||
|
|
4bb2b31bc9 | ||
|
|
9d74508ae3 | ||
|
|
c41baa1b76 | ||
|
|
cd32597d4d | ||
|
|
8594656f6e | ||
|
|
0aca1c2332 | ||
|
|
8c738aedee | ||
|
|
f64ce71afc | ||
|
|
277d5392fb | ||
|
|
23c35af9ef | ||
|
|
78fecdfcb0 | ||
|
|
a9d952560d | ||
|
|
56f234d1ee | ||
|
|
011800d425 | ||
|
|
647ce6c237 | ||
|
|
607da73109 | ||
|
|
1135d477ba | ||
|
|
a4a96a3df7 | ||
|
|
38e8a96dcd | ||
|
|
9a17656f88 | ||
|
|
e36cdaad0d | ||
|
|
722c38d576 | ||
|
|
8cd6c797e8 | ||
|
|
dff45e2c5d | ||
|
|
61d2ea6ac7 | ||
|
|
752562d23d | ||
|
|
b21a9007c5 | ||
|
|
a8fc29a31e | ||
|
|
36116a45d4 | ||
|
|
23915c3b1a | ||
|
|
55af22b487 | ||
|
|
01f3f4d315 | ||
|
|
58cadce052 | ||
|
|
1de72d09ca | ||
|
|
fa6fcc1c1c | ||
|
|
a46b438a4c | ||
|
|
7505443a0c | ||
|
|
f837083c12 | ||
|
|
e1bd13f481 | ||
|
|
c74f7cc628 | ||
|
|
9f467f4052 | ||
|
|
974390688d | ||
|
|
da3ae3ba8b | ||
|
|
0519e58fbf | ||
|
|
e711290d8b | ||
|
|
752ee664c2 | ||
|
|
69d03042c6 | ||
|
|
baf5edd73a | ||
|
|
3e738b1471 | ||
|
|
94f464cd14 | ||
|
|
900571ac9c | ||
|
|
09fd0bc5b7 | ||
|
|
74b4751a1c | ||
|
|
74b76ecdb9 | ||
|
|
9874a53206 | ||
|
|
257a703878 | ||
|
|
fdda813d5f | ||
|
|
6f45d34bf8 | ||
|
|
32c765d0c3 | ||
|
|
9813c94720 | ||
|
|
da3e4f5f75 | ||
|
|
572037ef1f | ||
|
|
50f3c10f19 | ||
|
|
6c577ac3bf | ||
|
|
31cc2423d2 | ||
|
|
3f3f221e0d | ||
|
|
d0f970fe4f | ||
|
|
95b75c067f | ||
|
|
81134e7071 | ||
|
|
e722ee4268 | ||
|
|
fd674d10e3 | ||
|
|
4835a0dcb1 | ||
|
|
d353e87ca1 | ||
|
|
8e64324d62 | ||
|
|
c9ed32e518 | ||
|
|
6b4c3a0969 | ||
|
|
0a0fdd7f3e | ||
|
|
3410cf21cb | ||
|
|
6e284d7a6c | ||
|
|
ea7914422c | ||
|
|
509cab3e28 | ||
|
|
dde38e91b5 | ||
|
|
970088a8a1 | ||
|
|
0e43618dda | ||
|
|
f2293a70f8 | ||
|
|
dce5123452 | ||
|
|
c81cb6f2af | ||
|
|
9b66e93b15 | ||
|
|
402eb845ab | ||
|
|
3a808fd768 | ||
|
|
d9eec6d82c | ||
|
|
6357056d7b | ||
|
|
a369971e04 | ||
|
|
1903924829 | ||
|
|
0de7530059 | ||
|
|
c42956bcaf | ||
|
|
7b5111571c | ||
|
|
2dad92d1bd | ||
|
|
c1fb7ab7dc | ||
|
|
3464f5e961 | ||
|
|
7c27d26161 | ||
|
|
98315f3899 | ||
|
|
8c82aaabd6 | ||
|
|
c7e33d1981 | ||
|
|
ba21b54195 | ||
|
|
f35c42b0b8 | ||
|
|
b88b1bef2c | ||
|
|
8abb41abbd | ||
|
|
a031edec16 | ||
|
|
2724b2867b | ||
|
|
8bebea4cca | ||
|
|
6545afacd6 | ||
|
|
31495758a9 | ||
|
|
c80396136f | ||
|
|
8da3e64039 | ||
|
|
c1167f8821 | ||
|
|
4176b598ce | ||
|
|
950c02e996 | ||
|
|
9502f349a2 | ||
|
|
3c3c2ae9b5 | ||
|
|
723f108bd9 | ||
|
|
55456a57d6 | ||
|
|
c148e2f3d9 | ||
|
|
f51036b203 | ||
|
|
9135a85de4 | ||
|
|
fd45d280b4 | ||
|
|
524adce654 | ||
|
|
f799c9b260 | ||
|
|
9c26ccf43d | ||
|
|
71a09bcf6e | ||
|
|
af31a6fc1b | ||
|
|
08b39500b3 | ||
|
|
f9fcc9f3c7 | ||
|
|
0812184995 | ||
|
|
646f8f60c0 | ||
|
|
f333db8e4f | ||
|
|
da42fc7457 | ||
|
|
48f1934387 | ||
|
|
2845e0003e | ||
|
|
1a189640f1 | ||
|
|
420f89af99 | ||
|
|
da1a66abd3 | ||
|
|
5d18e7df79 | ||
|
|
ba25a3e1b7 | ||
|
|
bc18dc7da6 | ||
|
|
5e8ec56196 | ||
|
|
9ca088a4e2 | ||
|
|
008e7a4d25 | ||
|
|
ce9b536b78 | ||
|
|
d9c50e5bc1 | ||
|
|
6e6f113336 | ||
|
|
f7441e2abc | ||
|
|
28c168145f | ||
|
|
c2115cab59 | ||
|
|
bf075f7dd8 | ||
|
|
a4fd673285 | ||
|
|
813d140213 | ||
|
|
3dc5942a85 | ||
|
|
03e2a9b200 | ||
|
|
8367a94e90 | ||
|
|
631546a68a | ||
|
|
7751022c66 | ||
|
|
f42ff59b43 | ||
|
|
104621841b | ||
|
|
c337439370 | ||
|
|
65ebdb7234 | ||
|
|
e708ce93ba | ||
|
|
1f69965c1e | ||
|
|
d7723b33f3 | ||
|
|
87e371ffde | ||
|
|
b649738718 | ||
|
|
022cbb9c00 | ||
|
|
40e112fc5b | ||
|
|
7cacbaadf0 | ||
|
|
a3e7e754b9 | ||
|
|
03ad288aaa | ||
|
|
811be3a36a | ||
|
|
3202f96181 | ||
|
|
f6a6b11ec5 | ||
|
|
48df8725d8 | ||
|
|
25bdd71477 | ||
|
|
deda331745 | ||
|
|
f6d3944b20 | ||
|
|
a50b0ea1e5 | ||
|
|
3c658e39ab | ||
|
|
d8354255e7 | ||
|
|
55b6a7842e | ||
|
|
0f113ec41f | ||
|
|
1fa5a31960 | ||
|
|
8be36455ab | ||
|
|
d1bd6d0e39 | ||
|
|
1660e72cc5 | ||
|
|
2d1f1abce4 | ||
|
|
7d74575eb8 | ||
|
|
91e613fe60 | ||
|
|
f3f2a0c1d5 | ||
|
|
1c2ae7bff6 | ||
|
|
78ebcb6f38 | ||
|
|
28dda39260 | ||
|
|
e2a72d16aa | ||
|
|
c724bfe4d3 | ||
|
|
6070d804f8 | ||
|
|
e794c977bc | ||
|
|
0b088ef1d3 | ||
|
|
5393465ea7 | ||
|
|
f5df811b15 | ||
|
|
a521f41838 | ||
|
|
0123d83fb2 | ||
|
|
559e392f1b | ||
|
|
8468b632a1 | ||
|
|
7053a8669f | ||
|
|
2c0a7346b1 | ||
|
|
bf6a6af683 | ||
|
|
914790fd99 | ||
|
|
69d702c783 | ||
|
|
dd92cf9e96 | ||
|
|
0cd0b44cdb | ||
|
|
d505642336 | ||
|
|
31c28be57a | ||
|
|
38db3a28ea | ||
|
|
09fa2d2c9c | ||
|
|
b786ed07be | ||
|
|
0527c4a1ea | ||
|
|
ec3713bc74 | ||
|
|
9fd5190c70 | ||
|
|
3995b01399 | ||
|
|
3fdb88c7aa | ||
|
|
8e4bb32b77 | ||
|
|
63d6272282 | ||
|
|
40a1377c0b | ||
|
|
e20c944350 | ||
|
|
85b7b10c01 | ||
|
|
35f73bb474 | ||
|
|
ffc9c28ad5 | ||
|
|
fcff206853 | ||
|
|
0e528986ab | ||
|
|
e7e83a4109 | ||
|
|
891543ff0a | ||
|
|
c617190905 | ||
|
|
2c1f20969a | ||
|
|
851ab47f8a | ||
|
|
bbf13e9242 | ||
|
|
05a24ea355 | ||
|
|
be736b3939 | ||
|
|
25c23a2e5f | ||
|
|
3b8ee3954e | ||
|
|
db79167469 | ||
|
|
b37e84dc10 | ||
|
|
4310d34135 | ||
|
|
09c6a3c240 | ||
|
|
796f4090b5 | ||
|
|
19a792bc12 | ||
|
|
a1b1f8138a | ||
|
|
0e627a6e05 | ||
|
|
d2cd33e226 | ||
|
|
2fa5c2581c | ||
|
|
d2260b234c | ||
|
|
832356d56e | ||
|
|
5fd1c07c9d | ||
|
|
4c75358abd | ||
|
|
d520d6cab8 | ||
|
|
737904fa63 | ||
|
|
a3fcc98d6e | ||
|
|
24a7e8500d | ||
|
|
9067902267 | ||
|
|
66c8809799 | ||
|
|
1fc994177f | ||
|
|
78b6450031 | ||
|
|
b4cb375a02 | ||
|
|
33e5c85503 | ||
|
|
9e8240a736 | ||
|
|
37afd35b6f | ||
|
|
6364c541ea | ||
|
|
8ec6b07690 | ||
|
|
7101ec09ed | ||
|
|
2c5efddf6c | ||
|
|
edb0c6a9e8 | ||
|
|
84049de696 | ||
|
|
a37bdffcd9 | ||
|
|
e95ab36f76 | ||
|
|
f809bd3a62 | ||
|
|
d4e71e431b | ||
|
|
de807f8538 | ||
|
|
80d2889217 | ||
|
|
9e8516c2df | ||
|
|
09f2bc28d2 | ||
|
|
be320c5501 | ||
|
|
2bbf7b2194 | ||
|
|
ab184c01d8 | ||
|
|
2c114e1a4a | ||
|
|
ec4cbbd004 | ||
|
|
f75091a1c5 | ||
|
|
98b59a1024 | ||
|
|
0ef06fd298 | ||
|
|
986346a0e9 | ||
|
|
2a65331573 | ||
|
|
45d0860448 | ||
|
|
da0531e63b | ||
|
|
421dc75f4e | ||
|
|
ea6eacb400 | ||
|
|
8ae91df038 | ||
|
|
64b41dd626 | ||
|
|
103649887f | ||
|
|
7b2fd515da | ||
|
|
3f61bfc43c | ||
|
|
905d339572 | ||
|
|
5d37a814fd | ||
|
|
f9c0edbd0c | ||
|
|
d084f225a0 | ||
|
|
ff3fb2ebb9 | ||
|
|
725ff5a328 | ||
|
|
f0ac454be1 | ||
|
|
0269f5122e | ||
|
|
6adc642d2f | ||
|
|
22a91c955d | ||
|
|
6951aa3d39 | ||
|
|
bd412ddbf9 | ||
|
|
7792da99ce | ||
|
|
98c6422fa6 | ||
|
|
25708542ff | ||
|
|
0fae807713 | ||
|
|
0f68be608d | ||
|
|
63056dbef4 | ||
|
|
803934d020 | ||
|
|
ffd6a1002e | ||
|
|
bf591765c1 | ||
|
|
06a7f1b54a | ||
|
|
3839bf6bf1 | ||
|
|
aee0e16194 | ||
|
|
1d3dbd6f6e | ||
|
|
1df9ec9647 | ||
|
|
d4143c3101 | ||
|
|
a03245e427 | ||
|
|
a090720241 | ||
|
|
b8b0afa0df | ||
|
|
f19bad8903 | ||
|
|
953402f2eb | ||
|
|
8c945034b9 | ||
|
|
900e853b15 | ||
|
|
b56f7355aa | ||
|
|
068a8a068c | ||
|
|
0e94fd44a8 | ||
|
|
ccbc68b560 | ||
|
|
f79b7bc799 | ||
|
|
60171b3522 | ||
|
|
8f3430d386 | ||
|
|
1ac1cf0c78 | ||
|
|
6dd89ba956 | ||
|
|
bf56254077 | ||
|
|
d933fe5dce | ||
|
|
391fb2cc62 | ||
|
|
af11e7dd54 | ||
|
|
af434d0216 | ||
|
|
931641ed2c | ||
|
|
b716fd2b8b | ||
|
|
a6a78d2ab5 | ||
|
|
67d7534d4f | ||
|
|
f21669c0c9 | ||
|
|
e18033ec1a | ||
|
|
5c5ea64228 | ||
|
|
90b4257889 | ||
|
|
f4388d5e4a | ||
|
|
7165481075 | ||
|
|
9fd7a6abed | ||
|
|
4757ed9453 | ||
|
|
97146a6359 | ||
|
|
d4f2fcdf79 |
@@ -3,6 +3,10 @@
|
||||
# Each option is shown with it's default value.
|
||||
# Do not copy this whole file to use as your '.env' file.
|
||||
|
||||
# The details here only serve as a quick reference.
|
||||
# Please refer to the BookStack documentation for full details:
|
||||
# https://www.bookstackapp.com/docs/
|
||||
|
||||
# Application environment
|
||||
# Can be 'production', 'development', 'testing' or 'demo'
|
||||
APP_ENV=production
|
||||
@@ -79,6 +83,10 @@ MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_VERIFY_SSL=true
|
||||
|
||||
# Command to use when email is sent via sendmail
|
||||
MAIL_SENDMAIL_COMMAND="/usr/sbin/sendmail -bs"
|
||||
|
||||
# Cache & Session driver to use
|
||||
# Can be 'file', 'database', 'memcached' or 'redis'
|
||||
@@ -268,6 +276,7 @@ OIDC_DUMP_USER_DETAILS=false
|
||||
OIDC_USER_TO_GROUPS=false
|
||||
OIDC_GROUPS_CLAIM=groups
|
||||
OIDC_REMOVE_FROM_GROUPS=false
|
||||
OIDC_EXTERNAL_ID_CLAIM=sub
|
||||
|
||||
# Disable default third-party services such as Gravatar and Draw.IO
|
||||
# Service-specific options will override this option
|
||||
@@ -318,6 +327,13 @@ FILE_UPLOAD_SIZE_LIMIT=50
|
||||
# Can be 'a4' or 'letter'.
|
||||
EXPORT_PAGE_SIZE=a4
|
||||
|
||||
# Set path to wkhtmltopdf binary for PDF generation.
|
||||
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
|
||||
# When false, BookStack will attempt to find a wkhtmltopdf in the application
|
||||
# root folder then fall back to the default dompdf renderer if no binary exists.
|
||||
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
|
||||
WKHTMLTOPDF=false
|
||||
|
||||
# Allow <script> tags in page content
|
||||
# Note, if set to 'true' the page editor may still escape scripts.
|
||||
ALLOW_CONTENT_SCRIPTS=false
|
||||
@@ -368,4 +384,4 @@ LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver
|
||||
# IP address '146.191.42.4' would result in '146.191.x.x' being logged.
|
||||
# For the IPv6 address '2001:db8:85a3:8d3:1319:8a2e:370:7348' this would result as:
|
||||
# '2001:db8:85a3:8d3:x:x:x:x'
|
||||
IP_ADDRESS_PRECISION=4
|
||||
IP_ADDRESS_PRECISION=4
|
||||
|
||||
55
.github/translators.txt
vendored
55
.github/translators.txt
vendored
@@ -176,7 +176,7 @@ Alexander Predl (Harveyhase68) :: German
|
||||
Rem (Rem9000) :: Dutch
|
||||
Michał Stelmach (stelmach-web) :: Polish
|
||||
arniom :: French
|
||||
REMOVED_USER :: ; Dutch; Turkish
|
||||
REMOVED_USER :: ; French; Dutch; Turkish
|
||||
林祖年 (contagion) :: Chinese Traditional
|
||||
Siamak Guodarzi (siamakgoudarzi88) :: Persian
|
||||
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
|
||||
@@ -280,3 +280,56 @@ DerLinkman (derlinkman) :: German; German Informal
|
||||
TurnArabic :: Arabic
|
||||
Martin Sebek (sebekmartin) :: Czech
|
||||
Kuchinashi Hoshikawa (kuchinashi) :: Chinese Simplified
|
||||
digilady :: Greek
|
||||
Linus (LinusOP) :: Swedish
|
||||
Felipe Cardoso (felipecardosoruff) :: Portuguese, Brazilian
|
||||
RandomUser0815 :: German Informal; German
|
||||
Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
|
||||
구인회 (laskdjlaskdj12) :: Korean
|
||||
LiZerui (CNLiZerui) :: Chinese Traditional
|
||||
Fabrice Boyer (FabriceBoyer) :: French
|
||||
mikael (bitcanon) :: Swedish
|
||||
Matthias Mai (schnapsidee) :: German; German Informal
|
||||
Ufuk Ayyıldız (ufukayyildiz) :: Turkish
|
||||
Jan Mitrof (jan.kachlik) :: Czech
|
||||
edwardsmirnov :: Russian
|
||||
Mr_OSS117 :: French
|
||||
shotu :: French
|
||||
Cesar_Lopez_Aguillon :: Spanish
|
||||
bdewoop :: German
|
||||
dina davoudi (dina.davoudi) :: Persian
|
||||
Angelos Chouvardas (achouvardas) :: Greek
|
||||
rndrss :: Portuguese, Brazilian
|
||||
rirac294 :: Russian
|
||||
David Furman (thefourCraft) :: Hebrew
|
||||
Pafzedog :: French
|
||||
Yllelder :: Spanish
|
||||
Adrian Ocneanu (aocneanu) :: Romanian
|
||||
Eduardo Castanho (EduardoCastanho) :: Portuguese
|
||||
VIET NAM VPS (vietnamvps) :: Vietnamese
|
||||
m4tthi4s :: French
|
||||
toras9000 :: Japanese
|
||||
pathab :: German
|
||||
MichelSchoon85 :: Dutch
|
||||
Jøran Haugli (haugli92) :: Norwegian Bokmal
|
||||
Vasileios Kouvelis (VasilisKouvelis) :: Greek
|
||||
Dremski :: Bulgarian
|
||||
Frédéric SENE (nothingfr) :: French
|
||||
bendem :: French
|
||||
kostasdizas :: Greek
|
||||
Ricardo Schroeder (brownstone666) :: Portuguese, Brazilian
|
||||
Eitan MG (EitanMG) :: Hebrew
|
||||
Robin Flikkema (RobinFlikkema) :: Dutch
|
||||
Michal Gurcik (mgurcik) :: Slovak
|
||||
Pooyan Arab (pooyanarab) :: Persian
|
||||
Ochi Darma Putra (troke12) :: Indonesian
|
||||
H.-H. Peng (Hsins) :: Chinese Traditional
|
||||
Mosi Wang (mosiwang) :: Chinese Traditional
|
||||
骆言 (LawssssCat) :: Chinese Simplified
|
||||
Stickers Gaming Shøw (StickerSGSHOW) :: French
|
||||
Le Van Chinh (Chino) (lvanchinh86) :: Vietnamese
|
||||
Rubens nagios (rubenix) :: Catalan
|
||||
Patrick Dantas (pa-tiq) :: Portuguese, Brazilian
|
||||
Michal (michalgurcik) :: Slovak
|
||||
Nepomacs :: German
|
||||
Rubens (rubenix) :: Catalan
|
||||
|
||||
4
.github/workflows/analyse-php.yml
vendored
4
.github/workflows/analyse-php.yml
vendored
@@ -18,10 +18,10 @@ jobs:
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-8.1
|
||||
|
||||
16
.github/workflows/lint-js.yml
vendored
Normal file
16
.github/workflows/lint-js.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: lint-js
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Install NPM deps
|
||||
run: npm ci
|
||||
|
||||
- name: Run formatting check
|
||||
run: npm run lint
|
||||
6
.github/workflows/test-migrations.yml
vendored
6
.github/workflows/test-migrations.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.4', '8.0', '8.1']
|
||||
php: ['8.0', '8.1', '8.2']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
@@ -21,10 +21,10 @@ jobs:
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
|
||||
8
.github/workflows/test-php.yml
vendored
8
.github/workflows/test-php.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.4', '8.0', '8.1']
|
||||
php: ['8.0', '8.1', '8.2']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
@@ -16,15 +16,15 @@ jobs:
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap, gmp
|
||||
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v2
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,5 +1,7 @@
|
||||
/vendor
|
||||
/node_modules
|
||||
/.vscode
|
||||
/composer
|
||||
Homestead.yaml
|
||||
.env
|
||||
.idea
|
||||
@@ -11,6 +13,7 @@ yarn-error.log
|
||||
/public/js/*.map
|
||||
/public/bower
|
||||
/public/build/
|
||||
/public/favicon.ico
|
||||
/storage/images
|
||||
_ide_helper.php
|
||||
/storage/debugbar
|
||||
@@ -20,8 +23,10 @@ yarn.lock
|
||||
nbproject
|
||||
.buildpath
|
||||
.project
|
||||
.nvmrc
|
||||
.settings/
|
||||
webpack-stats.json
|
||||
.phpunit.result.cache
|
||||
.DS_Store
|
||||
phpstan.neon
|
||||
phpstan.neon
|
||||
esbuild-meta.json
|
||||
3
LICENSE
3
LICENSE
@@ -1,7 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present, Dan Brown and the BookStack Project contributors
|
||||
https://github.com/BookStackApp/BookStack/graphs/contributors
|
||||
Copyright (c) 2015-2023, Dan Brown and the BookStack Project contributors.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -40,6 +42,12 @@ class Activity extends Model
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
|
||||
->whereColumn('activities.entity_type', '=', 'joint_permissions.entity_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns text from the language files, Looks up by using the activity key.
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class Favourite extends Model
|
||||
@@ -16,4 +18,10 @@ class Favourite extends Model
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'favouritable_id')
|
||||
->whereColumn('favourites.favouritable_type', '=', 'joint_permissions.entity_type');
|
||||
}
|
||||
}
|
||||
|
||||
30
app/Actions/Queries/WebhooksAllPaginatedAndSorted.php
Normal file
30
app/Actions/Queries/WebhooksAllPaginatedAndSorted.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions\Queries;
|
||||
|
||||
use BookStack\Actions\Webhook;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Get all the webhooks in the system in a paginated format.
|
||||
*/
|
||||
class WebhooksAllPaginatedAndSorted
|
||||
{
|
||||
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
|
||||
{
|
||||
$query = Webhook::query()->select(['*'])
|
||||
->withCount(['trackedEvents'])
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder());
|
||||
|
||||
if ($listOptions->getSearch()) {
|
||||
$term = '%' . $listOptions->getSearch() . '%';
|
||||
$query->where(function ($query) use ($term) {
|
||||
$query->where('name', 'like', $term)
|
||||
->orWhere('endpoint', 'like', $term);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->paginate($count);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
@@ -27,6 +29,12 @@ class Tag extends Model
|
||||
return $this->morphTo('entity');
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
|
||||
->whereColumn('tags.entity_type', '=', 'joint_permissions.entity_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a full URL to start a tag name search for this tag name.
|
||||
*/
|
||||
|
||||
@@ -4,24 +4,29 @@ namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TagRepo
|
||||
{
|
||||
protected PermissionApplicator $permissions;
|
||||
|
||||
public function __construct(PermissionApplicator $permissions)
|
||||
{
|
||||
$this->permissions = $permissions;
|
||||
public function __construct(
|
||||
protected PermissionApplicator $permissions
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query against all tags in the system.
|
||||
*/
|
||||
public function queryWithTotals(string $searchTerm, string $nameFilter): Builder
|
||||
public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder
|
||||
{
|
||||
$searchTerm = $listOptions->getSearch();
|
||||
$sort = $listOptions->getSort();
|
||||
if ($sort === 'name' && $nameFilter) {
|
||||
$sort = 'value';
|
||||
}
|
||||
|
||||
$query = Tag::query()
|
||||
->select([
|
||||
'name',
|
||||
@@ -32,7 +37,7 @@ class TagRepo
|
||||
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
|
||||
])
|
||||
->orderBy($nameFilter ? 'value' : 'name');
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
if ($nameFilter) {
|
||||
$query->where('name', '=', $nameFilter);
|
||||
@@ -57,21 +62,21 @@ class TagRepo
|
||||
* Get tag name suggestions from scanning existing tag names.
|
||||
* If no search term is given the 50 most popular tag names are provided.
|
||||
*/
|
||||
public function getNameSuggestions(?string $searchTerm): Collection
|
||||
public function getNameSuggestions(string $searchTerm): Collection
|
||||
{
|
||||
$query = Tag::query()
|
||||
->select('*', DB::raw('count(*) as count'))
|
||||
->groupBy('name');
|
||||
|
||||
if ($searchTerm) {
|
||||
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
|
||||
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'asc');
|
||||
} else {
|
||||
$query = $query->orderBy('count', 'desc')->take(50);
|
||||
}
|
||||
|
||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
|
||||
return $query->get(['name'])->pluck('name');
|
||||
return $query->pluck('name');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,10 +84,11 @@ class TagRepo
|
||||
* If no search is given the 50 most popular values are provided.
|
||||
* Passing a tagName will only find values for a tags with a particular name.
|
||||
*/
|
||||
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
|
||||
public function getValueSuggestions(string $searchTerm, string $tagName): Collection
|
||||
{
|
||||
$query = Tag::query()
|
||||
->select('*', DB::raw('count(*) as count'))
|
||||
->where('value', '!=', '')
|
||||
->groupBy('value');
|
||||
|
||||
if ($searchTerm) {
|
||||
@@ -97,7 +103,7 @@ class TagRepo
|
||||
|
||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
|
||||
return $query->get(['value'])->pluck('value');
|
||||
return $query->pluck('value');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Interfaces\Viewable;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
@@ -28,6 +30,12 @@ class View extends Model
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'viewable_id')
|
||||
->whereColumn('views.viewable_type', '=', 'joint_permissions.entity_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the current user's view count for the given viewable model.
|
||||
*/
|
||||
|
||||
107
app/Api/ApiEntityListFormatter.php
Normal file
107
app/Api/ApiEntityListFormatter.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
|
||||
class ApiEntityListFormatter
|
||||
{
|
||||
/**
|
||||
* The list to be formatted.
|
||||
* @var Entity[]
|
||||
*/
|
||||
protected $list = [];
|
||||
|
||||
/**
|
||||
* The fields to show in the formatted data.
|
||||
* Can be a plain string array item for a direct model field (If existing on model).
|
||||
* If the key is a string, with a callable value, the return value of the callable
|
||||
* 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',
|
||||
];
|
||||
|
||||
public function __construct(array $list)
|
||||
{
|
||||
$this->list = $list;
|
||||
|
||||
// Default dynamic fields
|
||||
$this->withField('url', fn(Entity $entity) => $entity->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a field to be used in the formatter, with the property using the given
|
||||
* name and value being the return type of the given callback.
|
||||
*/
|
||||
public function withField(string $property, callable $callback): self
|
||||
{
|
||||
$this->fields[$property] = $callback;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the 'type' property in the response reflecting the entity type.
|
||||
* EG: page, chapter, bookshelf, book
|
||||
* To be included in results with non-pre-determined types.
|
||||
*/
|
||||
public function withType(): self
|
||||
{
|
||||
$this->withField('type', fn(Entity $entity) => $entity->getType());
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Include tags in the formatted data.
|
||||
*/
|
||||
public function withTags(): self
|
||||
{
|
||||
$this->withField('tags', fn(Entity $entity) => $entity->tags);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the data and return an array of formatted content.
|
||||
* @return array[]
|
||||
*/
|
||||
public function format(): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($this->list as $item) {
|
||||
$results[] = $this->formatSingle($item);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single entity item to a plain array.
|
||||
*/
|
||||
protected function formatSingle(Entity $entity): array
|
||||
{
|
||||
$result = [];
|
||||
$values = (clone $entity)->toArray();
|
||||
|
||||
foreach ($this->fields as $field => $callback) {
|
||||
if (is_string($callback)) {
|
||||
$field = $callback;
|
||||
if (!isset($values[$field])) {
|
||||
continue;
|
||||
}
|
||||
$value = $values[$field];
|
||||
} else {
|
||||
$value = $callback($entity);
|
||||
if (is_null($value)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$result[$field] = $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,31 @@
|
||||
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ListingResponseBuilder
|
||||
{
|
||||
protected $query;
|
||||
protected $request;
|
||||
protected $fields;
|
||||
protected Builder $query;
|
||||
protected Request $request;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $fields;
|
||||
|
||||
/**
|
||||
* @var array<callable>
|
||||
*/
|
||||
protected $resultModifiers = [];
|
||||
protected array $resultModifiers = [];
|
||||
|
||||
protected $filterOperators = [
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected array $filterOperators = [
|
||||
'eq' => '=',
|
||||
'ne' => '!=',
|
||||
'gt' => '>',
|
||||
@@ -63,9 +70,9 @@ class ListingResponseBuilder
|
||||
/**
|
||||
* Add a callback to modify each element of the results.
|
||||
*
|
||||
* @param (callable(Model)) $modifier
|
||||
* @param (callable(Model): void) $modifier
|
||||
*/
|
||||
public function modifyResults($modifier): void
|
||||
public function modifyResults(callable $modifier): void
|
||||
{
|
||||
$this->resultModifiers[] = $modifier;
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ use BookStack\Notifications\ConfirmEmail;
|
||||
|
||||
class EmailConfirmationService extends UserTokenService
|
||||
{
|
||||
protected $tokenTable = 'email_confirmations';
|
||||
protected $expiryTime = 24;
|
||||
protected string $tokenTable = 'email_confirmations';
|
||||
protected int $expiryTime = 24;
|
||||
|
||||
/**
|
||||
* Create new confirmation for a user,
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Auth\Access;
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Access\Mfa\MfaSession;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Facades\Theme;
|
||||
@@ -149,6 +150,7 @@ class LoginService
|
||||
* May interrupt the flow if extra authentication requirements are imposed.
|
||||
*
|
||||
* @throws StoppedAuthenticationException
|
||||
* @throws LoginAttemptException
|
||||
*/
|
||||
public function attempt(array $credentials, string $method, bool $remember = false): bool
|
||||
{
|
||||
|
||||
@@ -4,35 +4,16 @@ namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
class OidcIdToken
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $header;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $payload;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature;
|
||||
protected array $header;
|
||||
protected array $payload;
|
||||
protected string $signature;
|
||||
protected string $issuer;
|
||||
protected array $tokenParts = [];
|
||||
|
||||
/**
|
||||
* @var array[]|string[]
|
||||
*/
|
||||
protected $keys;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $issuer;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $tokenParts = [];
|
||||
protected array $keys;
|
||||
|
||||
public function __construct(string $token, string $issuer, array $keys)
|
||||
{
|
||||
@@ -106,6 +87,14 @@ class OidcIdToken
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the existing claim data of this token with that provided.
|
||||
*/
|
||||
public function replaceClaims(array $claims): void
|
||||
{
|
||||
$this->payload = $claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the structure of the given token and ensure we have the required pieces.
|
||||
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
|
||||
|
||||
@@ -67,11 +67,10 @@ class OidcJwtSigningKey
|
||||
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
|
||||
}
|
||||
|
||||
if (empty($jwk['use'])) {
|
||||
throw new OidcInvalidKeyException('A "use" parameter on the provided key is expected');
|
||||
}
|
||||
|
||||
if ($jwk['use'] !== 'sig') {
|
||||
// 'use' is optional for a JWK but we assume 'sig' where no value exists since that's what
|
||||
// the OIDC discovery spec infers since 'sig' MUST be set if encryption keys come into play.
|
||||
$use = $jwk['use'] ?? 'sig';
|
||||
if ($use !== 'sig') {
|
||||
throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}");
|
||||
}
|
||||
|
||||
|
||||
@@ -15,40 +15,17 @@ use Psr\Http\Client\ClientInterface;
|
||||
*/
|
||||
class OidcProviderSettings
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $issuer;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $clientId;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $clientSecret;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $redirectUri;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $authorizationEndpoint;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $tokenEndpoint;
|
||||
public string $issuer;
|
||||
public string $clientId;
|
||||
public string $clientSecret;
|
||||
public ?string $redirectUri;
|
||||
public ?string $authorizationEndpoint;
|
||||
public ?string $tokenEndpoint;
|
||||
|
||||
/**
|
||||
* @var string[]|array[]
|
||||
*/
|
||||
public $keys = [];
|
||||
public ?array $keys = [];
|
||||
|
||||
public function __construct(array $settings)
|
||||
{
|
||||
@@ -164,9 +141,10 @@ class OidcProviderSettings
|
||||
protected function filterKeys(array $keys): array
|
||||
{
|
||||
return array_filter($keys, function (array $key) {
|
||||
$alg = $key['alg'] ?? null;
|
||||
$alg = $key['alg'] ?? 'RS256';
|
||||
$use = $key['use'] ?? 'sig';
|
||||
|
||||
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && (is_null($alg) || $alg === 'RS256');
|
||||
return $key['kty'] === 'RSA' && $use === 'sig' && $alg === 'RS256';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
|
||||
@@ -21,24 +23,12 @@ use Psr\Http\Client\ClientInterface as HttpClient;
|
||||
*/
|
||||
class OidcService
|
||||
{
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
protected HttpClient $httpClient;
|
||||
protected GroupSyncService $groupService;
|
||||
|
||||
/**
|
||||
* OpenIdService constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService,
|
||||
HttpClient $httpClient,
|
||||
GroupSyncService $groupService
|
||||
protected RegistrationService $registrationService,
|
||||
protected LoginService $loginService,
|
||||
protected HttpClient $httpClient,
|
||||
protected GroupSyncService $groupService
|
||||
) {
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
$this->httpClient = $httpClient;
|
||||
$this->groupService = $groupService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +42,6 @@ class OidcService
|
||||
{
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
|
||||
return [
|
||||
'url' => $provider->getAuthorizationUrl(),
|
||||
'state' => $provider->getState(),
|
||||
@@ -199,7 +188,8 @@ class OidcService
|
||||
*/
|
||||
protected function getUserDetails(OidcIdToken $token): array
|
||||
{
|
||||
$id = $token->getClaim('sub');
|
||||
$idClaim = $this->config()['external_id_claim'];
|
||||
$id = $token->getClaim($idClaim);
|
||||
|
||||
return [
|
||||
'external_id' => $id,
|
||||
@@ -226,6 +216,16 @@ class OidcService
|
||||
$settings->keys,
|
||||
);
|
||||
|
||||
$returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [
|
||||
'access_token' => $accessToken->getToken(),
|
||||
'expires_in' => $accessToken->getExpires(),
|
||||
'refresh_token' => $accessToken->getRefreshToken(),
|
||||
]);
|
||||
|
||||
if (!is_null($returnClaims)) {
|
||||
$idToken->replaceClaims($returnClaims);
|
||||
}
|
||||
|
||||
if ($this->config()['dump_user_details']) {
|
||||
throw new JsonDebugException($idToken->getAllClaims());
|
||||
}
|
||||
|
||||
@@ -20,14 +20,11 @@ use OneLogin\Saml2\ValidationError;
|
||||
*/
|
||||
class Saml2Service
|
||||
{
|
||||
protected $config;
|
||||
protected $registrationService;
|
||||
protected $loginService;
|
||||
protected $groupSyncService;
|
||||
protected array $config;
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
protected GroupSyncService $groupSyncService;
|
||||
|
||||
/**
|
||||
* Saml2Service constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService,
|
||||
@@ -70,7 +67,7 @@ class Saml2Service
|
||||
$returnRoute,
|
||||
[],
|
||||
$user->email,
|
||||
null,
|
||||
session()->get('saml2_session_index'),
|
||||
true,
|
||||
Constants::NAMEID_EMAIL_ADDRESS
|
||||
);
|
||||
@@ -121,6 +118,7 @@ class Saml2Service
|
||||
|
||||
$attrs = $toolkit->getAttributes();
|
||||
$id = $toolkit->getNameId();
|
||||
session()->put('saml2_session_index', $toolkit->getSessionIndex());
|
||||
|
||||
return $this->processLoginCallback($id, $attrs);
|
||||
}
|
||||
@@ -169,7 +167,7 @@ class Saml2Service
|
||||
*/
|
||||
public function metadata(): string
|
||||
{
|
||||
$toolKit = $this->getToolkit();
|
||||
$toolKit = $this->getToolkit(true);
|
||||
$settings = $toolKit->getSettings();
|
||||
$metadata = $settings->getSPMetadata();
|
||||
$errors = $settings->validateMetadata($metadata);
|
||||
@@ -190,7 +188,7 @@ class Saml2Service
|
||||
* @throws Error
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function getToolkit(): Auth
|
||||
protected function getToolkit(bool $spOnly = false): Auth
|
||||
{
|
||||
$settings = $this->config['onelogin'];
|
||||
$overrides = $this->config['onelogin_overrides'] ?? [];
|
||||
@@ -200,14 +198,14 @@ class Saml2Service
|
||||
}
|
||||
|
||||
$metaDataSettings = [];
|
||||
if ($this->config['autoload_from_metadata']) {
|
||||
if (!$spOnly && $this->config['autoload_from_metadata']) {
|
||||
$metaDataSettings = IdPMetadataParser::parseRemoteXML($settings['idp']['entityId']);
|
||||
}
|
||||
|
||||
$spSettings = $this->loadOneloginServiceProviderDetails();
|
||||
$settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides);
|
||||
|
||||
return new Auth($settings);
|
||||
return new Auth($settings, $spOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,14 +7,12 @@ use BookStack\Notifications\UserInvite;
|
||||
|
||||
class UserInviteService extends UserTokenService
|
||||
{
|
||||
protected $tokenTable = 'user_invites';
|
||||
protected $expiryTime = 336; // Two weeks
|
||||
protected string $tokenTable = 'user_invites';
|
||||
protected int $expiryTime = 336; // Two weeks
|
||||
|
||||
/**
|
||||
* Send an invitation to a user to sign into BookStack
|
||||
* Removes existing invitation tokens.
|
||||
*
|
||||
* @param User $user
|
||||
*/
|
||||
public function sendInvitation(User $user)
|
||||
{
|
||||
|
||||
@@ -14,41 +14,29 @@ class UserTokenService
|
||||
{
|
||||
/**
|
||||
* Name of table where user tokens are stored.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $tokenTable = 'user_tokens';
|
||||
protected string $tokenTable = 'user_tokens';
|
||||
|
||||
/**
|
||||
* Token expiry time in hours.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
protected $expiryTime = 24;
|
||||
protected int $expiryTime = 24;
|
||||
|
||||
/**
|
||||
* Delete all email confirmations that belong to a user.
|
||||
*
|
||||
* @param User $user
|
||||
*
|
||||
* @return mixed
|
||||
* Delete all tokens that belong to a user.
|
||||
*/
|
||||
public function deleteByUser(User $user)
|
||||
public function deleteByUser(User $user): void
|
||||
{
|
||||
return DB::table($this->tokenTable)
|
||||
DB::table($this->tokenTable)
|
||||
->where('user_id', '=', $user->id)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user id from a token, while check the token exists and has not expired.
|
||||
*
|
||||
* @param string $token
|
||||
* Get the user id from a token, while checking the token exists and has not expired.
|
||||
*
|
||||
* @throws UserTokenNotFoundException
|
||||
* @throws UserTokenExpiredException
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function checkTokenAndGetUserId(string $token): int
|
||||
{
|
||||
@@ -67,8 +55,6 @@ class UserTokenService
|
||||
|
||||
/**
|
||||
* Creates a unique token within the email confirmation database.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function generateToken(): string
|
||||
{
|
||||
@@ -82,10 +68,6 @@ class UserTokenService
|
||||
|
||||
/**
|
||||
* Generate and store a token for the given user.
|
||||
*
|
||||
* @param User $user
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function createTokenForUser(User $user): string
|
||||
{
|
||||
@@ -102,10 +84,6 @@ class UserTokenService
|
||||
|
||||
/**
|
||||
* Check if the given token exists.
|
||||
*
|
||||
* @param string $token
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function tokenExists(string $token): bool
|
||||
{
|
||||
@@ -115,12 +93,8 @@ class UserTokenService
|
||||
|
||||
/**
|
||||
* Get a token entry for the given token.
|
||||
*
|
||||
* @param string $token
|
||||
*
|
||||
* @return object|null
|
||||
*/
|
||||
protected function getEntryByToken(string $token)
|
||||
protected function getEntryByToken(string $token): ?stdClass
|
||||
{
|
||||
return DB::table($this->tokenTable)
|
||||
->where('token', '=', $token)
|
||||
@@ -129,10 +103,6 @@ class UserTokenService
|
||||
|
||||
/**
|
||||
* Check if the given token entry has expired.
|
||||
*
|
||||
* @param stdClass $tokenEntry
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function entryExpired(stdClass $tokenEntry): bool
|
||||
{
|
||||
|
||||
@@ -2,20 +2,40 @@
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $role_id
|
||||
* @property int $entity_id
|
||||
* @property string $entity_type
|
||||
* @property boolean $view
|
||||
* @property boolean $create
|
||||
* @property boolean $update
|
||||
* @property boolean $delete
|
||||
*/
|
||||
class EntityPermission extends Model
|
||||
{
|
||||
protected $fillable = ['role_id', 'action'];
|
||||
public const PERMISSIONS = ['view', 'create', 'update', 'delete'];
|
||||
|
||||
protected $fillable = ['role_id', 'view', 'create', 'update', 'delete'];
|
||||
public $timestamps = false;
|
||||
protected $hidden = ['entity_id', 'entity_type', 'id'];
|
||||
protected $casts = [
|
||||
'view' => 'boolean',
|
||||
'create' => 'boolean',
|
||||
'read' => 'boolean',
|
||||
'update' => 'boolean',
|
||||
'delete' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all this restriction's attached entity.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
|
||||
* Get the role assigned to this entity permission.
|
||||
*/
|
||||
public function restrictable()
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->morphTo('restrictable');
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
}
|
||||
|
||||
141
app/Auth/Permissions/EntityPermissionEvaluator.php
Normal file
141
app/Auth/Permissions/EntityPermissionEvaluator.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class EntityPermissionEvaluator
|
||||
{
|
||||
protected string $action;
|
||||
|
||||
public function __construct(string $action)
|
||||
{
|
||||
$this->action = $action;
|
||||
}
|
||||
|
||||
public function evaluateEntityForUser(Entity $entity, array $userRoleIds): ?bool
|
||||
{
|
||||
if ($this->isUserSystemAdmin($userRoleIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$typeIdChain = $this->gatherEntityChainTypeIds(SimpleEntityData::fromEntity($entity));
|
||||
$relevantPermissions = $this->getPermissionsMapByTypeId($typeIdChain, [...$userRoleIds, 0]);
|
||||
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions);
|
||||
|
||||
$status = $this->evaluatePermitsByType($permitsByType);
|
||||
|
||||
return is_null($status) ? null : $status === PermissionStatus::IMPLICIT_ALLOW || $status === PermissionStatus::EXPLICIT_ALLOW;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, array<string, int>> $permitsByType
|
||||
*/
|
||||
protected function evaluatePermitsByType(array $permitsByType): ?int
|
||||
{
|
||||
// Return grant or reject from role-level if exists
|
||||
if (count($permitsByType['role']) > 0) {
|
||||
return max($permitsByType['role']) ? PermissionStatus::EXPLICIT_ALLOW : PermissionStatus::EXPLICIT_DENY;
|
||||
}
|
||||
|
||||
// Return fallback permission if exists
|
||||
if (count($permitsByType['fallback']) > 0) {
|
||||
return $permitsByType['fallback'][0] ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $typeIdChain
|
||||
* @param array<string, EntityPermission[]> $permissionMapByTypeId
|
||||
* @return array<string, array<string, int>>
|
||||
*/
|
||||
protected function collapseAndCategorisePermissions(array $typeIdChain, array $permissionMapByTypeId): array
|
||||
{
|
||||
$permitsByType = ['fallback' => [], 'role' => []];
|
||||
|
||||
foreach ($typeIdChain as $typeId) {
|
||||
$permissions = $permissionMapByTypeId[$typeId] ?? [];
|
||||
foreach ($permissions as $permission) {
|
||||
$roleId = $permission->role_id;
|
||||
$type = $roleId === 0 ? 'fallback' : 'role';
|
||||
if (!isset($permitsByType[$type][$roleId])) {
|
||||
$permitsByType[$type][$roleId] = $permission->{$this->action};
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($permitsByType['fallback'][0])) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $permitsByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $typeIdChain
|
||||
* @return array<string, EntityPermission[]>
|
||||
*/
|
||||
protected function getPermissionsMapByTypeId(array $typeIdChain, array $filterRoleIds): array
|
||||
{
|
||||
$query = EntityPermission::query()->where(function (Builder $query) use ($typeIdChain) {
|
||||
foreach ($typeIdChain as $typeId) {
|
||||
$query->orWhere(function (Builder $query) use ($typeId) {
|
||||
[$type, $id] = explode(':', $typeId);
|
||||
$query->where('entity_type', '=', $type)
|
||||
->where('entity_id', '=', $id);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!empty($filterRoleIds)) {
|
||||
$query->where(function (Builder $query) use ($filterRoleIds) {
|
||||
$query->whereIn('role_id', [...$filterRoleIds, 0]);
|
||||
});
|
||||
}
|
||||
|
||||
$relevantPermissions = $query->get(['entity_id', 'entity_type', 'role_id', $this->action])->all();
|
||||
|
||||
$map = [];
|
||||
foreach ($relevantPermissions as $permission) {
|
||||
$key = $permission->entity_type . ':' . $permission->entity_id;
|
||||
if (!isset($map[$key])) {
|
||||
$map[$key] = [];
|
||||
}
|
||||
|
||||
$map[$key][] = $permission;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
protected function gatherEntityChainTypeIds(SimpleEntityData $entity): array
|
||||
{
|
||||
// The array order here is very important due to the fact we walk up the chain
|
||||
// elsewhere in the class. Earlier items in the chain have higher priority.
|
||||
|
||||
$chain = [$entity->type . ':' . $entity->id];
|
||||
|
||||
if ($entity->type === 'page' && $entity->chapter_id) {
|
||||
$chain[] = 'chapter:' . $entity->chapter_id;
|
||||
}
|
||||
|
||||
if ($entity->type === 'page' || $entity->type === 'chapter') {
|
||||
$chain[] = 'book:' . $entity->book_id;
|
||||
}
|
||||
|
||||
return $chain;
|
||||
}
|
||||
|
||||
protected function isUserSystemAdmin($userRoleIds): bool
|
||||
{
|
||||
$adminRoleId = Role::getSystemRole('admin')->id;
|
||||
return in_array($adminRoleId, $userRoleIds);
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,6 @@ use Illuminate\Support\Facades\DB;
|
||||
*/
|
||||
class JointPermissionBuilder
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<int, SimpleEntityData>>
|
||||
*/
|
||||
protected $entityCache;
|
||||
|
||||
/**
|
||||
* Re-generate all entity permission from scratch.
|
||||
*/
|
||||
@@ -40,7 +35,7 @@ class JointPermissionBuilder
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
|
||||
Bookshelf::query()->withTrashed()->select(['id', 'owned_by'])
|
||||
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
|
||||
$this->createManyJointPermissions($shelves->all(), $roles);
|
||||
});
|
||||
@@ -92,58 +87,24 @@ class JointPermissionBuilder
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->select(['id', 'restricted', 'owned_by'])
|
||||
Bookshelf::query()->select(['id', 'owned_by'])
|
||||
->chunk(50, function ($shelves) use ($roles) {
|
||||
$this->createManyJointPermissions($shelves->all(), $roles);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the local entity cache and ensure it's empty.
|
||||
*
|
||||
* @param SimpleEntityData[] $entities
|
||||
*/
|
||||
protected function readyEntityCache(array $entities)
|
||||
{
|
||||
$this->entityCache = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
if (!isset($this->entityCache[$entity->type])) {
|
||||
$this->entityCache[$entity->type] = [];
|
||||
}
|
||||
|
||||
$this->entityCache[$entity->type][$entity->id] = $entity;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a book via ID, Checks local cache.
|
||||
*/
|
||||
protected function getBook(int $bookId): SimpleEntityData
|
||||
{
|
||||
return $this->entityCache['book'][$bookId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a chapter via ID, Checks local cache.
|
||||
*/
|
||||
protected function getChapter(int $chapterId): SimpleEntityData
|
||||
{
|
||||
return $this->entityCache['chapter'][$chapterId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a query for fetching a book with its children.
|
||||
*/
|
||||
protected function bookFetchQuery(): Builder
|
||||
{
|
||||
return Book::query()->withTrashed()
|
||||
->select(['id', 'restricted', 'owned_by'])->with([
|
||||
->select(['id', 'owned_by'])->with([
|
||||
'chapters' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
|
||||
$query->withTrashed()->select(['id', 'owned_by', 'book_id']);
|
||||
},
|
||||
'pages' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
|
||||
$query->withTrashed()->select(['id', 'owned_by', 'book_id', 'chapter_id']);
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -214,14 +175,7 @@ class JointPermissionBuilder
|
||||
$simpleEntities = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$attrs = $entity->getAttributes();
|
||||
$simple = new SimpleEntityData();
|
||||
$simple->id = $attrs['id'];
|
||||
$simple->type = $entity->getMorphClass();
|
||||
$simple->restricted = boolval($attrs['restricted'] ?? 0);
|
||||
$simple->owned_by = $attrs['owned_by'] ?? 0;
|
||||
$simple->book_id = $attrs['book_id'] ?? null;
|
||||
$simple->chapter_id = $attrs['chapter_id'] ?? null;
|
||||
$simple = SimpleEntityData::fromEntity($entity);
|
||||
$simpleEntities[] = $simple;
|
||||
}
|
||||
|
||||
@@ -231,31 +185,16 @@ class JointPermissionBuilder
|
||||
/**
|
||||
* Create & Save entity jointPermissions for many entities and roles.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
* @param Entity[] $originalEntities
|
||||
* @param Role[] $roles
|
||||
*/
|
||||
protected function createManyJointPermissions(array $originalEntities, array $roles)
|
||||
{
|
||||
$entities = $this->entitiesToSimpleEntities($originalEntities);
|
||||
$this->readyEntityCache($entities);
|
||||
$jointPermissions = [];
|
||||
|
||||
// Create a mapping of entity restricted statuses
|
||||
$entityRestrictedMap = [];
|
||||
foreach ($entities as $entity) {
|
||||
$entityRestrictedMap[$entity->type . ':' . $entity->id] = $entity->restricted;
|
||||
}
|
||||
|
||||
// Fetch related entity permissions
|
||||
$permissions = $this->getEntityPermissionsForEntities($entities);
|
||||
|
||||
// Create a mapping of explicit entity permissions
|
||||
$permissionMap = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id;
|
||||
$isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
|
||||
$permissionMap[$key] = $isRestricted;
|
||||
}
|
||||
$permissions = new MassEntityPermissionEvaluator($entities, 'view');
|
||||
|
||||
// Create a mapping of role permissions
|
||||
$rolePermissionMap = [];
|
||||
@@ -268,13 +207,14 @@ class JointPermissionBuilder
|
||||
// Create Joint Permission Data
|
||||
foreach ($entities as $entity) {
|
||||
foreach ($roles as $role) {
|
||||
$jointPermissions[] = $this->createJointPermissionData(
|
||||
$jp = $this->createJointPermissionData(
|
||||
$entity,
|
||||
$role->getRawAttribute('id'),
|
||||
$permissionMap,
|
||||
$permissions,
|
||||
$rolePermissionMap,
|
||||
$role->system_name === 'admin'
|
||||
);
|
||||
$jointPermissions[] = $jp;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,98 +248,45 @@ class JointPermissionBuilder
|
||||
return $idsByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permissions for all the given entities.
|
||||
*
|
||||
* @param SimpleEntityData[] $entities
|
||||
*
|
||||
* @return EntityPermission[]
|
||||
*/
|
||||
protected function getEntityPermissionsForEntities(array $entities): array
|
||||
{
|
||||
$idsByType = $this->entitiesToTypeIdMap($entities);
|
||||
$permissionFetch = EntityPermission::query()
|
||||
->where('action', '=', 'view')
|
||||
->where(function (Builder $query) use ($idsByType) {
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
$query->orWhere(function (Builder $query) use ($type, $ids) {
|
||||
$query->where('restrictable_type', '=', $type)->whereIn('restrictable_id', $ids);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return $permissionFetch->get()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create entity permission data for an entity and role
|
||||
* for a particular action.
|
||||
*/
|
||||
protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, array $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
|
||||
protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, MassEntityPermissionEvaluator $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
|
||||
{
|
||||
// Ensure system admin role retains permissions
|
||||
if ($isAdminRole) {
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, PermissionStatus::EXPLICIT_ALLOW, true);
|
||||
}
|
||||
|
||||
// Return evaluated entity permission status if it has an affect.
|
||||
$entityPermissionStatus = $permissionMap->evaluateEntityForRole($entity, $roleId);
|
||||
if ($entityPermissionStatus !== null) {
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, $entityPermissionStatus, false);
|
||||
}
|
||||
|
||||
// Otherwise default to the role-level permissions
|
||||
$permissionPrefix = $entity->type . '-view';
|
||||
$roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']);
|
||||
$roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']);
|
||||
|
||||
if ($isAdminRole) {
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, true, true);
|
||||
}
|
||||
|
||||
if ($entity->restricted) {
|
||||
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $roleId);
|
||||
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, $hasAccess, $hasAccess);
|
||||
}
|
||||
|
||||
if ($entity->type === 'book' || $entity->type === 'bookshelf') {
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, $roleHasPermission, $roleHasPermissionOwn);
|
||||
}
|
||||
|
||||
// For chapters and pages, Check if explicit permissions are set on the Book.
|
||||
$book = $this->getBook($entity->book_id);
|
||||
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $roleId);
|
||||
$hasPermissiveAccessToParents = !$book->restricted;
|
||||
|
||||
// For pages with a chapter, Check if explicit permissions are set on the Chapter
|
||||
if ($entity->type === 'page' && $entity->chapter_id !== 0) {
|
||||
$chapter = $this->getChapter($entity->chapter_id);
|
||||
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
|
||||
if ($chapter->restricted) {
|
||||
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $roleId);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->createJointPermissionDataArray(
|
||||
$entity,
|
||||
$roleId,
|
||||
($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
|
||||
($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for an active restriction in an entity map.
|
||||
*/
|
||||
protected function mapHasActiveRestriction(array $entityMap, SimpleEntityData $entity, int $roleId): bool
|
||||
{
|
||||
$key = $entity->type . ':' . $entity->id . ':' . $roleId;
|
||||
|
||||
return $entityMap[$key] ?? false;
|
||||
$status = $roleHasPermission ? PermissionStatus::IMPLICIT_ALLOW : PermissionStatus::IMPLICIT_DENY;
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, $status, $roleHasPermissionOwn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an array of data with the information of an entity jointPermissions.
|
||||
* Used to build data for bulk insertion.
|
||||
*/
|
||||
protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, bool $permissionAll, bool $permissionOwn): array
|
||||
protected function createJointPermissionDataArray(SimpleEntityData $entity, int $roleId, int $permissionStatus, bool $hasPermissionOwn): array
|
||||
{
|
||||
$ownPermissionActive = ($hasPermissionOwn && $permissionStatus !== PermissionStatus::EXPLICIT_DENY && $entity->owned_by);
|
||||
|
||||
return [
|
||||
'entity_id' => $entity->id,
|
||||
'entity_type' => $entity->type,
|
||||
'has_permission' => $permissionAll,
|
||||
'has_permission_own' => $permissionOwn,
|
||||
'owned_by' => $entity->owned_by,
|
||||
'role_id' => $roleId,
|
||||
'entity_id' => $entity->id,
|
||||
'entity_type' => $entity->type,
|
||||
'role_id' => $roleId,
|
||||
'status' => $permissionStatus,
|
||||
'owner_id' => $ownPermissionActive ? $entity->owned_by : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
81
app/Auth/Permissions/MassEntityPermissionEvaluator.php
Normal file
81
app/Auth/Permissions/MassEntityPermissionEvaluator.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
class MassEntityPermissionEvaluator extends EntityPermissionEvaluator
|
||||
{
|
||||
/**
|
||||
* @var SimpleEntityData[]
|
||||
*/
|
||||
protected array $entitiesInvolved;
|
||||
protected array $permissionMapCache;
|
||||
|
||||
public function __construct(array $entitiesInvolved, string $action)
|
||||
{
|
||||
$this->entitiesInvolved = $entitiesInvolved;
|
||||
parent::__construct($action);
|
||||
}
|
||||
|
||||
public function evaluateEntityForRole(SimpleEntityData $entity, int $roleId): ?int
|
||||
{
|
||||
$typeIdChain = $this->gatherEntityChainTypeIds($entity);
|
||||
$relevantPermissions = $this->getPermissionMapByTypeIdForChainAndRole($typeIdChain, $roleId);
|
||||
$permitsByType = $this->collapseAndCategorisePermissions($typeIdChain, $relevantPermissions);
|
||||
|
||||
return $this->evaluatePermitsByType($permitsByType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $typeIdChain
|
||||
* @return array<string, EntityPermission[]>
|
||||
*/
|
||||
protected function getPermissionMapByTypeIdForChainAndRole(array $typeIdChain, int $roleId): array
|
||||
{
|
||||
$allPermissions = $this->getPermissionMapByTypeIdAndRoleForAllInvolved();
|
||||
$relevantPermissions = [];
|
||||
|
||||
// Filter down permissions to just those for current typeId
|
||||
// and current roleID or fallback permissions.
|
||||
foreach ($typeIdChain as $typeId) {
|
||||
$relevantPermissions[$typeId] = [
|
||||
...($allPermissions[$typeId][$roleId] ?? []),
|
||||
...($allPermissions[$typeId][0] ?? [])
|
||||
];
|
||||
}
|
||||
|
||||
return $relevantPermissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, EntityPermission[]>>
|
||||
*/
|
||||
protected function getPermissionMapByTypeIdAndRoleForAllInvolved(): array
|
||||
{
|
||||
if (isset($this->permissionMapCache)) {
|
||||
return $this->permissionMapCache;
|
||||
}
|
||||
|
||||
$entityTypeIdChain = [];
|
||||
foreach ($this->entitiesInvolved as $entity) {
|
||||
$entityTypeIdChain[] = $entity->type . ':' . $entity->id;
|
||||
}
|
||||
|
||||
$permissionMap = $this->getPermissionsMapByTypeId($entityTypeIdChain, []);
|
||||
|
||||
// Manipulate permission map to also be keyed by roleId.
|
||||
foreach ($permissionMap as $typeId => $permissions) {
|
||||
$permissionMap[$typeId] = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$roleId = $permission->getRawAttribute('role_id');
|
||||
if (!isset($permissionMap[$typeId][$roleId])) {
|
||||
$permissionMap[$typeId][$roleId] = [];
|
||||
}
|
||||
$permissionMap[$typeId][$roleId][] = $permission;
|
||||
}
|
||||
}
|
||||
|
||||
$this->permissionMapCache = $permissionMap;
|
||||
|
||||
return $this->permissionMapCache;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Model;
|
||||
@@ -59,34 +58,9 @@ class PermissionApplicator
|
||||
*/
|
||||
protected function hasEntityPermission(Entity $entity, array $userRoleIds, string $action): ?bool
|
||||
{
|
||||
$adminRoleId = Role::getSystemRole('admin')->id;
|
||||
if (in_array($adminRoleId, $userRoleIds)) {
|
||||
return true;
|
||||
}
|
||||
$this->ensureValidEntityAction($action);
|
||||
|
||||
$chain = [$entity];
|
||||
if ($entity instanceof Page && $entity->chapter_id) {
|
||||
$chain[] = $entity->chapter;
|
||||
}
|
||||
|
||||
if ($entity instanceof Page || $entity instanceof Chapter) {
|
||||
$chain[] = $entity->book;
|
||||
}
|
||||
|
||||
foreach ($chain as $currentEntity) {
|
||||
if (is_null($currentEntity->restricted)) {
|
||||
throw new InvalidArgumentException('Entity restricted field used but has not been loaded');
|
||||
}
|
||||
|
||||
if ($currentEntity->restricted) {
|
||||
return $currentEntity->permissions()
|
||||
->whereIn('role_id', $userRoleIds)
|
||||
->where('action', '=', $action)
|
||||
->count() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return (new EntityPermissionEvaluator($action))->evaluateEntityForUser($entity, $userRoleIds);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,18 +69,16 @@ class PermissionApplicator
|
||||
*/
|
||||
public function checkUserHasEntityPermissionOnAny(string $action, string $entityClass = ''): bool
|
||||
{
|
||||
if (strpos($action, '-') !== false) {
|
||||
throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
|
||||
}
|
||||
$this->ensureValidEntityAction($action);
|
||||
|
||||
$permissionQuery = EntityPermission::query()
|
||||
->where('action', '=', $action)
|
||||
->where($action, '=', true)
|
||||
->whereIn('role_id', $this->getCurrentUserRoleIds());
|
||||
|
||||
if (!empty($entityClass)) {
|
||||
/** @var Entity $entityInstance */
|
||||
$entityInstance = app()->make($entityClass);
|
||||
$permissionQuery = $permissionQuery->where('restrictable_type', '=', $entityInstance->getMorphClass());
|
||||
$permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
|
||||
}
|
||||
|
||||
$hasPermission = $permissionQuery->count() > 0;
|
||||
@@ -122,10 +94,12 @@ class PermissionApplicator
|
||||
{
|
||||
return $query->where(function (Builder $parentQuery) {
|
||||
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) {
|
||||
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoleIds())
|
||||
->where(function (Builder $query) {
|
||||
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
|
||||
});
|
||||
$permissionQuery->select(['entity_id', 'entity_type'])
|
||||
->selectRaw('max(owner_id) as owner_id')
|
||||
->selectRaw('max(status) as status')
|
||||
->whereIn('role_id', $this->getCurrentUserRoleIds())
|
||||
->groupBy(['entity_type', 'entity_id'])
|
||||
->havingRaw('(status IN (1, 3) or (owner_id = ? and status != 2))', [$this->currentUser()->id]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -149,35 +123,23 @@ class PermissionApplicator
|
||||
* Filter items that have entities set as a polymorphic relation.
|
||||
* For simplicity, this will not return results attached to draft pages.
|
||||
* Draft pages should never really have related items though.
|
||||
*
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
public function restrictEntityRelationQuery($query, string $tableName, string $entityIdColumn, string $entityTypeColumn)
|
||||
public function restrictEntityRelationQuery(Builder $query, string $tableName, string $entityIdColumn, string $entityTypeColumn): Builder
|
||||
{
|
||||
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
|
||||
$pageMorphClass = (new Page())->getMorphClass();
|
||||
|
||||
$q = $query->whereExists(function ($permissionQuery) use (&$tableDetails) {
|
||||
/** @var Builder $permissionQuery */
|
||||
$permissionQuery->select(['role_id'])->from('joint_permissions')
|
||||
->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
|
||||
->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
|
||||
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds())
|
||||
->where(function (QueryBuilder $query) {
|
||||
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
|
||||
});
|
||||
})->where(function ($query) use ($tableDetails, $pageMorphClass) {
|
||||
/** @var Builder $query */
|
||||
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
|
||||
return $this->restrictEntityQuery($query)
|
||||
->where(function ($query) use ($tableDetails, $pageMorphClass) {
|
||||
/** @var Builder $query */
|
||||
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
|
||||
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
|
||||
$query->select('id')->from('pages')
|
||||
->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
|
||||
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
|
||||
->where('pages.draft', '=', false);
|
||||
});
|
||||
});
|
||||
|
||||
return $q;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,49 +151,20 @@ class PermissionApplicator
|
||||
public function restrictPageRelationQuery(Builder $query, string $tableName, string $pageIdColumn): Builder
|
||||
{
|
||||
$fullPageIdColumn = $tableName . '.' . $pageIdColumn;
|
||||
$morphClass = (new Page())->getMorphClass();
|
||||
|
||||
$existsQuery = function ($permissionQuery) use ($fullPageIdColumn, $morphClass) {
|
||||
/** @var Builder $permissionQuery */
|
||||
$permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
|
||||
->whereColumn('joint_permissions.entity_id', '=', $fullPageIdColumn)
|
||||
->where('joint_permissions.entity_type', '=', $morphClass)
|
||||
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoleIds())
|
||||
->where(function (QueryBuilder $query) {
|
||||
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
|
||||
return $this->restrictEntityQuery($query)
|
||||
->where(function ($query) use ($fullPageIdColumn) {
|
||||
/** @var Builder $query */
|
||||
$query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
|
||||
$query->select('id')->from('pages')
|
||||
->whereColumn('pages.id', '=', $fullPageIdColumn)
|
||||
->where('pages.draft', '=', false);
|
||||
})->orWhereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
|
||||
$query->select('id')->from('pages')
|
||||
->whereColumn('pages.id', '=', $fullPageIdColumn)
|
||||
->where('pages.draft', '=', true)
|
||||
->where('pages.created_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
};
|
||||
|
||||
$q = $query->where(function ($query) use ($existsQuery, $fullPageIdColumn) {
|
||||
$query->whereExists($existsQuery)
|
||||
->orWhere($fullPageIdColumn, '=', 0);
|
||||
});
|
||||
|
||||
// Prevent visibility of non-owned draft pages
|
||||
$q->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) {
|
||||
$query->select('id')->from('pages')
|
||||
->whereColumn('pages.id', '=', $fullPageIdColumn)
|
||||
->where(function (QueryBuilder $query) {
|
||||
$query->where('pages.draft', '=', false)
|
||||
->orWhere('pages.owned_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
|
||||
return $q;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the query for checking the given user id has permission
|
||||
* within the join_permissions table.
|
||||
*
|
||||
* @param QueryBuilder|Builder $query
|
||||
*/
|
||||
protected function addJointHasPermissionCheck($query, int $userIdToCheck)
|
||||
{
|
||||
$query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
|
||||
$query->where('joint_permissions.has_permission_own', '=', true)
|
||||
->where('joint_permissions.owned_by', '=', $userIdToCheck);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,4 +188,16 @@ class PermissionApplicator
|
||||
|
||||
return $this->currentUser()->roles->pluck('id')->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the given action is a valid and expected entity action.
|
||||
* Throws an exception if invalid otherwise does nothing.
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function ensureValidEntityAction(string $action): void
|
||||
{
|
||||
if (!in_array($action, EntityPermission::PERMISSIONS)) {
|
||||
throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
68
app/Auth/Permissions/PermissionFormData.php
Normal file
68
app/Auth/Permissions/PermissionFormData.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
|
||||
class PermissionFormData
|
||||
{
|
||||
protected Entity $entity;
|
||||
|
||||
public function __construct(Entity $entity)
|
||||
{
|
||||
$this->entity = $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the permissions with assigned roles.
|
||||
*/
|
||||
public function permissionsWithRoles(): array
|
||||
{
|
||||
return $this->entity->permissions()
|
||||
->with('role')
|
||||
->where('role_id', '!=', 0)
|
||||
->get()
|
||||
->sortBy('role.display_name')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles that don't yet have specific permissions for the
|
||||
* entity we're managing permissions for.
|
||||
*/
|
||||
public function rolesNotAssigned(): array
|
||||
{
|
||||
$assigned = $this->entity->permissions()->pluck('role_id');
|
||||
return Role::query()
|
||||
->where('system_name', '!=', 'admin')
|
||||
->whereNotIn('id', $assigned)
|
||||
->orderBy('display_name', 'asc')
|
||||
->get()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permission for the "Everyone Else" option.
|
||||
*/
|
||||
public function everyoneElseEntityPermission(): EntityPermission
|
||||
{
|
||||
/** @var ?EntityPermission $permission */
|
||||
$permission = $this->entity->permissions()
|
||||
->where('role_id', '=', 0)
|
||||
->first();
|
||||
return $permission ?? (new EntityPermission());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the "Everyone Else" role entry.
|
||||
*/
|
||||
public function everyoneElseRole(): Role
|
||||
{
|
||||
return (new Role())->forceFill([
|
||||
'id' => 0,
|
||||
'display_name' => trans('entities.permissions_role_everyone_else'),
|
||||
'description' => trans('entities.permissions_role_everyone_else_desc'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
11
app/Auth/Permissions/PermissionStatus.php
Normal file
11
app/Auth/Permissions/PermissionStatus.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
class PermissionStatus
|
||||
{
|
||||
const IMPLICIT_DENY = 0;
|
||||
const IMPLICIT_ALLOW = 1;
|
||||
const EXPLICIT_DENY = 2;
|
||||
const EXPLICIT_ALLOW = 3;
|
||||
}
|
||||
@@ -12,11 +12,8 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
class PermissionsRepo
|
||||
{
|
||||
protected JointPermissionBuilder $permissionBuilder;
|
||||
protected $systemRoles = ['admin', 'public'];
|
||||
protected array $systemRoles = ['admin', 'public'];
|
||||
|
||||
/**
|
||||
* PermissionsRepo constructor.
|
||||
*/
|
||||
public function __construct(JointPermissionBuilder $permissionBuilder)
|
||||
{
|
||||
$this->permissionBuilder = $permissionBuilder;
|
||||
@@ -41,7 +38,7 @@ class PermissionsRepo
|
||||
/**
|
||||
* Get a role via its ID.
|
||||
*/
|
||||
public function getRoleById($id): Role
|
||||
public function getRoleById(int $id): Role
|
||||
{
|
||||
return Role::query()->findOrFail($id);
|
||||
}
|
||||
@@ -52,10 +49,10 @@ class PermissionsRepo
|
||||
public function saveNewRole(array $roleData): Role
|
||||
{
|
||||
$role = new Role($roleData);
|
||||
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
|
||||
$role->mfa_enforced = boolval($roleData['mfa_enforced'] ?? false);
|
||||
$role->save();
|
||||
|
||||
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
||||
$permissions = $roleData['permissions'] ?? [];
|
||||
$this->assignRolePermissions($role, $permissions);
|
||||
$this->permissionBuilder->rebuildForRole($role);
|
||||
|
||||
@@ -66,42 +63,45 @@ class PermissionsRepo
|
||||
|
||||
/**
|
||||
* Updates an existing role.
|
||||
* Ensure Admin role always have core permissions.
|
||||
* Ensures Admin system role always have core permissions.
|
||||
*/
|
||||
public function updateRole($roleId, array $roleData)
|
||||
public function updateRole($roleId, array $roleData): Role
|
||||
{
|
||||
$role = $this->getRoleById($roleId);
|
||||
|
||||
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
||||
if (isset($roleData['permissions'])) {
|
||||
$this->assignRolePermissions($role, $roleData['permissions']);
|
||||
}
|
||||
|
||||
$role->fill($roleData);
|
||||
$role->save();
|
||||
$this->permissionBuilder->rebuildForRole($role);
|
||||
|
||||
Activity::add(ActivityType::ROLE_UPDATE, $role);
|
||||
|
||||
return $role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a list of permission names to the given role.
|
||||
*/
|
||||
protected function assignRolePermissions(Role $role, array $permissionNameArray = []): void
|
||||
{
|
||||
$permissions = [];
|
||||
$permissionNameArray = array_values($permissionNameArray);
|
||||
|
||||
// Ensure the admin system role retains vital system permissions
|
||||
if ($role->system_name === 'admin') {
|
||||
$permissions = array_merge($permissions, [
|
||||
$permissionNameArray = array_unique(array_merge($permissionNameArray, [
|
||||
'users-manage',
|
||||
'user-roles-manage',
|
||||
'restrictions-manage-all',
|
||||
'restrictions-manage-own',
|
||||
'settings-manage',
|
||||
]);
|
||||
]));
|
||||
}
|
||||
|
||||
$this->assignRolePermissions($role, $permissions);
|
||||
|
||||
$role->fill($roleData);
|
||||
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
|
||||
$role->save();
|
||||
$this->permissionBuilder->rebuildForRole($role);
|
||||
|
||||
Activity::add(ActivityType::ROLE_UPDATE, $role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a list of permission names to a role.
|
||||
*/
|
||||
protected function assignRolePermissions(Role $role, array $permissionNameArray = [])
|
||||
{
|
||||
$permissions = [];
|
||||
$permissionNameArray = array_values($permissionNameArray);
|
||||
|
||||
if ($permissionNameArray) {
|
||||
if (!empty($permissionNameArray)) {
|
||||
$permissions = RolePermission::query()
|
||||
->whereIn('name', $permissionNameArray)
|
||||
->pluck('id')
|
||||
@@ -114,13 +114,13 @@ class PermissionsRepo
|
||||
/**
|
||||
* Delete a role from the system.
|
||||
* Check it's not an admin role or set as default before deleting.
|
||||
* If an migration Role ID is specified the users assign to the current role
|
||||
* If a migration Role ID is specified the users assign to the current role
|
||||
* will be added to the role of the specified id.
|
||||
*
|
||||
* @throws PermissionsException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function deleteRole($roleId, $migrateRoleId)
|
||||
public function deleteRole(int $roleId, int $migrateRoleId = 0): void
|
||||
{
|
||||
$role = $this->getRoleById($roleId);
|
||||
|
||||
@@ -131,7 +131,7 @@ class PermissionsRepo
|
||||
throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
|
||||
}
|
||||
|
||||
if ($migrateRoleId) {
|
||||
if ($migrateRoleId !== 0) {
|
||||
$newRole = Role::query()->find($migrateRoleId);
|
||||
if ($newRole) {
|
||||
$users = $role->users()->pluck('id')->toArray();
|
||||
@@ -139,6 +139,7 @@ class PermissionsRepo
|
||||
}
|
||||
}
|
||||
|
||||
$role->entityPermissions()->delete();
|
||||
$role->jointPermissions()->delete();
|
||||
Activity::add(ActivityType::ROLE_DELETE, $role);
|
||||
$role->delete();
|
||||
|
||||
@@ -8,6 +8,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $display_name
|
||||
*/
|
||||
class RolePermission extends Model
|
||||
{
|
||||
|
||||
@@ -2,12 +2,27 @@
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
|
||||
class SimpleEntityData
|
||||
{
|
||||
public int $id;
|
||||
public string $type;
|
||||
public bool $restricted;
|
||||
public int $owned_by;
|
||||
public ?int $book_id;
|
||||
public ?int $chapter_id;
|
||||
|
||||
public static function fromEntity(Entity $entity): self
|
||||
{
|
||||
$attrs = $entity->getAttributes();
|
||||
$simple = new self();
|
||||
|
||||
$simple->id = $attrs['id'];
|
||||
$simple->type = $entity->getMorphClass();
|
||||
$simple->owned_by = $attrs['owned_by'] ?? 0;
|
||||
$simple->book_id = $attrs['book_id'] ?? null;
|
||||
$simple->chapter_id = $attrs['chapter_id'] ?? null;
|
||||
|
||||
return $simple;
|
||||
}
|
||||
}
|
||||
|
||||
35
app/Auth/Queries/RolesAllPaginatedAndSorted.php
Normal file
35
app/Auth/Queries/RolesAllPaginatedAndSorted.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Queries;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Get all the roles in the system in a paginated format.
|
||||
*/
|
||||
class RolesAllPaginatedAndSorted
|
||||
{
|
||||
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
|
||||
{
|
||||
$sort = $listOptions->getSort();
|
||||
if ($sort === 'created_at') {
|
||||
$sort = 'users.created_at';
|
||||
}
|
||||
|
||||
$query = Role::query()->select(['*'])
|
||||
->withCount(['users', 'permissions'])
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
if ($listOptions->getSearch()) {
|
||||
$term = '%' . $listOptions->getSearch() . '%';
|
||||
$query->where(function ($query) use ($term) {
|
||||
$query->where('display_name', 'like', $term)
|
||||
->orWhere('description', 'like', $term);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->paginate($count);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Auth\Queries;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
@@ -11,23 +12,23 @@ use Illuminate\Pagination\LengthAwarePaginator;
|
||||
* user is assumed to be trusted. (Admin users).
|
||||
* Email search can be abused to extract email addresses.
|
||||
*/
|
||||
class AllUsersPaginatedAndSorted
|
||||
class UsersAllPaginatedAndSorted
|
||||
{
|
||||
/**
|
||||
* @param array{sort: string, order: string, search: string} $sortData
|
||||
*/
|
||||
public function run(int $count, array $sortData): LengthAwarePaginator
|
||||
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
|
||||
{
|
||||
$sort = $sortData['sort'];
|
||||
$sort = $listOptions->getSort();
|
||||
if ($sort === 'created_at') {
|
||||
$sort = 'users.created_at';
|
||||
}
|
||||
|
||||
$query = User::query()->select(['*'])
|
||||
->scopes(['withLastActivityAt'])
|
||||
->with(['roles', 'avatar'])
|
||||
->withCount('mfaValues')
|
||||
->orderBy($sort, $sortData['order']);
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
if ($sortData['search']) {
|
||||
$term = '%' . $sortData['search'] . '%';
|
||||
if ($listOptions->getSearch()) {
|
||||
$term = '%' . $listOptions->getSearch() . '%';
|
||||
$query->where(function ($query) use ($term) {
|
||||
$query->where('name', 'like', $term)
|
||||
->orWhere('email', 'like', $term);
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Auth;
|
||||
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Auth\Permissions\RolePermission;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
@@ -26,10 +27,14 @@ class Role extends Model implements Loggable
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['display_name', 'description', 'external_auth_id'];
|
||||
protected $fillable = ['display_name', 'description', 'external_auth_id', 'mfa_enforced'];
|
||||
|
||||
protected $hidden = ['pivot'];
|
||||
|
||||
protected $casts = [
|
||||
'mfa_enforced' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* The roles that belong to the role.
|
||||
*/
|
||||
@@ -54,6 +59,14 @@ class Role extends Model implements Loggable
|
||||
return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permissions assigned to this role.
|
||||
*/
|
||||
public function entityPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(EntityPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this role has a permission.
|
||||
*/
|
||||
@@ -98,26 +111,13 @@ class Role extends Model implements Loggable
|
||||
*/
|
||||
public static function getSystemRole(string $systemName): ?self
|
||||
{
|
||||
return static::query()->where('system_name', '=', $systemName)->first();
|
||||
}
|
||||
static $cache = [];
|
||||
|
||||
/**
|
||||
* Get all visible roles.
|
||||
*/
|
||||
public static function visible(): Collection
|
||||
{
|
||||
return static::query()->where('hidden', '=', false)->orderBy('name')->get();
|
||||
}
|
||||
if (!isset($cache[$systemName])) {
|
||||
$cache[$systemName] = static::query()->where('system_name', '=', $systemName)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles that can be restricted.
|
||||
*/
|
||||
public static function restrictable(): Collection
|
||||
{
|
||||
return static::query()
|
||||
->where('system_name', '!=', 'admin')
|
||||
->orderBy('display_name', 'asc')
|
||||
->get();
|
||||
return $cache[$systemName];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,7 +72,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
|
||||
'created_at', 'updated_at', 'image_id', 'roles', 'avatar', 'user_id',
|
||||
'created_at', 'updated_at', 'image_id', 'roles', 'avatar', 'user_id', 'pivot',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -200,6 +200,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
public function attachRole(Role $role)
|
||||
{
|
||||
$this->roles()->attach($role->id);
|
||||
$this->unsetRelation('roles');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ use BookStack\Exceptions\UserUpdateException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Uploads\UserAvatars;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -61,7 +62,7 @@ class UserRepo
|
||||
$user = new User();
|
||||
$user->name = $data['name'];
|
||||
$user->email = $data['email'];
|
||||
$user->password = bcrypt(empty($data['password']) ? Str::random(32) : $data['password']);
|
||||
$user->password = Hash::make(empty($data['password']) ? Str::random(32) : $data['password']);
|
||||
$user->email_confirmed = $emailConfirmed;
|
||||
$user->external_auth_id = $data['external_auth_id'] ?? '';
|
||||
|
||||
@@ -126,7 +127,7 @@ class UserRepo
|
||||
}
|
||||
|
||||
if (!empty($data['password'])) {
|
||||
$user->password = bcrypt($data['password']);
|
||||
$user->password = Hash::make($data['password']);
|
||||
}
|
||||
|
||||
if (!empty($data['language'])) {
|
||||
@@ -157,6 +158,9 @@ class UserRepo
|
||||
// Delete user profile images
|
||||
$this->userAvatar->destroyAllForUser($user);
|
||||
|
||||
// Delete related activities
|
||||
setting()->deleteUserSettings($user->id);
|
||||
|
||||
if (!empty($newOwnerId)) {
|
||||
$newOwner = User::query()->find($newOwnerId);
|
||||
if (!is_null($newOwner)) {
|
||||
@@ -230,6 +234,8 @@ class UserRepo
|
||||
*/
|
||||
protected function setUserRoles(User $user, array $roles)
|
||||
{
|
||||
$roles = array_filter(array_values($roles));
|
||||
|
||||
if ($this->demotingLastAdmin($user, $roles)) {
|
||||
throw new UserUpdateException(trans('errors.role_cannot_remove_only_admin'), $user->getEditUrl());
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||
*/
|
||||
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
return [
|
||||
|
||||
// The environment to run BookStack in.
|
||||
@@ -75,7 +77,7 @@ return [
|
||||
'locale' => env('APP_LANG', 'en'),
|
||||
|
||||
// Locales available
|
||||
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
|
||||
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'el', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ka', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
|
||||
|
||||
// Application Fallback Locale
|
||||
'fallback_locale' => 'en',
|
||||
@@ -98,7 +100,13 @@ return [
|
||||
// Encryption cipher
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
// Application Services Provides
|
||||
// Maintenance Mode Driver
|
||||
'maintenance' => [
|
||||
'driver' => 'file',
|
||||
// 'store' => 'redis',
|
||||
],
|
||||
|
||||
// Application Service Providers
|
||||
'providers' => [
|
||||
|
||||
// Laravel Framework Service Providers...
|
||||
@@ -114,6 +122,8 @@ return [
|
||||
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
|
||||
Illuminate\Hashing\HashServiceProvider::class,
|
||||
Illuminate\Mail\MailServiceProvider::class,
|
||||
Illuminate\Notifications\NotificationServiceProvider::class,
|
||||
Illuminate\Pagination\PaginationServiceProvider::class,
|
||||
Illuminate\Pipeline\PipelineServiceProvider::class,
|
||||
Illuminate\Queue\QueueServiceProvider::class,
|
||||
Illuminate\Redis\RedisServiceProvider::class,
|
||||
@@ -121,81 +131,27 @@ return [
|
||||
Illuminate\Session\SessionServiceProvider::class,
|
||||
Illuminate\Validation\ValidationServiceProvider::class,
|
||||
Illuminate\View\ViewServiceProvider::class,
|
||||
Illuminate\Notifications\NotificationServiceProvider::class,
|
||||
SocialiteProviders\Manager\ServiceProvider::class,
|
||||
|
||||
// Third party service providers
|
||||
Intervention\Image\ImageServiceProvider::class,
|
||||
Barryvdh\DomPDF\ServiceProvider::class,
|
||||
Barryvdh\Snappy\ServiceProvider::class,
|
||||
|
||||
// BookStack replacement service providers (Extends Laravel)
|
||||
BookStack\Providers\PaginationServiceProvider::class,
|
||||
BookStack\Providers\TranslationServiceProvider::class,
|
||||
Intervention\Image\ImageServiceProvider::class,
|
||||
SocialiteProviders\Manager\ServiceProvider::class,
|
||||
|
||||
// BookStack custom service providers
|
||||
BookStack\Providers\ThemeServiceProvider::class,
|
||||
BookStack\Providers\AuthServiceProvider::class,
|
||||
BookStack\Providers\AppServiceProvider::class,
|
||||
BookStack\Providers\BroadcastServiceProvider::class,
|
||||
BookStack\Providers\AuthServiceProvider::class,
|
||||
BookStack\Providers\EventServiceProvider::class,
|
||||
BookStack\Providers\RouteServiceProvider::class,
|
||||
BookStack\Providers\CustomFacadeProvider::class,
|
||||
BookStack\Providers\CustomValidationServiceProvider::class,
|
||||
BookStack\Providers\TranslationServiceProvider::class,
|
||||
BookStack\Providers\ValidationRuleServiceProvider::class,
|
||||
BookStack\Providers\ViewTweaksServiceProvider::class,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Class Aliases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This array of class aliases will be registered when this application
|
||||
| is started. However, feel free to register as many as you wish as
|
||||
| the aliases are "lazy" loaded so they don't hinder performance.
|
||||
|
|
||||
*/
|
||||
|
||||
// Class aliases, Registered on application start
|
||||
'aliases' => [
|
||||
// Laravel
|
||||
'App' => Illuminate\Support\Facades\App::class,
|
||||
'Arr' => Illuminate\Support\Arr::class,
|
||||
'Artisan' => Illuminate\Support\Facades\Artisan::class,
|
||||
'Auth' => Illuminate\Support\Facades\Auth::class,
|
||||
'Blade' => Illuminate\Support\Facades\Blade::class,
|
||||
'Bus' => Illuminate\Support\Facades\Bus::class,
|
||||
'Cache' => Illuminate\Support\Facades\Cache::class,
|
||||
'Config' => Illuminate\Support\Facades\Config::class,
|
||||
'Cookie' => Illuminate\Support\Facades\Cookie::class,
|
||||
'Crypt' => Illuminate\Support\Facades\Crypt::class,
|
||||
'Date' => Illuminate\Support\Facades\Date::class,
|
||||
'DB' => Illuminate\Support\Facades\DB::class,
|
||||
'Eloquent' => Illuminate\Database\Eloquent\Model::class,
|
||||
'Event' => Illuminate\Support\Facades\Event::class,
|
||||
'File' => Illuminate\Support\Facades\File::class,
|
||||
'Gate' => Illuminate\Support\Facades\Gate::class,
|
||||
'Hash' => Illuminate\Support\Facades\Hash::class,
|
||||
'Http' => Illuminate\Support\Facades\Http::class,
|
||||
'Lang' => Illuminate\Support\Facades\Lang::class,
|
||||
'Log' => Illuminate\Support\Facades\Log::class,
|
||||
'Mail' => Illuminate\Support\Facades\Mail::class,
|
||||
'Notification' => Illuminate\Support\Facades\Notification::class,
|
||||
'Password' => Illuminate\Support\Facades\Password::class,
|
||||
'Queue' => Illuminate\Support\Facades\Queue::class,
|
||||
'RateLimiter' => Illuminate\Support\Facades\RateLimiter::class,
|
||||
'Redirect' => Illuminate\Support\Facades\Redirect::class,
|
||||
// 'Redis' => Illuminate\Support\Facades\Redis::class,
|
||||
'Request' => Illuminate\Support\Facades\Request::class,
|
||||
'Response' => Illuminate\Support\Facades\Response::class,
|
||||
'Route' => Illuminate\Support\Facades\Route::class,
|
||||
'Schema' => Illuminate\Support\Facades\Schema::class,
|
||||
'Session' => Illuminate\Support\Facades\Session::class,
|
||||
'Storage' => Illuminate\Support\Facades\Storage::class,
|
||||
'Str' => Illuminate\Support\Str::class,
|
||||
'URL' => Illuminate\Support\Facades\URL::class,
|
||||
'Validator' => Illuminate\Support\Facades\Validator::class,
|
||||
'View' => Illuminate\Support\Facades\View::class,
|
||||
|
||||
// Class Aliases
|
||||
// This array of class aliases to be registered on application start.
|
||||
'aliases' => Facade::defaultAliases()->merge([
|
||||
// Laravel Packages
|
||||
'Socialite' => Laravel\Socialite\Facades\Socialite::class,
|
||||
|
||||
@@ -205,7 +161,7 @@ return [
|
||||
// Custom BookStack
|
||||
'Activity' => BookStack\Facades\Activity::class,
|
||||
'Theme' => BookStack\Facades\Theme::class,
|
||||
],
|
||||
])->toArray(),
|
||||
|
||||
// Proxy configuration
|
||||
'proxies' => env('APP_PROXIES', ''),
|
||||
|
||||
@@ -14,7 +14,7 @@ return [
|
||||
// This option controls the default broadcaster that will be used by the
|
||||
// framework when an event needs to be broadcast. This can be set to
|
||||
// any of the connections defined in the "connections" array below.
|
||||
'default' => env('BROADCAST_DRIVER', 'pusher'),
|
||||
'default' => 'null',
|
||||
|
||||
// Broadcast Connections
|
||||
// Here you may define all of the broadcast connections that will be used
|
||||
@@ -22,21 +22,7 @@ return [
|
||||
// each available type of connection are provided inside this array.
|
||||
'connections' => [
|
||||
|
||||
'pusher' => [
|
||||
'driver' => 'pusher',
|
||||
'key' => env('PUSHER_APP_KEY'),
|
||||
'secret' => env('PUSHER_APP_SECRET'),
|
||||
'app_id' => env('PUSHER_APP_ID'),
|
||||
'options' => [
|
||||
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||
'useTLS' => true,
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => 'default',
|
||||
],
|
||||
// Default options removed since we don't use broadcasting.
|
||||
|
||||
'log' => [
|
||||
'driver' => 'log',
|
||||
|
||||
@@ -87,6 +87,6 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache'),
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_cache_'),
|
||||
|
||||
];
|
||||
|
||||
@@ -33,17 +33,20 @@ return [
|
||||
'driver' => 'local',
|
||||
'root' => public_path(),
|
||||
'visibility' => 'public',
|
||||
'throw' => true,
|
||||
],
|
||||
|
||||
'local_secure_attachments' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('uploads/files/'),
|
||||
'throw' => true,
|
||||
],
|
||||
|
||||
'local_secure_images' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('uploads/images/'),
|
||||
'visibility' => 'public',
|
||||
'throw' => true,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
@@ -54,6 +57,7 @@ return [
|
||||
'bucket' => env('STORAGE_S3_BUCKET', 'your-bucket'),
|
||||
'endpoint' => env('STORAGE_S3_ENDPOINT', null),
|
||||
'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null,
|
||||
'throw' => true,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
@@ -21,6 +21,15 @@ return [
|
||||
// one of the channels defined in the "channels" configuration array.
|
||||
'default' => env('LOG_CHANNEL', 'single'),
|
||||
|
||||
// Deprecations Log Channel
|
||||
// This option controls the log channel that should be used to log warnings
|
||||
// regarding deprecated PHP and library features. This allows you to get
|
||||
// your application ready for upcoming major versions of dependencies.
|
||||
'deprecations' => [
|
||||
'channel' => 'null',
|
||||
'trace' => false,
|
||||
],
|
||||
|
||||
// Log Channels
|
||||
// Here you may configure the log channels for your application. Out of
|
||||
// the box, Laravel uses the Monolog PHP logging library. This gives
|
||||
|
||||
@@ -14,13 +14,7 @@ return [
|
||||
// From Laravel 7+ this is MAIL_MAILER in laravel.
|
||||
// Kept as MAIL_DRIVER in BookStack to prevent breaking change.
|
||||
// Options: smtp, sendmail, log, array
|
||||
'driver' => env('MAIL_DRIVER', 'smtp'),
|
||||
|
||||
// SMTP host address
|
||||
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
|
||||
|
||||
// SMTP host port
|
||||
'port' => env('MAIL_PORT', 587),
|
||||
'default' => env('MAIL_DRIVER', 'smtp'),
|
||||
|
||||
// Global "From" address & name
|
||||
'from' => [
|
||||
@@ -28,17 +22,43 @@ return [
|
||||
'name' => env('MAIL_FROM_NAME', 'BookStack'),
|
||||
],
|
||||
|
||||
// Email encryption protocol
|
||||
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
|
||||
// Mailer Configurations
|
||||
// Available mailing methods and their settings.
|
||||
'mailers' => [
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
|
||||
'port' => env('MAIL_PORT', 587),
|
||||
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'verify_peer' => env('MAIL_VERIFY_SSL', true),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN'),
|
||||
],
|
||||
|
||||
// SMTP server username
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'sendmail' => [
|
||||
'transport' => 'sendmail',
|
||||
'path' => env('MAIL_SENDMAIL_COMMAND', '/usr/sbin/sendmail -bs'),
|
||||
],
|
||||
|
||||
// SMTP server password
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'log' => [
|
||||
'transport' => 'log',
|
||||
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||
],
|
||||
|
||||
// Sendmail application path
|
||||
'sendmail' => '/usr/sbin/sendmail -bs',
|
||||
'array' => [
|
||||
'transport' => 'array',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'transport' => 'failover',
|
||||
'mailers' => [
|
||||
'smtp',
|
||||
'log',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
// Email markdown configuration
|
||||
'markdown' => [
|
||||
@@ -47,11 +67,4 @@ return [
|
||||
resource_path('views/vendor/mail'),
|
||||
],
|
||||
],
|
||||
|
||||
// Log Channel
|
||||
// If you are using the "log" driver, you may specify the logging channel
|
||||
// if you prefer to keep mail messages separate from other log entries
|
||||
// for simpler reading. Otherwise, the default channel will be used.
|
||||
'log_channel' => env('MAIL_LOG_CHANNEL'),
|
||||
|
||||
];
|
||||
|
||||
@@ -8,9 +8,12 @@ return [
|
||||
// Dump user details after a login request for debugging purposes
|
||||
'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false),
|
||||
|
||||
// Attribute, within a OpenId token, to find the user's display name
|
||||
// Claim, within an OpenId token, to find the user's display name
|
||||
'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')),
|
||||
|
||||
// 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'),
|
||||
|
||||
// OAuth2/OpenId client id, as configured in your Authorization server.
|
||||
'client_id' => env('OIDC_CLIENT_ID', null),
|
||||
|
||||
|
||||
@@ -16,16 +16,27 @@ return [
|
||||
'app-editor' => 'wysiwyg',
|
||||
'app-color' => '#206ea7',
|
||||
'app-color-light' => 'rgba(32,110,167,0.15)',
|
||||
'link-color' => '#206ea7',
|
||||
'bookshelf-color' => '#a94747',
|
||||
'book-color' => '#077b70',
|
||||
'chapter-color' => '#af4d0d',
|
||||
'page-color' => '#206ea7',
|
||||
'page-draft-color' => '#7e50b1',
|
||||
'app-color-dark' => '#195785',
|
||||
'app-color-light-dark' => 'rgba(32,110,167,0.15)',
|
||||
'link-color-dark' => '#429fe3',
|
||||
'bookshelf-color-dark' => '#ff5454',
|
||||
'book-color-dark' => '#389f60',
|
||||
'chapter-color-dark' => '#ee7a2d',
|
||||
'page-color-dark' => '#429fe3',
|
||||
'page-draft-color-dark' => '#a66ce8',
|
||||
'app-custom-head' => false,
|
||||
'registration-enabled' => false,
|
||||
|
||||
// User-level default settings
|
||||
'user' => [
|
||||
'ui-shortcuts' => '{}',
|
||||
'ui-shortcuts-enabled' => false,
|
||||
'dark-mode-enabled' => env('APP_DEFAULT_DARK_MODE', false),
|
||||
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
|
||||
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CopyShelfPermissions extends Command
|
||||
@@ -25,19 +25,16 @@ class CopyShelfPermissions extends Command
|
||||
*/
|
||||
protected $description = 'Copy shelf permissions to all child books';
|
||||
|
||||
/**
|
||||
* @var BookshelfRepo
|
||||
*/
|
||||
protected $bookshelfRepo;
|
||||
protected PermissionsUpdater $permissionsUpdater;
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(BookshelfRepo $repo)
|
||||
public function __construct(PermissionsUpdater $permissionsUpdater)
|
||||
{
|
||||
$this->bookshelfRepo = $repo;
|
||||
$this->permissionsUpdater = $permissionsUpdater;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@@ -69,18 +66,18 @@ class CopyShelfPermissions extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
$shelves = Bookshelf::query()->get(['id', 'restricted']);
|
||||
$shelves = Bookshelf::query()->get(['id']);
|
||||
}
|
||||
|
||||
if ($shelfSlug) {
|
||||
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id', 'restricted']);
|
||||
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id']);
|
||||
if ($shelves->count() === 0) {
|
||||
$this->info('No shelves found with the given slug.');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($shelves as $shelf) {
|
||||
$this->bookshelfRepo->copyDownPermissions($shelf, false);
|
||||
$this->permissionsUpdater->updateBookPermissionsFromShelf($shelf, false);
|
||||
$this->info('Copied permissions for shelf [' . $shelf->id . ']');
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ class UpdateUrl extends Command
|
||||
*/
|
||||
protected $signature = 'bookstack:update-url
|
||||
{oldUrl : URL to replace}
|
||||
{newUrl : URL to use as the replacement}';
|
||||
{newUrl : URL to use as the replacement}
|
||||
{--force : Force the operation to run, ignoring confirmations}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -23,25 +24,12 @@ class UpdateUrl extends Command
|
||||
*/
|
||||
protected $description = 'Find and replace the given URLs in your BookStack database';
|
||||
|
||||
protected $db;
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(Connection $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle()
|
||||
public function handle(Connection $db)
|
||||
{
|
||||
$oldUrl = str_replace("'", '', $this->argument('oldUrl'));
|
||||
$newUrl = str_replace("'", '', $this->argument('newUrl'));
|
||||
@@ -67,7 +55,7 @@ class UpdateUrl extends Command
|
||||
|
||||
foreach ($columnsToUpdateByTable as $table => $columns) {
|
||||
foreach ($columns as $column) {
|
||||
$changeCount = $this->replaceValueInTable($table, $column, $oldUrl, $newUrl);
|
||||
$changeCount = $this->replaceValueInTable($db, $table, $column, $oldUrl, $newUrl);
|
||||
$this->info("Updated {$changeCount} rows in {$table}->{$column}");
|
||||
}
|
||||
}
|
||||
@@ -80,7 +68,7 @@ class UpdateUrl extends Command
|
||||
foreach ($columns as $column) {
|
||||
$oldJson = trim(json_encode($oldUrl), '"');
|
||||
$newJson = trim(json_encode($newUrl), '"');
|
||||
$changeCount = $this->replaceValueInTable($table, $column, $oldJson, $newJson);
|
||||
$changeCount = $this->replaceValueInTable($db, $table, $column, $oldJson, $newJson);
|
||||
$this->info("Updated {$changeCount} JSON encoded rows in {$table}->{$column}");
|
||||
}
|
||||
}
|
||||
@@ -97,13 +85,18 @@ class UpdateUrl extends Command
|
||||
* Perform a find+replace operations in the provided table and column.
|
||||
* Returns the count of rows changed.
|
||||
*/
|
||||
protected function replaceValueInTable(string $table, string $column, string $oldUrl, string $newUrl): int
|
||||
{
|
||||
$oldQuoted = $this->db->getPdo()->quote($oldUrl);
|
||||
$newQuoted = $this->db->getPdo()->quote($newUrl);
|
||||
protected function replaceValueInTable(
|
||||
Connection $db,
|
||||
string $table,
|
||||
string $column,
|
||||
string $oldUrl,
|
||||
string $newUrl
|
||||
): int {
|
||||
$oldQuoted = $db->getPdo()->quote($oldUrl);
|
||||
$newQuoted = $db->getPdo()->quote($newUrl);
|
||||
|
||||
return $this->db->table($table)->update([
|
||||
$column => $this->db->raw("REPLACE({$column}, {$oldQuoted}, {$newQuoted})"),
|
||||
return $db->table($table)->update([
|
||||
$column => $db->raw("REPLACE({$column}, {$oldQuoted}, {$newQuoted})"),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -113,6 +106,10 @@ class UpdateUrl extends Command
|
||||
*/
|
||||
protected function checkUserOkayToProceed(string $oldUrl, string $newUrl): bool
|
||||
{
|
||||
if ($this->option('force')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$dangerWarning = "This will search for \"{$oldUrl}\" in your database and replace it with \"{$newUrl}\".\n";
|
||||
$dangerWarning .= 'Are you sure you want to proceed?';
|
||||
$backupConfirmation = 'This operation could cause issues if used incorrectly. Have you made a backup of your existing database?';
|
||||
|
||||
@@ -18,30 +18,11 @@ use BookStack\Entities\Models\PageRevision;
|
||||
*/
|
||||
class EntityProvider
|
||||
{
|
||||
/**
|
||||
* @var Bookshelf
|
||||
*/
|
||||
public $bookshelf;
|
||||
|
||||
/**
|
||||
* @var Book
|
||||
*/
|
||||
public $book;
|
||||
|
||||
/**
|
||||
* @var Chapter
|
||||
*/
|
||||
public $chapter;
|
||||
|
||||
/**
|
||||
* @var Page
|
||||
*/
|
||||
public $page;
|
||||
|
||||
/**
|
||||
* @var PageRevision
|
||||
*/
|
||||
public $pageRevision;
|
||||
public Bookshelf $bookshelf;
|
||||
public Book $book;
|
||||
public Chapter $chapter;
|
||||
public Page $page;
|
||||
public PageRevision $pageRevision;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -69,13 +50,18 @@ class EntityProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an entity instance by it's basic name.
|
||||
* Get an entity instance by its basic name.
|
||||
*/
|
||||
public function get(string $type): Entity
|
||||
{
|
||||
$type = strtolower($type);
|
||||
$instance = $this->all()[$type] ?? null;
|
||||
|
||||
return $this->all()[$type];
|
||||
if (is_null($instance)) {
|
||||
throw new \InvalidArgumentException("Provided type \"{$type}\" is not a valid entity type");
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@ use Illuminate\Support\Collection;
|
||||
* @property \Illuminate\Database\Eloquent\Collection $chapters
|
||||
* @property \Illuminate\Database\Eloquent\Collection $pages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $directPages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $shelves
|
||||
*/
|
||||
class Book extends Entity implements HasCoverImage
|
||||
{
|
||||
@@ -27,7 +28,7 @@ class Book extends Entity implements HasCoverImage
|
||||
public $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description'];
|
||||
protected $hidden = ['restricted', 'pivot', 'image_id', 'deleted_at'];
|
||||
protected $hidden = ['pivot', 'image_id', 'deleted_at'];
|
||||
|
||||
/**
|
||||
* Get the url for this book.
|
||||
@@ -119,4 +120,13 @@ class Book extends Entity implements HasCoverImage
|
||||
|
||||
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible book by its slug.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlug(string $slug): self
|
||||
{
|
||||
return static::visible()->where('slug', '=', $slug)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class Bookshelf extends Entity implements HasCoverImage
|
||||
|
||||
protected $fillable = ['name', 'description', 'image_id'];
|
||||
|
||||
protected $hidden = ['restricted', 'image_id', 'deleted_at'];
|
||||
protected $hidden = ['image_id', 'deleted_at'];
|
||||
|
||||
/**
|
||||
* Get the books in this shelf.
|
||||
@@ -109,4 +109,13 @@ class Bookshelf extends Entity implements HasCoverImage
|
||||
$maxOrder = $this->books()->max('order');
|
||||
$this->books()->attach($book->id, ['order' => $maxOrder + 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible shelf by its slug.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlug(string $slug): self
|
||||
{
|
||||
return static::visible()->where('slug', '=', $slug)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class Chapter extends BookChild
|
||||
public $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description', 'priority'];
|
||||
protected $hidden = ['restricted', 'pivot', 'deleted_at'];
|
||||
protected $hidden = ['pivot', 'deleted_at'];
|
||||
|
||||
/**
|
||||
* Get the pages that this chapter contains.
|
||||
@@ -58,4 +58,13 @@ class Chapter extends BookChild
|
||||
->orderBy('priority', 'asc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible chapter by its book and page slugs.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlugs(string $bookSlug, string $chapterSlug): self
|
||||
{
|
||||
return static::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property Carbon $deleted_at
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
* @property bool $restricted
|
||||
* @property Collection $tags
|
||||
*
|
||||
* @method static Entity|Builder visible()
|
||||
@@ -176,16 +175,15 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
*/
|
||||
public function permissions(): MorphMany
|
||||
{
|
||||
return $this->morphMany(EntityPermission::class, 'restrictable');
|
||||
return $this->morphMany(EntityPermission::class, 'entity');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this entity has a specific restriction set against it.
|
||||
*/
|
||||
public function hasRestriction(int $role_id, string $action): bool
|
||||
public function hasPermissions(): bool
|
||||
{
|
||||
return $this->permissions()->where('role_id', '=', $role_id)
|
||||
->where('action', '=', $action)->count() > 0;
|
||||
return $this->permissions()->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,7 +39,7 @@ class Page extends BookChild
|
||||
|
||||
public $textField = 'text';
|
||||
|
||||
protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot', 'deleted_at'];
|
||||
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
|
||||
|
||||
protected $casts = [
|
||||
'draft' => 'boolean',
|
||||
@@ -88,8 +88,6 @@ class Page extends BookChild
|
||||
|
||||
/**
|
||||
* Get the current revision for the page if existing.
|
||||
*
|
||||
* @return PageRevision|null
|
||||
*/
|
||||
public function currentRevision(): HasOne
|
||||
{
|
||||
@@ -145,4 +143,13 @@ class Page extends BookChild
|
||||
|
||||
return $refreshed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible page by its book and page slugs.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlugs(string $bookSlug, string $pageSlug): self
|
||||
{
|
||||
return static::visible()->whereSlugs($bookSlug, $pageSlug)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
class PageRevision extends Model implements Loggable
|
||||
{
|
||||
protected $fillable = ['name', 'text', 'summary'];
|
||||
protected $hidden = ['html', 'markdown', 'restricted', 'text'];
|
||||
protected $hidden = ['html', 'markdown', 'text'];
|
||||
|
||||
/**
|
||||
* Get the user that created the page revision.
|
||||
|
||||
@@ -87,14 +87,14 @@ class BaseRepo
|
||||
{
|
||||
if ($coverImage) {
|
||||
$imageType = $entity->coverImageTypeKey();
|
||||
$this->imageRepo->destroyImage($entity->cover);
|
||||
$this->imageRepo->destroyImage($entity->cover()->first());
|
||||
$image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
|
||||
$entity->cover()->associate($image);
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
if ($removeImage) {
|
||||
$this->imageRepo->destroyImage($entity->cover);
|
||||
$this->imageRepo->destroyImage($entity->cover()->first());
|
||||
$entity->image_id = 0;
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
@@ -134,31 +134,6 @@ class BookshelfRepo
|
||||
$shelf->books()->sync($syncData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy down the permissions of the given shelf to all child books.
|
||||
*/
|
||||
public function copyDownPermissions(Bookshelf $shelf, $checkUserPermissions = true): int
|
||||
{
|
||||
$shelfPermissions = $shelf->permissions()->get(['role_id', 'action'])->toArray();
|
||||
$shelfBooks = $shelf->books()->get(['id', 'restricted', 'owned_by']);
|
||||
$updatedBookCount = 0;
|
||||
|
||||
/** @var Book $book */
|
||||
foreach ($shelfBooks as $book) {
|
||||
if ($checkUserPermissions && !userCan('restrictions-manage', $book)) {
|
||||
continue;
|
||||
}
|
||||
$book->permissions()->delete();
|
||||
$book->restricted = $shelf->restricted;
|
||||
$book->permissions()->createMany($shelfPermissions);
|
||||
$book->save();
|
||||
$book->rebuildPermissions();
|
||||
$updatedBookCount++;
|
||||
}
|
||||
|
||||
return $updatedBookCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a bookshelf from the system.
|
||||
*
|
||||
|
||||
@@ -11,22 +11,15 @@ use Illuminate\Support\Collection;
|
||||
|
||||
class BookContents
|
||||
{
|
||||
/**
|
||||
* @var Book
|
||||
*/
|
||||
protected $book;
|
||||
protected Book $book;
|
||||
|
||||
/**
|
||||
* BookContents constructor.
|
||||
*/
|
||||
public function __construct(Book $book)
|
||||
{
|
||||
$this->book = $book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current priority of the last item
|
||||
* at the top-level of the book.
|
||||
* Get the current priority of the last item at the top-level of the book.
|
||||
*/
|
||||
public function getLastPriority(): int
|
||||
{
|
||||
@@ -188,7 +181,7 @@ class BookContents
|
||||
$model->changeBook($newBook->id);
|
||||
}
|
||||
|
||||
if ($chapterChanged) {
|
||||
if ($model instanceof Page && $chapterChanged) {
|
||||
$model->chapter_id = $newChapter->id ?? 0;
|
||||
}
|
||||
|
||||
@@ -242,7 +235,7 @@ class BookContents
|
||||
}
|
||||
|
||||
$hasPageEditPermission = userCan('page-update', $model);
|
||||
$newParentInRightLocation = ($newParent instanceof Book || $newParent->book_id === $newBook->id);
|
||||
$newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
|
||||
$newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
|
||||
$hasNewParentPermission = userCan($newParentPermission, $newParent);
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Actions\Tag;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\HasCoverImage;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
@@ -71,8 +73,10 @@ class Cloner
|
||||
$bookDetails = $this->entityToInputData($original);
|
||||
$bookDetails['name'] = $newName;
|
||||
|
||||
// Clone book
|
||||
$copyBook = $this->bookRepo->create($bookDetails);
|
||||
|
||||
// Clone contents
|
||||
$directChildren = $original->getDirectChildren();
|
||||
foreach ($directChildren as $child) {
|
||||
if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) {
|
||||
@@ -84,6 +88,14 @@ class Cloner
|
||||
}
|
||||
}
|
||||
|
||||
// Clone bookshelf relationships
|
||||
/** @var Bookshelf $shelf */
|
||||
foreach ($original->shelves as $shelf) {
|
||||
if (userCan('bookshelf-update', $shelf)) {
|
||||
$shelf->appendBook($copyBook);
|
||||
}
|
||||
}
|
||||
|
||||
return $copyBook;
|
||||
}
|
||||
|
||||
@@ -98,9 +110,11 @@ class Cloner
|
||||
$inputData['tags'] = $this->entityTagsToInputArray($entity);
|
||||
|
||||
// Add a cover to the data if existing on the original entity
|
||||
if ($entity->cover instanceof Image) {
|
||||
$uploadedFile = $this->imageToUploadedFile($entity->cover);
|
||||
$inputData['image'] = $uploadedFile;
|
||||
if ($entity instanceof HasCoverImage) {
|
||||
$cover = $entity->cover()->first();
|
||||
if ($cover) {
|
||||
$inputData['image'] = $this->imageToUploadedFile($cover);
|
||||
}
|
||||
}
|
||||
|
||||
return $inputData;
|
||||
@@ -111,8 +125,7 @@ class Cloner
|
||||
*/
|
||||
public function copyEntityPermissions(Entity $sourceEntity, Entity $targetEntity): void
|
||||
{
|
||||
$targetEntity->restricted = $sourceEntity->restricted;
|
||||
$permissions = $sourceEntity->permissions()->get(['role_id', 'action'])->toArray();
|
||||
$permissions = $sourceEntity->permissions()->get(['role_id', 'view', 'create', 'update', 'delete'])->toArray();
|
||||
$targetEntity->permissions()->delete();
|
||||
$targetEntity->permissions()->createMany($permissions);
|
||||
$targetEntity->rebuildPermissions();
|
||||
|
||||
@@ -65,7 +65,7 @@ class HierarchyTransformer
|
||||
foreach ($book->chapters as $index => $chapter) {
|
||||
$newBook = $this->transformChapterToBook($chapter);
|
||||
$shelfBookSyncData[$newBook->id] = ['order' => $index];
|
||||
if (!$newBook->restricted) {
|
||||
if (!$newBook->hasPermissions()) {
|
||||
$this->cloner->copyEntityPermissions($shelf, $newBook);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
namespace BookStack\Entities\Tools\Markdown;
|
||||
|
||||
use League\CommonMark\Block\Element\AbstractBlock;
|
||||
use League\CommonMark\Block\Element\ListItem;
|
||||
use League\CommonMark\Block\Element\Paragraph;
|
||||
use League\CommonMark\Block\Renderer\BlockRendererInterface;
|
||||
use League\CommonMark\Block\Renderer\ListItemRenderer;
|
||||
use League\CommonMark\ElementRendererInterface;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
|
||||
use League\CommonMark\Extension\CommonMark\Renderer\Block\ListItemRenderer;
|
||||
use League\CommonMark\Extension\TaskList\TaskListItemMarker;
|
||||
use League\CommonMark\HtmlElement;
|
||||
use League\CommonMark\Node\Block\Paragraph;
|
||||
use League\CommonMark\Node\Node;
|
||||
use League\CommonMark\Renderer\ChildNodeRendererInterface;
|
||||
use League\CommonMark\Renderer\NodeRendererInterface;
|
||||
use League\CommonMark\Util\HtmlElement;
|
||||
|
||||
class CustomListItemRenderer implements BlockRendererInterface
|
||||
class CustomListItemRenderer implements NodeRendererInterface
|
||||
{
|
||||
protected $baseRenderer;
|
||||
protected ListItemRenderer $baseRenderer;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -23,11 +23,11 @@ class CustomListItemRenderer implements BlockRendererInterface
|
||||
/**
|
||||
* @return HtmlElement|string|null
|
||||
*/
|
||||
public function render(AbstractBlock $block, ElementRendererInterface $htmlRenderer, bool $inTightList = false)
|
||||
public function render(Node $node, ChildNodeRendererInterface $childRenderer)
|
||||
{
|
||||
$listItem = $this->baseRenderer->render($block, $htmlRenderer, $inTightList);
|
||||
$listItem = $this->baseRenderer->render($node, $childRenderer);
|
||||
|
||||
if ($this->startsTaskListItem($block)) {
|
||||
if ($node instanceof ListItem && $this->startsTaskListItem($node) && $listItem instanceof HtmlElement) {
|
||||
$listItem->setAttribute('class', 'task-list-item');
|
||||
}
|
||||
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
namespace BookStack\Entities\Tools\Markdown;
|
||||
|
||||
use League\CommonMark\ConfigurableEnvironmentInterface;
|
||||
use League\CommonMark\Environment\EnvironmentBuilderInterface;
|
||||
use League\CommonMark\Extension\ExtensionInterface;
|
||||
use League\CommonMark\Extension\Strikethrough\Strikethrough;
|
||||
use League\CommonMark\Extension\Strikethrough\StrikethroughDelimiterProcessor;
|
||||
|
||||
class CustomStrikeThroughExtension implements ExtensionInterface
|
||||
{
|
||||
public function register(ConfigurableEnvironmentInterface $environment)
|
||||
public function register(EnvironmentBuilderInterface $environment): void
|
||||
{
|
||||
$environment->addDelimiterProcessor(new StrikethroughDelimiterProcessor());
|
||||
$environment->addInlineRenderer(Strikethrough::class, new CustomStrikethroughRenderer());
|
||||
$environment->addRenderer(Strikethrough::class, new CustomStrikethroughRenderer());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,25 +2,23 @@
|
||||
|
||||
namespace BookStack\Entities\Tools\Markdown;
|
||||
|
||||
use League\CommonMark\ElementRendererInterface;
|
||||
use League\CommonMark\Extension\Strikethrough\Strikethrough;
|
||||
use League\CommonMark\HtmlElement;
|
||||
use League\CommonMark\Inline\Element\AbstractInline;
|
||||
use League\CommonMark\Inline\Renderer\InlineRendererInterface;
|
||||
use League\CommonMark\Node\Node;
|
||||
use League\CommonMark\Renderer\ChildNodeRendererInterface;
|
||||
use League\CommonMark\Renderer\NodeRendererInterface;
|
||||
use League\CommonMark\Util\HtmlElement;
|
||||
|
||||
/**
|
||||
* This is a somewhat clone of the League\CommonMark\Extension\Strikethrough\StrikethroughRender
|
||||
* class but modified slightly to use <s> HTML tags instead of <del> in order to
|
||||
* match front-end markdown-it rendering.
|
||||
*/
|
||||
class CustomStrikethroughRenderer implements InlineRendererInterface
|
||||
class CustomStrikethroughRenderer implements NodeRendererInterface
|
||||
{
|
||||
public function render(AbstractInline $inline, ElementRendererInterface $htmlRenderer)
|
||||
public function render(Node $node, ChildNodeRendererInterface $childRenderer)
|
||||
{
|
||||
if (!($inline instanceof Strikethrough)) {
|
||||
throw new \InvalidArgumentException('Incompatible inline type: ' . get_class($inline));
|
||||
}
|
||||
Strikethrough::assertInstanceOf($node);
|
||||
|
||||
return new HtmlElement('s', $inline->getData('attributes', []), $htmlRenderer->renderInlines($inline->children()));
|
||||
return new HtmlElement('s', $node->data->get('attributes'), $childRenderer->renderNodes($node->children()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@ namespace BookStack\Entities\Tools\Markdown;
|
||||
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use League\CommonMark\Block\Element\ListItem;
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
use League\CommonMark\Environment;
|
||||
use League\CommonMark\Environment\Environment;
|
||||
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
|
||||
use League\CommonMark\Extension\CommonMark\Node\Block\ListItem;
|
||||
use League\CommonMark\Extension\Table\TableExtension;
|
||||
use League\CommonMark\Extension\TaskList\TaskListExtension;
|
||||
use League\CommonMark\MarkdownConverter;
|
||||
|
||||
class MarkdownToHtml
|
||||
{
|
||||
@@ -21,15 +22,16 @@ class MarkdownToHtml
|
||||
|
||||
public function convert(): string
|
||||
{
|
||||
$environment = Environment::createCommonMarkEnvironment();
|
||||
$environment = new Environment();
|
||||
$environment->addExtension(new CommonMarkCoreExtension());
|
||||
$environment->addExtension(new TableExtension());
|
||||
$environment->addExtension(new TaskListExtension());
|
||||
$environment->addExtension(new CustomStrikeThroughExtension());
|
||||
$environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment;
|
||||
$converter = new CommonMarkConverter([], $environment);
|
||||
$converter = new MarkdownConverter($environment);
|
||||
|
||||
$environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);
|
||||
$environment->addRenderer(ListItem::class, new CustomListItemRenderer(), 10);
|
||||
|
||||
return $converter->convertToHtml($this->markdown);
|
||||
return $converter->convert($this->markdown)->getContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,20 +19,15 @@ use Illuminate\Support\Str;
|
||||
|
||||
class PageContent
|
||||
{
|
||||
protected Page $page;
|
||||
|
||||
/**
|
||||
* PageContent constructor.
|
||||
*/
|
||||
public function __construct(Page $page)
|
||||
{
|
||||
$this->page = $page;
|
||||
public function __construct(
|
||||
protected Page $page
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the content of the page with new provided HTML.
|
||||
*/
|
||||
public function setNewHTML(string $html)
|
||||
public function setNewHTML(string $html): void
|
||||
{
|
||||
$html = $this->extractBase64ImagesFromHtml($html);
|
||||
$this->page->html = $this->formatHtml($html);
|
||||
@@ -43,7 +38,7 @@ class PageContent
|
||||
/**
|
||||
* Update the content of the page with new provided Markdown content.
|
||||
*/
|
||||
public function setNewMarkdown(string $markdown)
|
||||
public function setNewMarkdown(string $markdown): void
|
||||
{
|
||||
$markdown = $this->extractBase64ImagesFromMarkdown($markdown);
|
||||
$this->page->markdown = $markdown;
|
||||
@@ -57,7 +52,7 @@ class PageContent
|
||||
*/
|
||||
protected function extractBase64ImagesFromHtml(string $htmlText): string
|
||||
{
|
||||
if (empty($htmlText) || strpos($htmlText, 'data:image') === false) {
|
||||
if (empty($htmlText) || !str_contains($htmlText, 'data:image')) {
|
||||
return $htmlText;
|
||||
}
|
||||
|
||||
@@ -91,7 +86,7 @@ class PageContent
|
||||
* Attempting to capture the whole data uri using regex can cause PHP
|
||||
* PCRE limits to be hit with larger, multi-MB, files.
|
||||
*/
|
||||
protected function extractBase64ImagesFromMarkdown(string $markdown)
|
||||
protected function extractBase64ImagesFromMarkdown(string $markdown): string
|
||||
{
|
||||
$matches = [];
|
||||
$contentLength = strlen($markdown);
|
||||
@@ -183,32 +178,13 @@ class PageContent
|
||||
$childNodes = $body->childNodes;
|
||||
$xPath = new DOMXPath($doc);
|
||||
|
||||
// Set ids on top-level nodes
|
||||
// Map to hold used ID references
|
||||
$idMap = [];
|
||||
foreach ($childNodes as $index => $childNode) {
|
||||
[$oldId, $newId] = $this->setUniqueId($childNode, $idMap);
|
||||
if ($newId && $newId !== $oldId) {
|
||||
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
|
||||
}
|
||||
}
|
||||
// Map to hold changing ID references
|
||||
$changeMap = [];
|
||||
|
||||
// Set ids on nested header nodes
|
||||
$nestedHeaders = $xPath->query('//body//*//h1|//body//*//h2|//body//*//h3|//body//*//h4|//body//*//h5|//body//*//h6');
|
||||
foreach ($nestedHeaders as $nestedHeader) {
|
||||
[$oldId, $newId] = $this->setUniqueId($nestedHeader, $idMap);
|
||||
if ($newId && $newId !== $oldId) {
|
||||
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure no duplicate ids within child items
|
||||
$idElems = $xPath->query('//body//*//*[@id]');
|
||||
foreach ($idElems as $domElem) {
|
||||
[$oldId, $newId] = $this->setUniqueId($domElem, $idMap);
|
||||
if ($newId && $newId !== $oldId) {
|
||||
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
|
||||
}
|
||||
}
|
||||
$this->updateIdsRecursively($body, 0, $idMap, $changeMap);
|
||||
$this->updateLinks($xPath, $changeMap);
|
||||
|
||||
// Generate inner html as a string
|
||||
$html = '';
|
||||
@@ -223,20 +199,53 @@ class PageContent
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the all links to the $old location to instead point to $new.
|
||||
* For the given DOMNode, traverse its children recursively and update IDs
|
||||
* where required (Top-level, headers & elements with IDs).
|
||||
* Will update the provided $changeMap array with changes made, where keys are the old
|
||||
* ids and the corresponding values are the new ids.
|
||||
*/
|
||||
protected function updateLinks(DOMXPath $xpath, string $old, string $new)
|
||||
protected function updateIdsRecursively(DOMNode $element, int $depth, array &$idMap, array &$changeMap): void
|
||||
{
|
||||
$old = str_replace('"', '', $old);
|
||||
$matchingLinks = $xpath->query('//body//*//*[@href="' . $old . '"]');
|
||||
foreach ($matchingLinks as $domElem) {
|
||||
$domElem->setAttribute('href', $new);
|
||||
/* @var DOMNode $child */
|
||||
foreach ($element->childNodes as $child) {
|
||||
if ($child instanceof DOMElement && ($depth === 0 || in_array($child->nodeName, ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) || $child->getAttribute('id'))) {
|
||||
[$oldId, $newId] = $this->setUniqueId($child, $idMap);
|
||||
if ($newId && $newId !== $oldId && !isset($idMap[$oldId])) {
|
||||
$changeMap[$oldId] = $newId;
|
||||
}
|
||||
}
|
||||
|
||||
if ($child->hasChildNodes()) {
|
||||
$this->updateIdsRecursively($child, $depth + 1, $idMap, $changeMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the all links in the given xpath to apply requires changes within the
|
||||
* given $changeMap array.
|
||||
*/
|
||||
protected function updateLinks(DOMXPath $xpath, array $changeMap): void
|
||||
{
|
||||
if (empty($changeMap)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$links = $xpath->query('//body//*//*[@href]');
|
||||
/** @var DOMElement $domElem */
|
||||
foreach ($links as $domElem) {
|
||||
$href = ltrim($domElem->getAttribute('href'), '#');
|
||||
$newHref = $changeMap[$href] ?? null;
|
||||
if ($newHref) {
|
||||
$domElem->setAttribute('href', '#' . $newHref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a unique id on the given DOMElement.
|
||||
* A map for existing ID's should be passed in to check for current existence.
|
||||
* A map for existing ID's should be passed in to check for current existence,
|
||||
* and this will be updated with any new IDs set upon elements.
|
||||
* Returns a pair of strings in the format [old_id, new_id].
|
||||
*/
|
||||
protected function setUniqueId(DOMNode $element, array &$idMap): array
|
||||
@@ -247,7 +256,7 @@ class PageContent
|
||||
|
||||
// Stop if there's an existing valid id that has not already been used.
|
||||
$existingId = $element->getAttribute('id');
|
||||
if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
|
||||
if (str_starts_with($existingId, 'bkmrk') && !isset($idMap[$existingId])) {
|
||||
$idMap[$existingId] = true;
|
||||
|
||||
return [$existingId, $existingId];
|
||||
@@ -258,7 +267,7 @@ class PageContent
|
||||
// the same content is passed through.
|
||||
$contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
|
||||
$newId = urlencode($contentId);
|
||||
$loopIndex = 0;
|
||||
$loopIndex = 1;
|
||||
|
||||
while (isset($idMap[$newId])) {
|
||||
$newId = urlencode($contentId . '-' . $loopIndex);
|
||||
@@ -295,7 +304,9 @@ class PageContent
|
||||
if ($blankIncludes) {
|
||||
$content = $this->blankPageIncludes($content);
|
||||
} else {
|
||||
$content = $this->parsePageIncludes($content);
|
||||
for ($includeDepth = 0; $includeDepth < 3; $includeDepth++) {
|
||||
$content = $this->parsePageIncludes($content);
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
@@ -440,8 +451,8 @@ class PageContent
|
||||
{
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument();
|
||||
$html = '<body>' . $html . '</body>';
|
||||
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
|
||||
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
|
||||
$doc->loadHTML($html);
|
||||
|
||||
return $doc;
|
||||
}
|
||||
|
||||
@@ -3,24 +3,25 @@
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Facades\Activity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class PermissionsUpdater
|
||||
{
|
||||
/**
|
||||
* Update an entities permissions from a permission form submit request.
|
||||
*/
|
||||
public function updateFromPermissionsForm(Entity $entity, Request $request)
|
||||
public function updateFromPermissionsForm(Entity $entity, Request $request): void
|
||||
{
|
||||
$restricted = $request->get('restricted') === 'true';
|
||||
$permissions = $request->get('restrictions', null);
|
||||
$permissions = $request->get('permissions', null);
|
||||
$ownerId = $request->get('owned_by', null);
|
||||
|
||||
$entity->restricted = $restricted;
|
||||
$entity->permissions()->delete();
|
||||
|
||||
if (!is_null($permissions)) {
|
||||
@@ -38,12 +39,44 @@ class PermissionsUpdater
|
||||
Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update permissions from API request data.
|
||||
*/
|
||||
public function updateFromApiRequestData(Entity $entity, array $data): void
|
||||
{
|
||||
if (isset($data['role_permissions'])) {
|
||||
$entity->permissions()->where('role_id', '!=', 0)->delete();
|
||||
$rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions($data['role_permissions'] ?? [], false);
|
||||
$entity->permissions()->createMany($rolePermissionData);
|
||||
}
|
||||
|
||||
if (array_key_exists('fallback_permissions', $data)) {
|
||||
$entity->permissions()->where('role_id', '=', 0)->delete();
|
||||
}
|
||||
|
||||
if (isset($data['fallback_permissions']['inheriting']) && $data['fallback_permissions']['inheriting'] !== true) {
|
||||
$data = $data['fallback_permissions'];
|
||||
$data['role_id'] = 0;
|
||||
$rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions([$data], true);
|
||||
$entity->permissions()->createMany($rolePermissionData);
|
||||
}
|
||||
|
||||
if (isset($data['owner_id'])) {
|
||||
$this->updateOwnerFromId($entity, intval($data['owner_id']));
|
||||
}
|
||||
|
||||
$entity->save();
|
||||
$entity->rebuildPermissions();
|
||||
|
||||
Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the owner of the given entity.
|
||||
* Checks the user exists in the system first.
|
||||
* Does not save the model, just updates it.
|
||||
*/
|
||||
protected function updateOwnerFromId(Entity $entity, int $newOwnerId)
|
||||
protected function updateOwnerFromId(Entity $entity, int $newOwnerId): void
|
||||
{
|
||||
$newOwner = User::query()->find($newOwnerId);
|
||||
if (!is_null($newOwner)) {
|
||||
@@ -52,18 +85,77 @@ class PermissionsUpdater
|
||||
}
|
||||
|
||||
/**
|
||||
* Format permissions provided from a permission form to be
|
||||
* EntityPermission data.
|
||||
* Format permissions provided from a permission form to be EntityPermission data.
|
||||
*/
|
||||
protected function formatPermissionsFromRequestToEntityPermissions(array $permissions): Collection
|
||||
protected function formatPermissionsFromRequestToEntityPermissions(array $permissions): array
|
||||
{
|
||||
return collect($permissions)->flatMap(function ($restrictions, $roleId) {
|
||||
return collect($restrictions)->keys()->map(function ($action) use ($roleId) {
|
||||
return [
|
||||
'role_id' => $roleId,
|
||||
'action' => strtolower($action),
|
||||
];
|
||||
});
|
||||
});
|
||||
$formatted = [];
|
||||
|
||||
foreach ($permissions as $roleId => $info) {
|
||||
$entityPermissionData = ['role_id' => $roleId];
|
||||
foreach (EntityPermission::PERMISSIONS as $permission) {
|
||||
$entityPermissionData[$permission] = (($info[$permission] ?? false) === "true");
|
||||
}
|
||||
$formatted[] = $entityPermissionData;
|
||||
}
|
||||
|
||||
return $this->filterEntityPermissionDataUponRole($formatted, true);
|
||||
}
|
||||
|
||||
protected function formatPermissionsFromApiRequestToEntityPermissions(array $permissions, bool $allowFallback): array
|
||||
{
|
||||
$formatted = [];
|
||||
|
||||
foreach ($permissions as $requestPermissionData) {
|
||||
$entityPermissionData = ['role_id' => $requestPermissionData['role_id']];
|
||||
foreach (EntityPermission::PERMISSIONS as $permission) {
|
||||
$entityPermissionData[$permission] = boolval($requestPermissionData[$permission] ?? false);
|
||||
}
|
||||
$formatted[] = $entityPermissionData;
|
||||
}
|
||||
|
||||
return $this->filterEntityPermissionDataUponRole($formatted, $allowFallback);
|
||||
}
|
||||
|
||||
protected function filterEntityPermissionDataUponRole(array $entityPermissionData, bool $allowFallback): array
|
||||
{
|
||||
$roleIds = [];
|
||||
foreach ($entityPermissionData as $permissionEntry) {
|
||||
$roleIds[] = intval($permissionEntry['role_id']);
|
||||
}
|
||||
|
||||
$actualRoleIds = array_unique(array_values(array_filter($roleIds)));
|
||||
$rolesById = Role::query()->whereIn('id', $actualRoleIds)->get('id')->keyBy('id');
|
||||
|
||||
return array_values(array_filter($entityPermissionData, function ($data) use ($rolesById, $allowFallback) {
|
||||
if (intval($data['role_id']) === 0) {
|
||||
return $allowFallback;
|
||||
}
|
||||
|
||||
return $rolesById->has($data['role_id']);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy down the permissions of the given shelf to all child books.
|
||||
*/
|
||||
public function updateBookPermissionsFromShelf(Bookshelf $shelf, $checkUserPermissions = true): int
|
||||
{
|
||||
$shelfPermissions = $shelf->permissions()->get(['role_id', 'view', 'create', 'update', 'delete'])->toArray();
|
||||
$shelfBooks = $shelf->books()->get(['id', 'owned_by']);
|
||||
$updatedBookCount = 0;
|
||||
|
||||
/** @var Book $book */
|
||||
foreach ($shelfBooks as $book) {
|
||||
if ($checkUserPermissions && !userCan('restrictions-manage', $book)) {
|
||||
continue;
|
||||
}
|
||||
$book->permissions()->delete();
|
||||
$book->permissions()->createMany($shelfPermissions);
|
||||
$book->rebuildPermissions();
|
||||
$updatedBookCount++;
|
||||
}
|
||||
|
||||
return $updatedBookCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,25 +2,18 @@
|
||||
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
use Whoops\Handler\Handler;
|
||||
use Illuminate\Contracts\Foundation\ExceptionRenderer;
|
||||
|
||||
class WhoopsBookStackPrettyHandler extends Handler
|
||||
class BookStackExceptionHandlerPage implements ExceptionRenderer
|
||||
{
|
||||
/**
|
||||
* @return int|null A handler may return nothing, or a Handler::HANDLE_* constant
|
||||
*/
|
||||
public function handle()
|
||||
public function render($throwable)
|
||||
{
|
||||
$exception = $this->getException();
|
||||
|
||||
echo view('errors.debug', [
|
||||
'error' => $exception->getMessage(),
|
||||
'errorClass' => get_class($exception),
|
||||
'trace' => $exception->getTraceAsString(),
|
||||
return view('errors.debug', [
|
||||
'error' => $throwable->getMessage(),
|
||||
'errorClass' => get_class($throwable),
|
||||
'trace' => $throwable->getTraceAsString(),
|
||||
'environment' => $this->getEnvironment(),
|
||||
])->render();
|
||||
|
||||
return Handler::QUIT;
|
||||
}
|
||||
|
||||
protected function safeReturn(callable $callback, $default = null)
|
||||
@@ -17,7 +17,7 @@ class Handler extends ExceptionHandler
|
||||
/**
|
||||
* A list of the exception types that are not reported.
|
||||
*
|
||||
* @var array
|
||||
* @var array<int, class-string<\Throwable>>
|
||||
*/
|
||||
protected $dontReport = [
|
||||
NotFoundException::class,
|
||||
@@ -25,9 +25,9 @@ class Handler extends ExceptionHandler
|
||||
];
|
||||
|
||||
/**
|
||||
* A list of the inputs that are never flashed for validation exceptions.
|
||||
* A list of the inputs that are never flashed to the session on validation exceptions.
|
||||
*
|
||||
* @var array
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $dontFlash = [
|
||||
'current_password',
|
||||
@@ -98,6 +98,7 @@ class Handler extends ExceptionHandler
|
||||
];
|
||||
|
||||
if ($e instanceof ValidationException) {
|
||||
$responseData['error']['message'] = 'The given data was invalid.';
|
||||
$responseData['error']['validation'] = $e->errors();
|
||||
$code = $e->status;
|
||||
}
|
||||
|
||||
@@ -32,10 +32,15 @@ abstract class ApiController extends Controller
|
||||
*/
|
||||
public function getValidationRules(): array
|
||||
{
|
||||
if (method_exists($this, 'rules')) {
|
||||
return $this->rules();
|
||||
}
|
||||
return $this->rules();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules for the actions in this controller.
|
||||
* Defaults to a $rules property but can be a rules() method.
|
||||
*/
|
||||
protected function rules(): array
|
||||
{
|
||||
return $this->rules;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,9 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AttachmentApiController extends ApiController
|
||||
{
|
||||
protected $attachmentService;
|
||||
|
||||
public function __construct(AttachmentService $attachmentService)
|
||||
{
|
||||
$this->attachmentService = $attachmentService;
|
||||
public function __construct(
|
||||
protected AttachmentService $attachmentService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,13 +172,13 @@ class AttachmentApiController extends ApiController
|
||||
'name' => ['required', 'min:1', 'max:255', 'string'],
|
||||
'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
|
||||
'file' => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()),
|
||||
'link' => ['required_without:file', 'min:1', 'max:255', 'safe_url'],
|
||||
'link' => ['required_without:file', 'min:1', 'max:2000', 'safe_url'],
|
||||
],
|
||||
'update' => [
|
||||
'name' => ['min:1', 'max:255', 'string'],
|
||||
'uploaded_to' => ['integer', 'exists:pages,id'],
|
||||
'file' => $this->attachmentService->getFileValidationRules(),
|
||||
'link' => ['min:1', 'max:255', 'safe_url'],
|
||||
'link' => ['min:1', 'max:2000', 'safe_url'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
namespace BookStack\Http\Controllers\Api;
|
||||
|
||||
use BookStack\Api\ApiEntityListFormatter;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BookApiController extends ApiController
|
||||
{
|
||||
protected $bookRepo;
|
||||
protected BookRepo $bookRepo;
|
||||
|
||||
public function __construct(BookRepo $bookRepo)
|
||||
{
|
||||
@@ -47,11 +51,25 @@ class BookApiController extends ApiController
|
||||
|
||||
/**
|
||||
* View the details of a single book.
|
||||
* The response data will contain 'content' property listing the chapter and pages directly within, in
|
||||
* the same structure as you'd see within the BookStack interface when viewing a book. Top-level
|
||||
* contents will have a 'type' property to distinguish between pages & chapters.
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])->findOrFail($id);
|
||||
|
||||
$contents = (new BookContents($book))->getTree(true, false)->all();
|
||||
$contentsApiData = (new ApiEntityListFormatter($contents))
|
||||
->withType()
|
||||
->withField('pages', function (Entity $entity) {
|
||||
if ($entity instanceof Chapter) {
|
||||
return (new ApiEntityListFormatter($entity->pages->all()))->format();
|
||||
}
|
||||
return null;
|
||||
})->format();
|
||||
$book->setAttribute('contents', $contentsApiData);
|
||||
|
||||
return response()->json($book);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,6 @@ class BookshelfApiController extends ApiController
|
||||
{
|
||||
protected BookshelfRepo $bookshelfRepo;
|
||||
|
||||
/**
|
||||
* BookshelfApiController constructor.
|
||||
*/
|
||||
public function __construct(BookshelfRepo $bookshelfRepo)
|
||||
{
|
||||
$this->bookshelfRepo = $bookshelfRepo;
|
||||
|
||||
100
app/Http/Controllers/Api/ContentPermissionApiController.php
Normal file
100
app/Http/Controllers/Api/ContentPermissionApiController.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers\Api;
|
||||
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ContentPermissionApiController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionsUpdater $permissionsUpdater,
|
||||
protected EntityProvider $entities
|
||||
) {
|
||||
}
|
||||
|
||||
protected $rules = [
|
||||
'update' => [
|
||||
'owner_id' => ['int'],
|
||||
|
||||
'role_permissions' => ['array'],
|
||||
'role_permissions.*.role_id' => ['required', 'int', 'exists:roles,id'],
|
||||
'role_permissions.*.view' => ['required', 'boolean'],
|
||||
'role_permissions.*.create' => ['required', 'boolean'],
|
||||
'role_permissions.*.update' => ['required', 'boolean'],
|
||||
'role_permissions.*.delete' => ['required', 'boolean'],
|
||||
|
||||
'fallback_permissions' => ['nullable'],
|
||||
'fallback_permissions.inheriting' => ['required_with:fallback_permissions', 'boolean'],
|
||||
'fallback_permissions.view' => ['required_if:fallback_permissions.inheriting,false', 'boolean'],
|
||||
'fallback_permissions.create' => ['required_if:fallback_permissions.inheriting,false', 'boolean'],
|
||||
'fallback_permissions.update' => ['required_if:fallback_permissions.inheriting,false', 'boolean'],
|
||||
'fallback_permissions.delete' => ['required_if:fallback_permissions.inheriting,false', 'boolean'],
|
||||
]
|
||||
];
|
||||
|
||||
/**
|
||||
* Read the configured content-level permissions for the item of the given type and ID.
|
||||
* 'contentType' should be one of: page, book, chapter, bookshelf.
|
||||
* 'contentId' should be the relevant ID of that item type you'd like to handle permissions for.
|
||||
* The permissions shown are those that override the default for just the specified item, they do not show the
|
||||
* full evaluated permission for a role, nor do they reflect permissions inherited from other items in the hierarchy.
|
||||
* Fallback permission values may be `null` when inheriting is active.
|
||||
*/
|
||||
public function read(string $contentType, string $contentId)
|
||||
{
|
||||
$entity = $this->entities->get($contentType)
|
||||
->newQuery()->scopes(['visible'])->findOrFail($contentId);
|
||||
|
||||
$this->checkOwnablePermission('restrictions-manage', $entity);
|
||||
|
||||
return response()->json($this->formattedPermissionDataForEntity($entity));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the configured content-level permission overrides for the item of the given type and ID.
|
||||
* 'contentType' should be one of: page, book, chapter, bookshelf.
|
||||
* 'contentId' should be the relevant ID of that item type you'd like to handle permissions for.
|
||||
* Providing an empty `role_permissions` array will remove any existing configured role permissions,
|
||||
* so you may want to fetch existing permissions beforehand if just adding/removing a single item.
|
||||
* You should completely omit the `owner_id`, `role_permissions` and/or the `fallback_permissions` properties
|
||||
* from your request data if you don't wish to update details within those categories.
|
||||
*/
|
||||
public function update(Request $request, string $contentType, string $contentId)
|
||||
{
|
||||
$entity = $this->entities->get($contentType)
|
||||
->newQuery()->scopes(['visible'])->findOrFail($contentId);
|
||||
|
||||
$this->checkOwnablePermission('restrictions-manage', $entity);
|
||||
|
||||
$data = $this->validate($request, $this->rules()['update']);
|
||||
$this->permissionsUpdater->updateFromApiRequestData($entity, $data);
|
||||
|
||||
return response()->json($this->formattedPermissionDataForEntity($entity));
|
||||
}
|
||||
|
||||
protected function formattedPermissionDataForEntity(Entity $entity): array
|
||||
{
|
||||
$rolePermissions = $entity->permissions()
|
||||
->where('role_id', '!=', 0)
|
||||
->with(['role:id,display_name'])
|
||||
->get();
|
||||
|
||||
$fallback = $entity->permissions()->where('role_id', '=', 0)->first();
|
||||
$fallbackData = [
|
||||
'inheriting' => is_null($fallback),
|
||||
'view' => $fallback->view ?? null,
|
||||
'create' => $fallback->create ?? null,
|
||||
'update' => $fallback->update ?? null,
|
||||
'delete' => $fallback->delete ?? null,
|
||||
];
|
||||
|
||||
return [
|
||||
'owner' => $entity->ownedBy()->first(),
|
||||
'role_permissions' => $rolePermissions,
|
||||
'fallback_permissions' => $fallbackData,
|
||||
];
|
||||
}
|
||||
}
|
||||
146
app/Http/Controllers/Api/ImageGalleryApiController.php
Normal file
146
app/Http/Controllers/Api/ImageGalleryApiController.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers\Api;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ImageGalleryApiController extends ApiController
|
||||
{
|
||||
protected array $fieldsToExpose = [
|
||||
'id', 'name', 'url', 'path', 'type', 'uploaded_to', 'created_by', 'updated_by', 'created_at', 'updated_at',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected ImageRepo $imageRepo
|
||||
) {
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'create' => [
|
||||
'type' => ['required', 'string', 'in:gallery,drawio'],
|
||||
'uploaded_to' => ['required', 'integer'],
|
||||
'image' => ['required', 'file', ...$this->getImageValidationRules()],
|
||||
'name' => ['string', 'max:180'],
|
||||
],
|
||||
'update' => [
|
||||
'name' => ['string', 'max:180'],
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a listing of images in the system. Includes gallery (page content) images and drawings.
|
||||
* Requires visibility of the page they're originally uploaded to.
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$images = Image::query()->scopes(['visible'])
|
||||
->select($this->fieldsToExpose)
|
||||
->whereIn('type', ['gallery', 'drawio']);
|
||||
|
||||
return $this->apiListingResponse($images, [
|
||||
...$this->fieldsToExpose
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new image in the system.
|
||||
* Since "image" is expected to be a file, this needs to be a 'multipart/form-data' type request.
|
||||
* The provided "uploaded_to" should be an existing page ID in the system.
|
||||
* If the "name" parameter is omitted, the filename of the provided image file will be used instead.
|
||||
* The "type" parameter should be 'gallery' for page content images, and 'drawio' should only be used
|
||||
* when the file is a PNG file with diagrams.net image data embedded within.
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
$this->checkPermission('image-create-all');
|
||||
$data = $this->validate($request, $this->rules()['create']);
|
||||
Page::visible()->findOrFail($data['uploaded_to']);
|
||||
|
||||
$image = $this->imageRepo->saveNew($data['image'], $data['type'], $data['uploaded_to']);
|
||||
|
||||
if (isset($data['name'])) {
|
||||
$image->refresh();
|
||||
$image->update(['name' => $data['name']]);
|
||||
}
|
||||
|
||||
return response()->json($this->formatForSingleResponse($image));
|
||||
}
|
||||
|
||||
/**
|
||||
* View the details of a single image.
|
||||
* The "thumbs" response property contains links to scaled variants that BookStack may use in its UI.
|
||||
* The "content" response property provides HTML and Markdown content, in the format that BookStack
|
||||
* would typically use by default to add the image in page content, as a convenience.
|
||||
* Actual image file data is not provided but can be fetched via the "url" response property.
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$image = Image::query()->scopes(['visible'])->findOrFail($id);
|
||||
|
||||
return response()->json($this->formatForSingleResponse($image));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the details of an existing image in the system.
|
||||
* Only allows updating of the image name at this time.
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$data = $this->validate($request, $this->rules()['update']);
|
||||
$image = $this->imageRepo->getById($id);
|
||||
$this->checkOwnablePermission('page-view', $image->getPage());
|
||||
$this->checkOwnablePermission('image-update', $image);
|
||||
|
||||
$this->imageRepo->updateImageDetails($image, $data);
|
||||
|
||||
return response()->json($this->formatForSingleResponse($image));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an image from the system.
|
||||
* Will also delete thumbnails for the image.
|
||||
* Does not check or handle image usage so this could leave pages with broken image references.
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
$image = $this->imageRepo->getById($id);
|
||||
$this->checkOwnablePermission('page-view', $image->getPage());
|
||||
$this->checkOwnablePermission('image-delete', $image);
|
||||
$this->imageRepo->destroyImage($image);
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the given image model for single-result display.
|
||||
*/
|
||||
protected function formatForSingleResponse(Image $image): array
|
||||
{
|
||||
$this->imageRepo->loadThumbs($image);
|
||||
$data = $image->getAttributes();
|
||||
$data['created_by'] = $image->createdBy;
|
||||
$data['updated_by'] = $image->updatedBy;
|
||||
$data['content'] = [];
|
||||
|
||||
$escapedUrl = htmlentities($image->url);
|
||||
$escapedName = htmlentities($image->name);
|
||||
if ($image->type === 'drawio') {
|
||||
$data['content']['html'] = "<div drawio-diagram=\"{$image->id}\"><img src=\"{$escapedUrl}\"></div>";
|
||||
$data['content']['markdown'] = $data['content']['html'];
|
||||
} else {
|
||||
$escapedDisplayThumb = htmlentities($image->thumbs['display']);
|
||||
$data['content']['html'] = "<a href=\"{$escapedUrl}\" target=\"_blank\"><img src=\"{$escapedDisplayThumb}\" alt=\"{$escapedName}\"></a>";
|
||||
$mdEscapedName = str_replace(']', '', str_replace('[', '', $image->name));
|
||||
$mdEscapedThumb = str_replace(']', '', str_replace('[', '', $image->thumbs['display']));
|
||||
$data['content']['markdown'] = "";
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
136
app/Http/Controllers/Api/RoleApiController.php
Normal file
136
app/Http/Controllers/Api/RoleApiController.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers\Api;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionsRepo;
|
||||
use BookStack\Auth\Role;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RoleApiController extends ApiController
|
||||
{
|
||||
protected PermissionsRepo $permissionsRepo;
|
||||
|
||||
protected array $fieldsToExpose = [
|
||||
'display_name', 'description', 'mfa_enforced', 'external_auth_id', 'created_at', 'updated_at',
|
||||
];
|
||||
|
||||
protected $rules = [
|
||||
'create' => [
|
||||
'display_name' => ['required', 'string', 'min:3', 'max:180'],
|
||||
'description' => ['string', 'max:180'],
|
||||
'mfa_enforced' => ['boolean'],
|
||||
'external_auth_id' => ['string'],
|
||||
'permissions' => ['array'],
|
||||
'permissions.*' => ['string'],
|
||||
],
|
||||
'update' => [
|
||||
'display_name' => ['string', 'min:3', 'max:180'],
|
||||
'description' => ['string', 'max:180'],
|
||||
'mfa_enforced' => ['boolean'],
|
||||
'external_auth_id' => ['string'],
|
||||
'permissions' => ['array'],
|
||||
'permissions.*' => ['string'],
|
||||
]
|
||||
];
|
||||
|
||||
public function __construct(PermissionsRepo $permissionsRepo)
|
||||
{
|
||||
$this->permissionsRepo = $permissionsRepo;
|
||||
|
||||
// Checks for all endpoints in this controller
|
||||
$this->middleware(function ($request, $next) {
|
||||
$this->checkPermission('user-roles-manage');
|
||||
|
||||
return $next($request);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a listing of roles in the system.
|
||||
* Requires permission to manage roles.
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$roles = Role::query()->select(['*'])
|
||||
->withCount(['users', 'permissions']);
|
||||
|
||||
return $this->apiListingResponse($roles, [
|
||||
...$this->fieldsToExpose,
|
||||
'permissions_count',
|
||||
'users_count',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new role in the system.
|
||||
* Permissions should be provided as an array of permission name strings.
|
||||
* Requires permission to manage roles.
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
$data = $this->validate($request, $this->rules()['create']);
|
||||
|
||||
$role = null;
|
||||
DB::transaction(function () use ($data, &$role) {
|
||||
$role = $this->permissionsRepo->saveNewRole($data);
|
||||
});
|
||||
|
||||
$this->singleFormatter($role);
|
||||
|
||||
return response()->json($role);
|
||||
}
|
||||
|
||||
/**
|
||||
* View the details of a single role.
|
||||
* Provides the permissions and a high-level list of the users assigned.
|
||||
* Requires permission to manage roles.
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$role = $this->permissionsRepo->getRoleById($id);
|
||||
$this->singleFormatter($role);
|
||||
|
||||
return response()->json($role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing role in the system.
|
||||
* Permissions should be provided as an array of permission name strings.
|
||||
* An empty "permissions" array would clear granted permissions.
|
||||
* In many cases, where permissions are changed, you'll want to fetch the existing
|
||||
* permissions and then modify before providing in your update request.
|
||||
* Requires permission to manage roles.
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$data = $this->validate($request, $this->rules()['update']);
|
||||
$role = $this->permissionsRepo->updateRole($id, $data);
|
||||
|
||||
$this->singleFormatter($role);
|
||||
|
||||
return response()->json($role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a role from the system.
|
||||
* Requires permission to manage roles.
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
$this->permissionsRepo->deleteRole(intval($id));
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the given role model for single-result display.
|
||||
*/
|
||||
protected function singleFormatter(Role $role)
|
||||
{
|
||||
$role->load('users:id,name,slug');
|
||||
$role->unsetRelation('permissions');
|
||||
$role->setAttribute('permissions', $role->permissions()->orderBy('name', 'asc')->pluck('name'));
|
||||
$role->makeVisible(['users', 'permissions']);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Http\Controllers\Api;
|
||||
|
||||
use BookStack\Api\ApiEntityListFormatter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Search\SearchOptions;
|
||||
use BookStack\Search\SearchResultsFormatter;
|
||||
@@ -10,8 +11,8 @@ use Illuminate\Http\Request;
|
||||
|
||||
class SearchApiController extends ApiController
|
||||
{
|
||||
protected $searchRunner;
|
||||
protected $resultsFormatter;
|
||||
protected SearchRunner $searchRunner;
|
||||
protected SearchResultsFormatter $resultsFormatter;
|
||||
|
||||
protected $rules = [
|
||||
'all' => [
|
||||
@@ -50,24 +51,17 @@ class SearchApiController extends ApiController
|
||||
$results = $this->searchRunner->searchEntities($options, 'all', $page, $count);
|
||||
$this->resultsFormatter->format($results['results']->all(), $options);
|
||||
|
||||
/** @var Entity $result */
|
||||
foreach ($results['results'] as $result) {
|
||||
$result->setVisible([
|
||||
'id', 'name', 'slug', 'book_id',
|
||||
'chapter_id', 'draft', 'template',
|
||||
'created_at', 'updated_at',
|
||||
'tags', 'type', 'preview_html', 'url',
|
||||
]);
|
||||
$result->setAttribute('type', $result->getType());
|
||||
$result->setAttribute('url', $result->getUrl());
|
||||
$result->setAttribute('preview_html', [
|
||||
'name' => (string) $result->getAttribute('preview_name'),
|
||||
'content' => (string) $result->getAttribute('preview_content'),
|
||||
]);
|
||||
}
|
||||
$data = (new ApiEntityListFormatter($results['results']->all()))
|
||||
->withType()->withTags()
|
||||
->withField('preview_html', function (Entity $entity) {
|
||||
return [
|
||||
'name' => (string) $entity->getAttribute('preview_name'),
|
||||
'content' => (string) $entity->getAttribute('preview_content'),
|
||||
];
|
||||
})->format();
|
||||
|
||||
return response()->json([
|
||||
'data' => $results['results'],
|
||||
'data' => $data,
|
||||
'total' => $results['total'],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ use Illuminate\Validation\Rules\Unique;
|
||||
|
||||
class UserApiController extends ApiController
|
||||
{
|
||||
protected $userRepo;
|
||||
protected UserRepo $userRepo;
|
||||
|
||||
protected $fieldsToExpose = [
|
||||
protected array $fieldsToExpose = [
|
||||
'email', 'created_at', 'updated_at', 'last_activity_at', 'external_auth_id',
|
||||
];
|
||||
|
||||
|
||||
@@ -15,16 +15,10 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AttachmentController extends Controller
|
||||
{
|
||||
protected AttachmentService $attachmentService;
|
||||
protected PageRepo $pageRepo;
|
||||
|
||||
/**
|
||||
* AttachmentController constructor.
|
||||
*/
|
||||
public function __construct(AttachmentService $attachmentService, PageRepo $pageRepo)
|
||||
{
|
||||
$this->attachmentService = $attachmentService;
|
||||
$this->pageRepo = $pageRepo;
|
||||
public function __construct(
|
||||
protected AttachmentService $attachmentService,
|
||||
protected PageRepo $pageRepo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,7 +106,7 @@ class AttachmentController extends Controller
|
||||
try {
|
||||
$this->validate($request, [
|
||||
'attachment_edit_name' => ['required', 'string', 'min:1', 'max:255'],
|
||||
'attachment_edit_url' => ['string', 'min:1', 'max:255', 'safe_url'],
|
||||
'attachment_edit_url' => ['string', 'min:1', 'max:2000', 'safe_url'],
|
||||
]);
|
||||
} catch (ValidationException $exception) {
|
||||
return response()->view('attachments.manager-edit-form', array_merge($request->only(['attachment_edit_name', 'attachment_edit_url']), [
|
||||
@@ -148,7 +142,7 @@ class AttachmentController extends Controller
|
||||
$this->validate($request, [
|
||||
'attachment_link_uploaded_to' => ['required', 'integer', 'exists:pages,id'],
|
||||
'attachment_link_name' => ['required', 'string', 'min:1', 'max:255'],
|
||||
'attachment_link_url' => ['required', 'string', 'min:1', 'max:255', 'safe_url'],
|
||||
'attachment_link_url' => ['required', 'string', 'min:1', 'max:2000', 'safe_url'],
|
||||
]);
|
||||
} catch (ValidationException $exception) {
|
||||
return response()->view('attachments.manager-link-form', array_merge($request->only(['attachment_link_name', 'attachment_link_url']), [
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\Activity;
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -13,10 +15,15 @@ class AuditLogController extends Controller
|
||||
$this->checkPermission('settings-manage');
|
||||
$this->checkPermission('users-manage');
|
||||
|
||||
$listDetails = [
|
||||
'order' => $request->get('order', 'desc'),
|
||||
$sort = $request->get('sort', 'activity_date');
|
||||
$order = $request->get('order', 'desc');
|
||||
$listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([
|
||||
'created_at' => trans('settings.audit_table_date'),
|
||||
'type' => trans('settings.audit_table_event'),
|
||||
]);
|
||||
|
||||
$filters = [
|
||||
'event' => $request->get('event', ''),
|
||||
'sort' => $request->get('sort', 'created_at'),
|
||||
'date_from' => $request->get('date_from', ''),
|
||||
'date_to' => $request->get('date_to', ''),
|
||||
'user' => $request->get('user', ''),
|
||||
@@ -25,39 +32,38 @@ class AuditLogController extends Controller
|
||||
|
||||
$query = Activity::query()
|
||||
->with([
|
||||
'entity' => function ($query) {
|
||||
$query->withTrashed();
|
||||
},
|
||||
'entity' => fn ($query) => $query->withTrashed(),
|
||||
'user',
|
||||
])
|
||||
->orderBy($listDetails['sort'], $listDetails['order']);
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder());
|
||||
|
||||
if ($listDetails['event']) {
|
||||
$query->where('type', '=', $listDetails['event']);
|
||||
if ($filters['event']) {
|
||||
$query->where('type', '=', $filters['event']);
|
||||
}
|
||||
if ($listDetails['user']) {
|
||||
$query->where('user_id', '=', $listDetails['user']);
|
||||
if ($filters['user']) {
|
||||
$query->where('user_id', '=', $filters['user']);
|
||||
}
|
||||
|
||||
if ($listDetails['date_from']) {
|
||||
$query->where('created_at', '>=', $listDetails['date_from']);
|
||||
if ($filters['date_from']) {
|
||||
$query->where('created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
if ($listDetails['date_to']) {
|
||||
$query->where('created_at', '<=', $listDetails['date_to']);
|
||||
if ($filters['date_to']) {
|
||||
$query->where('created_at', '<=', $filters['date_to']);
|
||||
}
|
||||
if ($listDetails['ip']) {
|
||||
$query->where('ip', 'like', $listDetails['ip'] . '%');
|
||||
if ($filters['ip']) {
|
||||
$query->where('ip', 'like', $filters['ip'] . '%');
|
||||
}
|
||||
|
||||
$activities = $query->paginate(100);
|
||||
$activities->appends($listDetails);
|
||||
$activities->appends($request->all());
|
||||
|
||||
$types = DB::table('activities')->select('type')->distinct()->pluck('type');
|
||||
$types = ActivityType::all();
|
||||
$this->setPageTitle(trans('settings.audit'));
|
||||
|
||||
return view('settings.audit', [
|
||||
'activities' => $activities,
|
||||
'listDetails' => $listDetails,
|
||||
'filters' => $filters,
|
||||
'listOptions' => $listOptions,
|
||||
'activityTypes' => $types,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -14,21 +14,11 @@ use Illuminate\Http\Request;
|
||||
|
||||
class ConfirmEmailController extends Controller
|
||||
{
|
||||
protected $emailConfirmationService;
|
||||
protected $loginService;
|
||||
protected $userRepo;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct(
|
||||
EmailConfirmationService $emailConfirmationService,
|
||||
LoginService $loginService,
|
||||
UserRepo $userRepo
|
||||
protected EmailConfirmationService $emailConfirmationService,
|
||||
protected LoginService $loginService,
|
||||
protected UserRepo $userRepo
|
||||
) {
|
||||
$this->emailConfirmationService = $emailConfirmationService;
|
||||
$this->loginService = $loginService;
|
||||
$this->userRepo = $userRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,14 +41,28 @@ class ConfirmEmailController extends Controller
|
||||
return view('auth.user-unconfirmed', ['user' => $user]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for a user to provide their positive confirmation of their email.
|
||||
*/
|
||||
public function showAcceptForm(string $token)
|
||||
{
|
||||
return view('auth.register-confirm-accept', ['token' => $token]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirms an email via a token and logs the user into the system.
|
||||
*
|
||||
* @throws ConfirmationEmailException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function confirm(string $token)
|
||||
public function confirm(Request $request)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'token' => ['required', 'string']
|
||||
]);
|
||||
|
||||
$token = $validated['token'];
|
||||
|
||||
try {
|
||||
$userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
|
||||
} catch (UserTokenNotFoundException $exception) {
|
||||
|
||||
@@ -4,24 +4,11 @@ namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
|
||||
class ForgotPasswordController extends Controller
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Reset Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This controller is responsible for handling password reset emails and
|
||||
| includes a trait which assists in sending these notifications from
|
||||
| your application to your users. Feel free to explore this trait.
|
||||
|
|
||||
*/
|
||||
use SendsPasswordResetEmails;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
@@ -33,6 +20,14 @@ class ForgotPasswordController extends Controller
|
||||
$this->middleware('guard:standard');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the form to request a password reset link.
|
||||
*/
|
||||
public function showLinkRequestForm()
|
||||
{
|
||||
return view('auth.passwords.email');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a reset link to the given user.
|
||||
*
|
||||
@@ -49,7 +44,7 @@ class ForgotPasswordController extends Controller
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
$response = $this->broker()->sendResetLink(
|
||||
$response = Password::broker()->sendResetLink(
|
||||
$request->only('email')
|
||||
);
|
||||
|
||||
|
||||
@@ -8,31 +8,14 @@ use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\AuthenticatesUsers;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Login Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This controller handles authenticating users for the application and
|
||||
| redirecting them to your home screen. The controller uses a trait
|
||||
| to conveniently provide its functionality to your applications.
|
||||
|
|
||||
*/
|
||||
use AuthenticatesUsers {
|
||||
logout as traitLogout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirection paths.
|
||||
*/
|
||||
protected $redirectTo = '/';
|
||||
protected $redirectPath = '/';
|
||||
use ThrottlesLogins;
|
||||
|
||||
protected SocialAuthService $socialAuthService;
|
||||
protected LoginService $loginService;
|
||||
@@ -48,21 +31,6 @@ class LoginController extends Controller
|
||||
|
||||
$this->socialAuthService = $socialAuthService;
|
||||
$this->loginService = $loginService;
|
||||
|
||||
$this->redirectPath = url('/');
|
||||
}
|
||||
|
||||
public function username()
|
||||
{
|
||||
return config('auth.method') === 'standard' ? 'email' : 'username';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the needed authorization credentials from the request.
|
||||
*/
|
||||
protected function credentials(Request $request)
|
||||
{
|
||||
return $request->only('username', 'email', 'password');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,29 +66,15 @@ class LoginController extends Controller
|
||||
|
||||
/**
|
||||
* Handle a login request to the application.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse
|
||||
*/
|
||||
public function login(Request $request)
|
||||
{
|
||||
$this->validateLogin($request);
|
||||
$username = $request->get($this->username());
|
||||
|
||||
// If the class is using the ThrottlesLogins trait, we can automatically throttle
|
||||
// the login attempts for this application. We'll key this by the username and
|
||||
// the IP address of the client making these requests into this application.
|
||||
if (
|
||||
method_exists($this, 'hasTooManyLoginAttempts') &&
|
||||
$this->hasTooManyLoginAttempts($request)
|
||||
) {
|
||||
$this->fireLockoutEvent($request);
|
||||
|
||||
// Check login throttling attempts to see if they've gone over the limit
|
||||
if ($this->hasTooManyLoginAttempts($request)) {
|
||||
Activity::logFailedLogin($username);
|
||||
|
||||
return $this->sendLockoutResponse($request);
|
||||
}
|
||||
|
||||
@@ -134,24 +88,62 @@ class LoginController extends Controller
|
||||
return $this->sendLoginAttemptExceptionResponse($exception, $request);
|
||||
}
|
||||
|
||||
// If the login attempt was unsuccessful we will increment the number of attempts
|
||||
// to login and redirect the user back to the login form. Of course, when this
|
||||
// user surpasses their maximum number of attempts they will get locked out.
|
||||
// On unsuccessful login attempt, Increment login attempts for throttling and log failed login.
|
||||
$this->incrementLoginAttempts($request);
|
||||
|
||||
Activity::logFailedLogin($username);
|
||||
|
||||
return $this->sendFailedLoginResponse($request);
|
||||
// Throw validation failure for failed login
|
||||
throw ValidationException::withMessages([
|
||||
$this->username() => [trans('auth.failed')],
|
||||
])->redirectTo('/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user and perform subsequent redirect.
|
||||
*/
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::guard()->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
|
||||
|
||||
return redirect($redirectUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expected username input based upon the current auth method.
|
||||
*/
|
||||
protected function username(): string
|
||||
{
|
||||
return config('auth.method') === 'standard' ? 'email' : 'username';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the needed authorization credentials from the request.
|
||||
*/
|
||||
protected function credentials(Request $request): array
|
||||
{
|
||||
return $request->only('username', 'email', 'password');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the response after the user was authenticated.
|
||||
* @return RedirectResponse
|
||||
*/
|
||||
protected function sendLoginResponse(Request $request)
|
||||
{
|
||||
$request->session()->regenerate();
|
||||
$this->clearLoginAttempts($request);
|
||||
|
||||
return redirect()->intended('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to log the user into the application.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function attemptLogin(Request $request)
|
||||
protected function attemptLogin(Request $request): bool
|
||||
{
|
||||
return $this->loginService->attempt(
|
||||
$this->credentials($request),
|
||||
@@ -160,29 +152,12 @@ class LoginController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user has been authenticated.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param mixed $user
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
protected function authenticated(Request $request, $user)
|
||||
{
|
||||
return redirect()->intended($this->redirectPath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the user login request.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*
|
||||
* @return void
|
||||
* @throws ValidationException
|
||||
*/
|
||||
protected function validateLogin(Request $request)
|
||||
protected function validateLogin(Request $request): void
|
||||
{
|
||||
$rules = ['password' => ['required', 'string']];
|
||||
$authMethod = config('auth.method');
|
||||
@@ -216,22 +191,6 @@ class LoginController extends Controller
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the failed login response instance.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*
|
||||
* @throws \Illuminate\Validation\ValidationException
|
||||
*
|
||||
* @return \Symfony\Component\HttpFoundation\Response
|
||||
*/
|
||||
protected function sendFailedLoginResponse(Request $request)
|
||||
{
|
||||
throw ValidationException::withMessages([
|
||||
$this->username() => [trans('auth.failed')],
|
||||
])->redirectTo('/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the intended URL location from their previous URL.
|
||||
* Ignores if not from the current app instance or if from certain
|
||||
@@ -271,20 +230,4 @@ class LoginController extends Controller
|
||||
|
||||
return $autoRedirect && count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user and perform subsequent redirect.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function logout(Request $request)
|
||||
{
|
||||
$this->traitLogout($request);
|
||||
|
||||
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
|
||||
|
||||
return redirect($redirectUri);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,42 +5,20 @@ namespace BookStack\Http\Controllers\Auth;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\RegistrationService;
|
||||
use BookStack\Auth\Access\SocialAuthService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\RegistersUsers;
|
||||
use Illuminate\Contracts\Validation\Validator as ValidatorContract;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Register Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This controller handles the registration of new users as well as their
|
||||
| validation and creation. By default this controller uses a trait to
|
||||
| provide this functionality without requiring any additional code.
|
||||
|
|
||||
*/
|
||||
use RegistersUsers;
|
||||
|
||||
protected SocialAuthService $socialAuthService;
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
|
||||
/**
|
||||
* Where to redirect users after login / registration.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $redirectTo = '/';
|
||||
protected $redirectPath = '/';
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
@@ -55,23 +33,6 @@ class RegisterController extends Controller
|
||||
$this->socialAuthService = $socialAuthService;
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
|
||||
$this->redirectTo = url('/');
|
||||
$this->redirectPath = url('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a validator for an incoming registration request.
|
||||
*
|
||||
* @return \Illuminate\Contracts\Validation\Validator
|
||||
*/
|
||||
protected function validator(array $data)
|
||||
{
|
||||
return Validator::make($data, [
|
||||
'name' => ['required', 'min:2', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users'],
|
||||
'password' => ['required', Password::default()],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,22 +75,18 @@ class RegisterController extends Controller
|
||||
|
||||
$this->showSuccessNotification(trans('auth.register_success'));
|
||||
|
||||
return redirect($this->redirectPath());
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user instance after a valid registration.
|
||||
*
|
||||
* @param array $data
|
||||
*
|
||||
* @return User
|
||||
* Get a validator for an incoming registration request.
|
||||
*/
|
||||
protected function create(array $data)
|
||||
protected function validator(array $data): ValidatorContract
|
||||
{
|
||||
return User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => Hash::make($data['password']),
|
||||
return Validator::make($data, [
|
||||
'name' => ['required', 'min:2', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users'],
|
||||
'password' => ['required', Password::default()],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,65 +3,87 @@
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\ResetsPasswords;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
|
||||
class ResetPasswordController extends Controller
|
||||
{
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Reset Controller
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This controller is responsible for handling password reset requests
|
||||
| and uses a simple trait to include this behavior. You're free to
|
||||
| explore this trait and override any methods you wish to tweak.
|
||||
|
|
||||
*/
|
||||
use ResetsPasswords;
|
||||
protected LoginService $loginService;
|
||||
|
||||
protected $redirectTo = '/';
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
public function __construct(LoginService $loginService)
|
||||
{
|
||||
$this->middleware('guest');
|
||||
$this->middleware('guard:standard');
|
||||
|
||||
$this->loginService = $loginService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the password reset view for the given token.
|
||||
* If no token is present, display the link request form.
|
||||
*/
|
||||
public function showResetForm(Request $request)
|
||||
{
|
||||
$token = $request->route()->parameter('token');
|
||||
|
||||
return view('auth.passwords.reset')->with(
|
||||
['token' => $token, 'email' => $request->email]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the given user's password.
|
||||
*/
|
||||
public function reset(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'token' => 'required',
|
||||
'email' => 'required|email',
|
||||
'password' => ['required', 'confirmed', PasswordRule::defaults()],
|
||||
]);
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$credentials = $request->only('email', 'password', 'password_confirmation', 'token');
|
||||
$response = Password::broker()->reset($credentials, function (User $user, string $password) {
|
||||
$user->password = Hash::make($password);
|
||||
$user->setRememberToken(Str::random(60));
|
||||
$user->save();
|
||||
|
||||
$this->loginService->login($user, auth()->getDefaultDriver());
|
||||
});
|
||||
|
||||
// If the password was successfully reset, we will redirect the user back to
|
||||
// the application's home authenticated view. If there is an error we can
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $response === Password::PASSWORD_RESET
|
||||
? $this->sendResetResponse()
|
||||
: $this->sendResetFailedResponse($request, $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the response for a successful password reset.
|
||||
*
|
||||
* @param Request $request
|
||||
* @param string $response
|
||||
*
|
||||
* @return \Illuminate\Http\Response
|
||||
*/
|
||||
protected function sendResetResponse(Request $request, $response)
|
||||
protected function sendResetResponse(): RedirectResponse
|
||||
{
|
||||
$message = trans('auth.reset_password_success');
|
||||
$this->showSuccessNotification($message);
|
||||
$this->showSuccessNotification(trans('auth.reset_password_success'));
|
||||
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET_UPDATE, user());
|
||||
|
||||
return redirect($this->redirectPath())
|
||||
->with('status', trans($response));
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the response for a failed password reset.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @param string $response
|
||||
*
|
||||
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\JsonResponse
|
||||
*/
|
||||
protected function sendResetFailedResponse(Request $request, $response)
|
||||
protected function sendResetFailedResponse(Request $request, string $response): RedirectResponse
|
||||
{
|
||||
// We show invalid users as invalid tokens as to not leak what
|
||||
// users may exist in the system.
|
||||
|
||||
@@ -9,7 +9,7 @@ use Illuminate\Support\Str;
|
||||
|
||||
class Saml2Controller extends Controller
|
||||
{
|
||||
protected $samlService;
|
||||
protected Saml2Service $samlService;
|
||||
|
||||
/**
|
||||
* Saml2Controller constructor.
|
||||
|
||||
@@ -16,9 +16,9 @@ use Laravel\Socialite\Contracts\User as SocialUser;
|
||||
|
||||
class SocialController extends Controller
|
||||
{
|
||||
protected $socialAuthService;
|
||||
protected $registrationService;
|
||||
protected $loginService;
|
||||
protected SocialAuthService $socialAuthService;
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
|
||||
/**
|
||||
* SocialController constructor.
|
||||
@@ -28,7 +28,7 @@ class SocialController extends Controller
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService
|
||||
) {
|
||||
$this->middleware('guest')->only(['getRegister', 'postRegister']);
|
||||
$this->middleware('guest')->only(['register']);
|
||||
$this->socialAuthService = $socialAuthService;
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
|
||||
92
app/Http/Controllers/Auth/ThrottlesLogins.php
Normal file
92
app/Http/Controllers/Auth/ThrottlesLogins.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers\Auth;
|
||||
|
||||
use Illuminate\Cache\RateLimiter;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
trait ThrottlesLogins
|
||||
{
|
||||
/**
|
||||
* Determine if the user has too many failed login attempts.
|
||||
*/
|
||||
protected function hasTooManyLoginAttempts(Request $request): bool
|
||||
{
|
||||
return $this->limiter()->tooManyAttempts(
|
||||
$this->throttleKey($request),
|
||||
$this->maxAttempts()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the login attempts for the user.
|
||||
*/
|
||||
protected function incrementLoginAttempts(Request $request): void
|
||||
{
|
||||
$this->limiter()->hit(
|
||||
$this->throttleKey($request),
|
||||
$this->decayMinutes() * 60
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect the user after determining they are locked out.
|
||||
* @throws ValidationException
|
||||
*/
|
||||
protected function sendLockoutResponse(Request $request): \Symfony\Component\HttpFoundation\Response
|
||||
{
|
||||
$seconds = $this->limiter()->availableIn(
|
||||
$this->throttleKey($request)
|
||||
);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
$this->username() => [trans('auth.throttle', [
|
||||
'seconds' => $seconds,
|
||||
'minutes' => ceil($seconds / 60),
|
||||
])],
|
||||
])->status(Response::HTTP_TOO_MANY_REQUESTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the login locks for the given user credentials.
|
||||
*/
|
||||
protected function clearLoginAttempts(Request $request): void
|
||||
{
|
||||
$this->limiter()->clear($this->throttleKey($request));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the throttle key for the given request.
|
||||
*/
|
||||
protected function throttleKey(Request $request): string
|
||||
{
|
||||
return Str::transliterate(Str::lower($request->input($this->username())) . '|' . $request->ip());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiter instance.
|
||||
*/
|
||||
protected function limiter(): RateLimiter
|
||||
{
|
||||
return app(RateLimiter::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum number of attempts to allow.
|
||||
*/
|
||||
public function maxAttempts(): int
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of minutes to throttle for.
|
||||
*/
|
||||
public function decayMinutes(): int
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,13 @@ use Exception;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Redirector;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class UserInviteController extends Controller
|
||||
{
|
||||
protected $inviteService;
|
||||
protected $userRepo;
|
||||
protected UserInviteService $inviteService;
|
||||
protected UserRepo $userRepo;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
@@ -66,7 +67,7 @@ class UserInviteController extends Controller
|
||||
}
|
||||
|
||||
$user = $this->userRepo->getById($userId);
|
||||
$user->password = bcrypt($request->get('password'));
|
||||
$user->password = Hash::make($request->get('password'));
|
||||
$user->email_confirmed = true;
|
||||
$user->save();
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
use BookStack\Entities\Tools\HierarchyTransformer;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use BookStack\Entities\Tools\ShelfContext;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
@@ -36,13 +36,16 @@ class BookController extends Controller
|
||||
/**
|
||||
* Display a listing of the book.
|
||||
*/
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$view = setting()->getForCurrentUser('books_view_type');
|
||||
$sort = setting()->getForCurrentUser('books_sort', 'name');
|
||||
$order = setting()->getForCurrentUser('books_sort_order', 'asc');
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'books')->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
'created_at' => trans('common.sort_created_at'),
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
]);
|
||||
|
||||
$books = $this->bookRepo->getAllPaginated(18, $sort, $order);
|
||||
$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);
|
||||
@@ -57,8 +60,7 @@ class BookController extends Controller
|
||||
'popular' => $popular,
|
||||
'new' => $new,
|
||||
'view' => $view,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -209,36 +211,6 @@ class BookController extends Controller
|
||||
return redirect('/books');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the permissions view.
|
||||
*/
|
||||
public function showPermissions(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $book);
|
||||
|
||||
return view('books.permissions', [
|
||||
'book' => $book,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the restrictions for this book.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $book);
|
||||
|
||||
$permissionsUpdater->updateFromPermissionsForm($book, $request);
|
||||
|
||||
$this->showSuccessNotification(trans('entities.books_permissions_updated'));
|
||||
|
||||
return redirect($book->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to copy a book.
|
||||
*
|
||||
|
||||
@@ -6,11 +6,11 @@ use BookStack\Actions\ActivityQueries;
|
||||
use BookStack\Actions\View;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use BookStack\Entities\Tools\ShelfContext;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -31,18 +31,16 @@ class BookshelfController extends Controller
|
||||
/**
|
||||
* Display a listing of the book.
|
||||
*/
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$view = setting()->getForCurrentUser('bookshelves_view_type');
|
||||
$sort = setting()->getForCurrentUser('bookshelves_sort', 'name');
|
||||
$order = setting()->getForCurrentUser('bookshelves_sort_order', 'asc');
|
||||
$sortOptions = [
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'bookshelves')->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
'created_at' => trans('common.sort_created_at'),
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
];
|
||||
]);
|
||||
|
||||
$shelves = $this->shelfRepo->getAllPaginated(18, $sort, $order);
|
||||
$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);
|
||||
@@ -56,9 +54,7 @@ class BookshelfController extends Controller
|
||||
'popular' => $popular,
|
||||
'new' => $new,
|
||||
'view' => $view,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
'sortOptions' => $sortOptions,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -68,7 +64,7 @@ class BookshelfController extends Controller
|
||||
public function create()
|
||||
{
|
||||
$this->checkPermission('bookshelf-create-all');
|
||||
$books = Book::visible()->orderBy('name')->get(['name', 'id', 'slug']);
|
||||
$books = Book::visible()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
|
||||
$this->setPageTitle(trans('entities.shelves_create'));
|
||||
|
||||
return view('shelves.create', ['books' => $books]);
|
||||
@@ -101,16 +97,21 @@ class BookshelfController extends Controller
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function show(ActivityQueries $activities, string $slug)
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$this->checkOwnablePermission('bookshelf-view', $shelf);
|
||||
|
||||
$sort = setting()->getForCurrentUser('shelf_books_sort', 'default');
|
||||
$order = setting()->getForCurrentUser('shelf_books_sort_order', 'asc');
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
|
||||
'default' => trans('common.sort_default'),
|
||||
'name' => trans('common.sort_name'),
|
||||
'created_at' => trans('common.sort_created_at'),
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
]);
|
||||
|
||||
$sort = $listOptions->getSort();
|
||||
$sortedVisibleShelfBooks = $shelf->visibleBooks()->get()
|
||||
->sortBy($sort === 'default' ? 'pivot.order' : $sort, SORT_REGULAR, $order === 'desc')
|
||||
->sortBy($sort === 'default' ? 'pivot.order' : $sort, SORT_REGULAR, $listOptions->getOrder() === 'desc')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
@@ -125,8 +126,7 @@ class BookshelfController extends Controller
|
||||
'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks,
|
||||
'view' => $view,
|
||||
'activity' => $activities->entityActivity($shelf, 20, 1),
|
||||
'order' => $order,
|
||||
'sort' => $sort,
|
||||
'listOptions' => $listOptions,
|
||||
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($shelf),
|
||||
]);
|
||||
}
|
||||
@@ -140,7 +140,7 @@ class BookshelfController extends Controller
|
||||
$this->checkOwnablePermission('bookshelf-update', $shelf);
|
||||
|
||||
$shelfBookIds = $shelf->books()->get(['id'])->pluck('id');
|
||||
$books = Book::visible()->whereNotIn('id', $shelfBookIds)->orderBy('name')->get(['name', 'id', 'slug']);
|
||||
$books = Book::visible()->whereNotIn('id', $shelfBookIds)->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
|
||||
|
||||
$this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()]));
|
||||
|
||||
@@ -207,46 +207,4 @@ class BookshelfController extends Controller
|
||||
|
||||
return redirect('/shelves');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the permissions view.
|
||||
*/
|
||||
public function showPermissions(string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
||||
|
||||
return view('shelves.permissions', [
|
||||
'shelf' => $shelf,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the permissions for this bookshelf.
|
||||
*/
|
||||
public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
||||
|
||||
$permissionsUpdater->updateFromPermissionsForm($shelf, $request);
|
||||
|
||||
$this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
|
||||
|
||||
return redirect($shelf->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the permissions of a bookshelf to the child books.
|
||||
*/
|
||||
public function copyPermissions(string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
||||
|
||||
$updateCount = $this->shelfRepo->copyDownPermissions($shelf);
|
||||
$this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
|
||||
|
||||
return redirect($shelf->getUrl());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
use BookStack\Entities\Tools\HierarchyTransformer;
|
||||
use BookStack\Entities\Tools\NextPreviousContentLocator;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
@@ -243,38 +242,6 @@ class ChapterController extends Controller
|
||||
return redirect($chapterCopy->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Restrictions view.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function showPermissions(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $chapter);
|
||||
|
||||
return view('chapters.permissions', [
|
||||
'chapter' => $chapter,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the restrictions for this chapter.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $chapter);
|
||||
|
||||
$permissionsUpdater->updateFromPermissionsForm($chapter, $request);
|
||||
|
||||
$this->showSuccessNotification(trans('entities.chapters_permissions_success'));
|
||||
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the chapter to a book.
|
||||
*/
|
||||
|
||||
@@ -87,7 +87,7 @@ class FavouriteController extends Controller
|
||||
|
||||
$modelInstance = $model->newQuery()
|
||||
->where('id', '=', $modelInfo['id'])
|
||||
->first(['id', 'name', 'restricted', 'owned_by']);
|
||||
->first(['id', 'name', 'owned_by']);
|
||||
|
||||
$inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
|
||||
if (is_null($modelInstance) || $inaccessibleEntity) {
|
||||
|
||||
@@ -10,13 +10,16 @@ use BookStack\Entities\Queries\TopFavourites;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Uploads\FaviconHandler;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the homepage.
|
||||
*/
|
||||
public function index(ActivityQueries $activities)
|
||||
public function index(Request $request, ActivityQueries $activities)
|
||||
{
|
||||
$activity = $activities->latest(10);
|
||||
$draftPages = [];
|
||||
@@ -61,33 +64,27 @@ class HomeController extends Controller
|
||||
if ($homepageOption === 'bookshelves' || $homepageOption === 'books') {
|
||||
$key = $homepageOption;
|
||||
$view = setting()->getForCurrentUser($key . '_view_type');
|
||||
$sort = setting()->getForCurrentUser($key . '_sort', 'name');
|
||||
$order = setting()->getForCurrentUser($key . '_sort_order', 'asc');
|
||||
|
||||
$sortOptions = [
|
||||
'name' => trans('common.sort_name'),
|
||||
$listOptions = SimpleListOptions::fromRequest($request, $key)->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
'created_at' => trans('common.sort_created_at'),
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
];
|
||||
]);
|
||||
|
||||
$commonData = array_merge($commonData, [
|
||||
'view' => $view,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
'sortOptions' => $sortOptions,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($homepageOption === 'bookshelves') {
|
||||
$shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['sort'], $commonData['order']);
|
||||
$shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
|
||||
$data = array_merge($commonData, ['shelves' => $shelves]);
|
||||
|
||||
return view('home.shelves', $data);
|
||||
}
|
||||
|
||||
if ($homepageOption === 'books') {
|
||||
$bookRepo = app(BookRepo::class);
|
||||
$books = $bookRepo->getAllPaginated(18, $commonData['sort'], $commonData['order']);
|
||||
$books = app(BookRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
|
||||
$data = array_merge($commonData, ['books' => $books]);
|
||||
|
||||
return view('home.books', $data);
|
||||
@@ -131,4 +128,15 @@ class HomeController extends Controller
|
||||
{
|
||||
return response()->view('errors.404', [], 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the application favicon.
|
||||
* Ensures a 'favicon.ico' file exists at the web root location (if writable) to be served
|
||||
* directly by the webserver in the future.
|
||||
*/
|
||||
public function favicon(FaviconHandler $favicons)
|
||||
{
|
||||
$exists = $favicons->restoreOriginalIfNotExists();
|
||||
return response()->file($exists ? $favicons->getPath() : $favicons->getOriginalPath());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,14 +66,19 @@ class DrawioImageController extends Controller
|
||||
*/
|
||||
public function getAsBase64($id)
|
||||
{
|
||||
$image = $this->imageRepo->getById($id);
|
||||
if (is_null($image) || $image->type !== 'drawio' || !userCan('page-view', $image->getPage())) {
|
||||
return $this->jsonError('Image data could not be found');
|
||||
try {
|
||||
$image = $this->imageRepo->getById($id);
|
||||
} catch (Exception $exception) {
|
||||
return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
|
||||
}
|
||||
|
||||
if ($image->type !== 'drawio' || !userCan('page-view', $image->getPage())) {
|
||||
return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
|
||||
}
|
||||
|
||||
$imageData = $this->imageRepo->getImageData($image);
|
||||
if (is_null($imageData)) {
|
||||
return $this->jsonError('Image data could not be found');
|
||||
return $this->jsonError(trans('errors.drawing_data_not_found'), 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -10,14 +10,9 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GalleryImageController extends Controller
|
||||
{
|
||||
protected $imageRepo;
|
||||
|
||||
/**
|
||||
* GalleryImageController constructor.
|
||||
*/
|
||||
public function __construct(ImageRepo $imageRepo)
|
||||
{
|
||||
$this->imageRepo = $imageRepo;
|
||||
public function __construct(
|
||||
protected ImageRepo $imageRepo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,9 +42,14 @@ class GalleryImageController extends Controller
|
||||
public function create(Request $request)
|
||||
{
|
||||
$this->checkPermission('image-create-all');
|
||||
$this->validate($request, [
|
||||
'file' => $this->getImageValidationRules(),
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->validate($request, [
|
||||
'file' => $this->getImageValidationRules(),
|
||||
]);
|
||||
} catch (ValidationException $exception) {
|
||||
return $this->jsonError(implode("\n", $exception->errors()['file']));
|
||||
}
|
||||
|
||||
try {
|
||||
$imageUpload = $request->file('file');
|
||||
|
||||
@@ -11,7 +11,6 @@ use BookStack\Entities\Tools\NextPreviousContentLocator;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditActivity;
|
||||
use BookStack\Entities\Tools\PageEditorData;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
@@ -452,37 +451,4 @@ class PageController extends Controller
|
||||
|
||||
return redirect($pageCopy->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Permissions view.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function showPermissions(string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $page);
|
||||
|
||||
return view('pages.permissions', [
|
||||
'page' => $page,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the permissions for this page.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function permissions(Request $request, PermissionsUpdater $permissionsUpdater, string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $page);
|
||||
|
||||
$permissionsUpdater->updateFromPermissionsForm($page, $request);
|
||||
|
||||
$this->showSuccessNotification(trans('entities.pages_permissions_success'));
|
||||
|
||||
return redirect($page->getUrl());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Entities\Models\PageRevision;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
use Ssddanbrown\HtmlDiff\Diff;
|
||||
|
||||
class PageRevisionController extends Controller
|
||||
@@ -23,22 +26,29 @@ class PageRevisionController extends Controller
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function index(string $bookSlug, string $pageSlug)
|
||||
public function index(Request $request, string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([
|
||||
'id' => trans('entities.pages_revisions_sort_number')
|
||||
]);
|
||||
|
||||
$revisions = $page->revisions()->select([
|
||||
'id', 'page_id', 'name', 'created_at', 'created_by', 'updated_at',
|
||||
'type', 'revision_number', 'summary',
|
||||
])
|
||||
'id', 'page_id', 'name', 'created_at', 'created_by', 'updated_at',
|
||||
'type', 'revision_number', 'summary',
|
||||
])
|
||||
->selectRaw("IF(markdown = '', false, true) as is_markdown")
|
||||
->with(['page.book', 'createdBy'])
|
||||
->get();
|
||||
->reorder('id', $listOptions->getOrder())
|
||||
->reorder('created_at', $listOptions->getOrder())
|
||||
->paginate(50);
|
||||
|
||||
$this->setPageTitle(trans('entities.pages_revisions_named', ['pageName' => $page->getShortName()]));
|
||||
|
||||
return view('pages.revisions', [
|
||||
'revisions' => $revisions,
|
||||
'page' => $page,
|
||||
'revisions' => $revisions,
|
||||
'page' => $page,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -50,6 +60,7 @@ class PageRevisionController extends Controller
|
||||
public function show(string $bookSlug, string $pageSlug, int $revisionId)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||
if ($revision === null) {
|
||||
throw new NotFoundException();
|
||||
@@ -78,6 +89,7 @@ class PageRevisionController extends Controller
|
||||
public function changes(string $bookSlug, string $pageSlug, int $revisionId)
|
||||
{
|
||||
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||
if ($revision === null) {
|
||||
throw new NotFoundException();
|
||||
|
||||
174
app/Http/Controllers/PermissionsController.php
Normal file
174
app/Http/Controllers/PermissionsController.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\Permissions\PermissionFormData;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PermissionsController extends Controller
|
||||
{
|
||||
protected PermissionsUpdater $permissionsUpdater;
|
||||
|
||||
public function __construct(PermissionsUpdater $permissionsUpdater)
|
||||
{
|
||||
$this->permissionsUpdater = $permissionsUpdater;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Permissions view for a page.
|
||||
*/
|
||||
public function showForPage(string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = Page::getBySlugs($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $page);
|
||||
|
||||
$this->setPageTitle(trans('entities.pages_permissions'));
|
||||
return view('pages.permissions', [
|
||||
'page' => $page,
|
||||
'data' => new PermissionFormData($page),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the permissions for a page.
|
||||
*/
|
||||
public function updateForPage(Request $request, string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$page = Page::getBySlugs($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $page);
|
||||
|
||||
$this->permissionsUpdater->updateFromPermissionsForm($page, $request);
|
||||
|
||||
$this->showSuccessNotification(trans('entities.pages_permissions_success'));
|
||||
|
||||
return redirect($page->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the Restrictions view for a chapter.
|
||||
*/
|
||||
public function showForChapter(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = Chapter::getBySlugs($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $chapter);
|
||||
|
||||
$this->setPageTitle(trans('entities.chapters_permissions'));
|
||||
return view('chapters.permissions', [
|
||||
'chapter' => $chapter,
|
||||
'data' => new PermissionFormData($chapter),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the restrictions for a chapter.
|
||||
*/
|
||||
public function updateForChapter(Request $request, string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
$chapter = Chapter::getBySlugs($bookSlug, $chapterSlug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $chapter);
|
||||
|
||||
$this->permissionsUpdater->updateFromPermissionsForm($chapter, $request);
|
||||
|
||||
$this->showSuccessNotification(trans('entities.chapters_permissions_success'));
|
||||
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the permissions view for a book.
|
||||
*/
|
||||
public function showForBook(string $slug)
|
||||
{
|
||||
$book = Book::getBySlug($slug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $book);
|
||||
|
||||
$this->setPageTitle(trans('entities.books_permissions'));
|
||||
return view('books.permissions', [
|
||||
'book' => $book,
|
||||
'data' => new PermissionFormData($book),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the restrictions for a book.
|
||||
*/
|
||||
public function updateForBook(Request $request, string $slug)
|
||||
{
|
||||
$book = Book::getBySlug($slug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $book);
|
||||
|
||||
$this->permissionsUpdater->updateFromPermissionsForm($book, $request);
|
||||
|
||||
$this->showSuccessNotification(trans('entities.books_permissions_updated'));
|
||||
|
||||
return redirect($book->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the permissions view for a shelf.
|
||||
*/
|
||||
public function showForShelf(string $slug)
|
||||
{
|
||||
$shelf = Bookshelf::getBySlug($slug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
||||
|
||||
$this->setPageTitle(trans('entities.shelves_permissions'));
|
||||
return view('shelves.permissions', [
|
||||
'shelf' => $shelf,
|
||||
'data' => new PermissionFormData($shelf),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the permissions for a shelf.
|
||||
*/
|
||||
public function updateForShelf(Request $request, string $slug)
|
||||
{
|
||||
$shelf = Bookshelf::getBySlug($slug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
||||
|
||||
$this->permissionsUpdater->updateFromPermissionsForm($shelf, $request);
|
||||
|
||||
$this->showSuccessNotification(trans('entities.shelves_permissions_updated'));
|
||||
|
||||
return redirect($shelf->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the permissions of a bookshelf to the child books.
|
||||
*/
|
||||
public function copyShelfPermissionsToBooks(string $slug)
|
||||
{
|
||||
$shelf = Bookshelf::getBySlug($slug);
|
||||
$this->checkOwnablePermission('restrictions-manage', $shelf);
|
||||
|
||||
$updateCount = $this->permissionsUpdater->updateBookPermissionsFromShelf($shelf);
|
||||
$this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
|
||||
|
||||
return redirect($shelf->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an empty entity permissions form row for the given role.
|
||||
*/
|
||||
public function formRowForRole(string $entityType, string $roleId)
|
||||
{
|
||||
$this->checkPermissionOr('restrictions-manage-all', fn() => userCan('restrictions-manage-own'));
|
||||
|
||||
$role = Role::query()->findOrFail($roleId);
|
||||
|
||||
return view('form.entity-permissions-row', [
|
||||
'role' => $role,
|
||||
'permission' => new EntityPermission(),
|
||||
'entityType' => $entityType,
|
||||
'inheriting' => false,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user