mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-05 08:39:55 +03:00
Compare commits
659 Commits
ldap_host_
...
v23.05.2
| 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 | ||
|
|
ebd6e4d3a2 | ||
|
|
80374aea5c | ||
|
|
2ac9efae7d | ||
|
|
a11d565ba4 | ||
|
|
1fdf854ea7 | ||
|
|
e9c9792cb9 | ||
|
|
5ae524c25a | ||
|
|
0d7287fc8b | ||
|
|
9fd7a6abed | ||
|
|
4757ed9453 | ||
|
|
97146a6359 | ||
|
|
d4f2fcdf79 | ||
|
|
e77c96f6b7 | ||
|
|
9b8a10dd3a | ||
|
|
49200ca5ce | ||
|
|
34aa4dbf10 | ||
|
|
5ee79d16c9 | ||
|
|
a1ea4006e0 | ||
|
|
9078188939 | ||
|
|
ed0aad1a7a | ||
|
|
5c59cfb020 | ||
|
|
3ca15ad68a | ||
|
|
60014989f5 | ||
|
|
57b10f195e | ||
|
|
b1e95eb39f | ||
|
|
b3da77b8f9 | ||
|
|
1a345b74bb | ||
|
|
8ffc3a4abf | ||
|
|
7233c1c7b2 | ||
|
|
1309a01131 | ||
|
|
0333185b6d | ||
|
|
83f89f64e8 | ||
|
|
11a1a6fb16 | ||
|
|
882c609296 | ||
|
|
176a0dcd59 | ||
|
|
94b0f70bfa | ||
|
|
08b2a77d41 | ||
|
|
3e8e9a23cf | ||
|
|
58b83b64c8 | ||
|
|
dfe4cde6ee | ||
|
|
d11144d9e2 | ||
|
|
f96b0ea5f3 | ||
|
|
815f8d79ed | ||
|
|
b62dab32e0 | ||
|
|
262f863981 | ||
|
|
a4c94390a1 | ||
|
|
53f3cca85d | ||
|
|
ed08bbcecc | ||
|
|
de97ebf9b7 | ||
|
|
f492a660a8 | ||
|
|
09436836a5 | ||
|
|
bb455d7788 | ||
|
|
009212ab80 | ||
|
|
ba9cb591c8 | ||
|
|
d00ac2f34e | ||
|
|
bd4dc6d463 | ||
|
|
d91180a909 | ||
|
|
bc2913a5cb | ||
|
|
4802394562 | ||
|
|
1755556468 | ||
|
|
01cdbdb7ae | ||
|
|
fc8bbf3eab | ||
|
|
3cdab19319 | ||
|
|
5661d20e87 | ||
|
|
91f80123e8 | ||
|
|
7a0636d0f8 | ||
|
|
0fe5bdfbac | ||
|
|
f88687e977 | ||
|
|
68d437d05b | ||
|
|
1e56aaea04 | ||
|
|
dab170a6fe | ||
|
|
a8de717d9b | ||
|
|
78fe95b6fc | ||
|
|
e0c24e41aa | ||
|
|
fa8553839b | ||
|
|
b8fcefc794 | ||
|
|
88bcb68fcb | ||
|
|
7c000553ae | ||
|
|
391fa35c80 | ||
|
|
c6773a8c9f | ||
|
|
9b226e7d39 | ||
|
|
9865446267 | ||
|
|
926abbe776 | ||
|
|
4fabef3a57 | ||
|
|
5ef4cd80c3 | ||
|
|
e01f23583f | ||
|
|
7792cb3915 | ||
|
|
be26253a18 | ||
|
|
1bdd1f8189 | ||
|
|
fa62c79b17 | ||
|
|
d7d8fa1e5b | ||
|
|
18562f1e10 | ||
|
|
86090a694f | ||
|
|
1ee8287c73 | ||
|
|
8eb98cd591 | ||
|
|
0f9ba21b05 | ||
|
|
834f8e7046 | ||
|
|
32e3399334 | ||
|
|
2d8698a218 | ||
|
|
454fb883a2 | ||
|
|
6f4a6ab8ea | ||
|
|
9c4b6f36f1 | ||
|
|
78886b1e67 | ||
|
|
d9debaf032 | ||
|
|
d4360d6347 | ||
|
|
175b1785c0 | ||
|
|
c8740c0171 | ||
|
|
91ee895a74 | ||
|
|
a045e46571 | ||
|
|
44eaa65c3b | ||
|
|
0a22af7b14 | ||
|
|
b54702ab08 | ||
|
|
c4fdcfc5d1 | ||
|
|
cb8117e8df | ||
|
|
5a218d5056 | ||
|
|
8dbc5cf9c6 | ||
|
|
71e81615a3 | ||
|
|
611d37da04 | ||
|
|
0e799a3857 | ||
|
|
b91d6e2bfa | ||
|
|
ea16ad7e94 | ||
|
|
ba6eb54552 | ||
|
|
f705e7683b | ||
|
|
dc996adb20 | ||
|
|
a64c638ccc | ||
|
|
359c067279 | ||
|
|
66a746e297 | ||
|
|
a4d43ee24b | ||
|
|
f7793a70a9 | ||
|
|
ceba3d31fb | ||
|
|
eecc08edde | ||
|
|
eb19aadc75 | ||
|
|
06c81e69b9 | ||
|
|
3dc3d4a639 | ||
|
|
94c59c1e3d | ||
|
|
4d2205853a | ||
|
|
751772b87a | ||
|
|
76e30869e1 | ||
|
|
3edc9fe9eb | ||
|
|
616c62703e | ||
|
|
ecd56917e7 | ||
|
|
e22c9cae91 | ||
|
|
29ddb6e1b9 | ||
|
|
2ff90e2ff0 | ||
|
|
04ecc128a2 | ||
|
|
87d1d3423b | ||
|
|
4818192a2a | ||
|
|
965dd97f54 | ||
|
|
195b74926c | ||
|
|
2120db12b2 | ||
|
|
ed563fef28 | ||
|
|
0d31a8e3f1 | ||
|
|
b8354b974b | ||
|
|
034c1e289d | ||
|
|
f31605a3de | ||
|
|
e7cc75c74d | ||
|
|
4b79d5e4e8 | ||
|
|
34854915b3 | ||
|
|
af6f34b529 | ||
|
|
fb82a2b896 | ||
|
|
5b464938b6 | ||
|
|
81f954890d | ||
|
|
0e2bbcec62 | ||
|
|
fdd339f525 | ||
|
|
8cf7d6a83d | ||
|
|
58a5008718 | ||
|
|
c44a8df55d | ||
|
|
ff1494c519 | ||
|
|
b8ce8fd852 | ||
|
|
75e7454a5f | ||
|
|
2558ea8931 | ||
|
|
ac0f47a4b2 | ||
|
|
4f16129869 | ||
|
|
64a8037fdd | ||
|
|
7502ba1bc8 | ||
|
|
33a04697ef | ||
|
|
b70a5c0cdb | ||
|
|
9443ae9f40 | ||
|
|
220c2a4102 | ||
|
|
e9914eb301 | ||
|
|
934512d09c | ||
|
|
9102c90986 | ||
|
|
c3e74219c4 | ||
|
|
13c9d7bc2d | ||
|
|
119b539586 | ||
|
|
29a5c180f0 | ||
|
|
7906602291 | ||
|
|
6dafe773ff | ||
|
|
25bc28a1be | ||
|
|
4c561c7fa0 | ||
|
|
95b3e78573 | ||
|
|
63a345bc93 | ||
|
|
e093a172cb | ||
|
|
4b01f8934b | ||
|
|
bc116b45b5 | ||
|
|
a059960b9e | ||
|
|
7770966fed | ||
|
|
d7adcf6c69 | ||
|
|
04a364dcc3 | ||
|
|
db83ac7eaa | ||
|
|
3ca9dddf61 | ||
|
|
bf74f53ca7 | ||
|
|
9d67efb4a4 | ||
|
|
3a39b9f440 | ||
|
|
27f7aab375 | ||
|
|
337da0c467 | ||
|
|
f56b3560c4 | ||
|
|
02dfe11ce6 | ||
|
|
83d06beb70 | ||
|
|
a8cfc059c8 | ||
|
|
1614b2bab0 | ||
|
|
4bdec0d214 | ||
|
|
6a7d7e7c2b | ||
|
|
30d4674657 | ||
|
|
9f961f95f8 | ||
|
|
bab99a26ec | ||
|
|
9a7fecd269 | ||
|
|
a8dc0d449b | ||
|
|
a0381f76bf | ||
|
|
6102f66daa | ||
|
|
c6134d162d | ||
|
|
2046f9b9de | ||
|
|
ac3ba594a4 | ||
|
|
22df25a480 | ||
|
|
8b30c7f02e | ||
|
|
757cdddc7c | ||
|
|
df95e99680 | ||
|
|
5a6d544db7 | ||
|
|
16117d329c | ||
|
|
e90da18ada | ||
|
|
a08d80e1cc | ||
|
|
6258175922 | ||
|
|
15736777a0 | ||
|
|
75915e8a94 | ||
|
|
9bde0ae4ea | ||
|
|
0c802d1f86 | ||
|
|
b7a96c6466 | ||
|
|
4b645a82c7 | ||
|
|
d599b77b6f | ||
|
|
26e93dc8c1 | ||
|
|
a4c9a8491b | ||
|
|
70ee636d87 | ||
|
|
b35f6dbb03 | ||
|
|
67d9e24d8f | ||
|
|
3903fda6ca | ||
|
|
441e46ebaa | ||
|
|
1f4260f359 | ||
|
|
dc0bf8ad4e | ||
|
|
102e326e6a | ||
|
|
2b25bf6f3b | ||
|
|
f93280696d | ||
|
|
1787391b07 | ||
|
|
a74a8ee483 | ||
|
|
7fa5405cb7 | ||
|
|
6725ddcc41 | ||
|
|
bce941db3f | ||
|
|
6d926048ec | ||
|
|
5335c973b4 | ||
|
|
15c3e5c96e | ||
|
|
a5d5904969 | ||
|
|
598758b991 | ||
|
|
9926e23bc8 | ||
|
|
5d3264bc63 | ||
|
|
d71f819f95 | ||
|
|
ee13509760 | ||
|
|
82d7bb1f32 | ||
|
|
cdfda508d8 | ||
|
|
da941e584f | ||
|
|
65874d7b96 | ||
|
|
ac9b8f405c | ||
|
|
8d1419a12e | ||
|
|
04f7a7d301 | ||
|
|
c10d2a1493 | ||
|
|
97bbf79ffd | ||
|
|
f7b01ae53d | ||
|
|
d704e1dbba | ||
|
|
ef2ff5e093 | ||
|
|
7caed3b0db | ||
|
|
45641d0754 | ||
|
|
4b1d08ba99 | ||
|
|
160fa99ba4 | ||
|
|
d2a5ab49ed | ||
|
|
c6404d8917 | ||
|
|
7113807f12 | ||
|
|
be711215e8 | ||
|
|
7e3b404240 | ||
|
|
e86901ca20 | ||
|
|
bdfa61c8b2 | ||
|
|
2cc36787f5 | ||
|
|
448ac61b48 | ||
|
|
753f6394f7 | ||
|
|
b1faf65934 | ||
|
|
09f478bd74 | ||
|
|
a0497feddd | ||
|
|
789693bde9 | ||
|
|
1fe933e4ea | ||
|
|
724b4b5a70 | ||
|
|
1778a56146 | ||
|
|
744865fcb2 | ||
|
|
7f8c8b448d | ||
|
|
a67c53826d | ||
|
|
14b131e850 | ||
|
|
9b55a52b85 | ||
|
|
db1d10e80f | ||
|
|
1be576966f | ||
|
|
b97e792c5f | ||
|
|
8dec674cc3 | ||
|
|
f784c03746 | ||
|
|
148e172fe8 | ||
|
|
56ae86646f | ||
|
|
1d2b6fdfa2 | ||
|
|
4fc75beed4 | ||
|
|
3b3bc0c4bf | ||
|
|
910faab88e | ||
|
|
f184d763ad | ||
|
|
a91d42634d | ||
|
|
f517ef3616 | ||
|
|
e99507ddcf | ||
|
|
d2cacf1945 | ||
|
|
448ac1405b | ||
|
|
6ad21ce885 |
@@ -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 }}
|
||||
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -1,16 +1,19 @@
|
||||
/vendor
|
||||
/node_modules
|
||||
/.vscode
|
||||
/composer
|
||||
Homestead.yaml
|
||||
.env
|
||||
.idea
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/public/dist
|
||||
/public/dist/*.map
|
||||
/public/plugins
|
||||
/public/css
|
||||
/public/js
|
||||
/public/css/*.map
|
||||
/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
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-2022, Dan Brown and the BookStack Project 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);
|
||||
@@ -83,6 +88,7 @@ class TagRepo
|
||||
{
|
||||
$query = Tag::query()
|
||||
->select('*', DB::raw('count(*) as count'))
|
||||
->where('value', '!=', '')
|
||||
->groupBy('value');
|
||||
|
||||
if ($searchTerm) {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -4,21 +4,29 @@ namespace BookStack\Api;
|
||||
|
||||
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' => '>',
|
||||
@@ -62,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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class Saml2Service
|
||||
$returnRoute,
|
||||
[],
|
||||
$user->email,
|
||||
null,
|
||||
session()->get('saml2_session_index'),
|
||||
true,
|
||||
Constants::NAMEID_EMAIL_ADDRESS
|
||||
);
|
||||
@@ -118,6 +118,7 @@ class Saml2Service
|
||||
|
||||
$attrs = $toolkit->getAttributes();
|
||||
$id = $toolkit->getNameId();
|
||||
session()->put('saml2_session_index', $toolkit->getSessionIndex());
|
||||
|
||||
return $this->processLoginCallback($id, $attrs);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace BookStack\Auth\Permissions;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
@@ -23,14 +22,14 @@ class EntityPermission extends Model
|
||||
|
||||
protected $fillable = ['role_id', 'view', 'create', 'update', 'delete'];
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* Get this restriction's attached entity.
|
||||
*/
|
||||
public function restrictable(): MorphTo
|
||||
{
|
||||
return $this->morphTo('restrictable');
|
||||
}
|
||||
protected $hidden = ['entity_id', 'entity_type', 'id'];
|
||||
protected $casts = [
|
||||
'view' => 'boolean',
|
||||
'create' => 'boolean',
|
||||
'read' => 'boolean',
|
||||
'update' => 'boolean',
|
||||
'delete' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the role assigned to this entity permission.
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -98,40 +93,6 @@ class JointPermissionBuilder
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@@ -140,6 +101,7 @@ class JointPermissionBuilder
|
||||
return Book::query()->withTrashed()
|
||||
->select(['id', 'owned_by'])->with([
|
||||
'chapters' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'owned_by', 'book_id']);
|
||||
},
|
||||
'pages' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'owned_by', 'book_id', 'chapter_id']);
|
||||
@@ -213,13 +175,7 @@ class JointPermissionBuilder
|
||||
$simpleEntities = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$attrs = $entity->getAttributes();
|
||||
$simple = new SimpleEntityData();
|
||||
$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;
|
||||
$simple = SimpleEntityData::fromEntity($entity);
|
||||
$simpleEntities[] = $simple;
|
||||
}
|
||||
|
||||
@@ -229,24 +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 = [];
|
||||
|
||||
// Fetch related entity permissions
|
||||
$permissions = $this->getEntityPermissionsForEntities($entities);
|
||||
|
||||
// Create a mapping of explicit entity permissions
|
||||
$permissionMap = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$key = $permission->entity_type . ':' . $permission->entity_id . ':' . $permission->role_id;
|
||||
$permissionMap[$key] = $permission->view;
|
||||
}
|
||||
$permissions = new MassEntityPermissionEvaluator($entities, 'view');
|
||||
|
||||
// Create a mapping of role permissions
|
||||
$rolePermissionMap = [];
|
||||
@@ -259,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,109 +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(function (Builder $query) use ($idsByType) {
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
$query->orWhere(function (Builder $query) use ($type, $ids) {
|
||||
$query->where('entity_type', '=', $type)->whereIn('entity_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 ($this->entityPermissionsActiveForRole($permissionMap, $entity, $roleId)) {
|
||||
$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 = !$this->entityPermissionsActiveForRole($permissionMap, $book, $roleId);
|
||||
|
||||
// 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);
|
||||
$chapterRestricted = $this->entityPermissionsActiveForRole($permissionMap, $chapter, $roleId);
|
||||
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapterRestricted;
|
||||
if ($chapterRestricted) {
|
||||
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $roleId);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->createJointPermissionDataArray(
|
||||
$entity,
|
||||
$roleId,
|
||||
($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
|
||||
($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity permissions are defined within the given map, for the given entity and role.
|
||||
* Checks for the default `role_id=0` backup option as a fallback.
|
||||
*/
|
||||
protected function entityPermissionsActiveForRole(array $permissionMap, SimpleEntityData $entity, int $roleId): bool
|
||||
{
|
||||
$keyPrefix = $entity->type . ':' . $entity->id . ':';
|
||||
return isset($permissionMap[$keyPrefix . $roleId]) || isset($permissionMap[$keyPrefix . '0']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for an active restriction in an entity map.
|
||||
*/
|
||||
protected function mapHasActiveRestriction(array $entityMap, SimpleEntityData $entity, int $roleId): bool
|
||||
{
|
||||
$roleKey = $entity->type . ':' . $entity->id . ':' . $roleId;
|
||||
$defaultKey = $entity->type . ':' . $entity->id . ':0';
|
||||
|
||||
return $entityMap[$roleKey] ?? $entityMap[$defaultKey] ?? 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;
|
||||
@@ -61,46 +60,7 @@ class PermissionApplicator
|
||||
{
|
||||
$this->ensureValidEntityAction($action);
|
||||
|
||||
$adminRoleId = Role::getSystemRole('admin')->id;
|
||||
if (in_array($adminRoleId, $userRoleIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The chain order here is very important due to the fact we walk up the chain
|
||||
// in the loop below. Earlier items in the chain have higher priority.
|
||||
$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) {
|
||||
$allowedByRoleId = $currentEntity->permissions()
|
||||
->whereIn('role_id', [0, ...$userRoleIds])
|
||||
->pluck($action, 'role_id');
|
||||
|
||||
// Continue up the chain if no applicable entity permission overrides.
|
||||
if ($allowedByRoleId->isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If we have user-role-specific permissions set, allow if any of those
|
||||
// role permissions allow access.
|
||||
$hasDefault = $allowedByRoleId->has(0);
|
||||
if (!$hasDefault || $allowedByRoleId->count() > 1) {
|
||||
return $allowedByRoleId->search(function (bool $allowed, int $roleId) {
|
||||
return $roleId !== 0 && $allowed;
|
||||
}) !== false;
|
||||
}
|
||||
|
||||
// Otherwise, return the default "Other roles" fallback value.
|
||||
return $allowedByRoleId->get(0);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (new EntityPermissionEvaluator($action))->evaluateEntityForUser($entity, $userRoleIds);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -161,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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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();
|
||||
|
||||
@@ -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,6 +2,8 @@
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
|
||||
class SimpleEntityData
|
||||
{
|
||||
public int $id;
|
||||
@@ -9,4 +11,18 @@ class SimpleEntityData
|
||||
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);
|
||||
@@ -27,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.
|
||||
*/
|
||||
@@ -107,15 +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();
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -158,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)) {
|
||||
@@ -231,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...
|
||||
@@ -141,58 +149,9 @@ return [
|
||||
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,
|
||||
|
||||
@@ -202,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'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -88,8 +88,6 @@ class Page extends BookChild
|
||||
|
||||
/**
|
||||
* Get the current revision for the page if existing.
|
||||
*
|
||||
* @return PageRevision|null
|
||||
*/
|
||||
public function currentRevision(): HasOne
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ class BookContents
|
||||
$model->changeBook($newBook->id);
|
||||
}
|
||||
|
||||
if ($chapterChanged) {
|
||||
if ($model instanceof Page && $chapterChanged) {
|
||||
$model->chapter_id = $newChapter->id ?? 0;
|
||||
}
|
||||
|
||||
@@ -235,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);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -109,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,20 +4,20 @@ 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
|
||||
{
|
||||
$permissions = $request->get('permissions', null);
|
||||
$ownerId = $request->get('owned_by', null);
|
||||
@@ -39,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)) {
|
||||
@@ -67,7 +99,41 @@ class PermissionsUpdater
|
||||
$formatted[] = $entityPermissionData;
|
||||
}
|
||||
|
||||
return $formatted;
|
||||
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']);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
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']);
|
||||
}
|
||||
}
|
||||
@@ -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 $emailConfirmationService;
|
||||
protected LoginService $loginService;
|
||||
protected UserRepo $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) {
|
||||
|
||||
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -35,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);
|
||||
@@ -56,8 +60,7 @@ class BookController extends Controller
|
||||
'popular' => $popular,
|
||||
'new' => $new,
|
||||
'view' => $view,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -30,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);
|
||||
@@ -55,9 +54,7 @@ class BookshelfController extends Controller
|
||||
'popular' => $popular,
|
||||
'new' => $new,
|
||||
'view' => $view,
|
||||
'sort' => $sort,
|
||||
'order' => $order,
|
||||
'sortOptions' => $sortOptions,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -67,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]);
|
||||
@@ -100,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();
|
||||
|
||||
@@ -124,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),
|
||||
]);
|
||||
}
|
||||
@@ -139,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()]));
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -3,19 +3,18 @@
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionsRepo;
|
||||
use BookStack\Auth\Queries\RolesAllPaginatedAndSorted;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class RoleController extends Controller
|
||||
{
|
||||
protected $permissionsRepo;
|
||||
protected PermissionsRepo $permissionsRepo;
|
||||
|
||||
/**
|
||||
* PermissionController constructor.
|
||||
*/
|
||||
public function __construct(PermissionsRepo $permissionsRepo)
|
||||
{
|
||||
$this->permissionsRepo = $permissionsRepo;
|
||||
@@ -24,14 +23,27 @@ class RoleController extends Controller
|
||||
/**
|
||||
* Show a listing of the roles in the system.
|
||||
*/
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$roles = $this->permissionsRepo->getAllRoles();
|
||||
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'roles')->withSortOptions([
|
||||
'display_name' => trans('common.sort_name'),
|
||||
'users_count' => trans('settings.roles_assigned_users'),
|
||||
'permissions_count' => trans('settings.roles_permissions_provided'),
|
||||
'created_at' => trans('common.sort_created_at'),
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
]);
|
||||
|
||||
$roles = (new RolesAllPaginatedAndSorted())->run(20, $listOptions);
|
||||
$roles->appends($listOptions->getPaginationAppends());
|
||||
|
||||
$this->setPageTitle(trans('settings.roles'));
|
||||
|
||||
return view('settings.roles.index', ['roles' => $roles]);
|
||||
return view('settings.roles.index', [
|
||||
'roles' => $roles,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,29 +74,28 @@ class RoleController extends Controller
|
||||
public function store(Request $request)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$this->validate($request, [
|
||||
$data = $this->validate($request, [
|
||||
'display_name' => ['required', 'min:3', 'max:180'],
|
||||
'description' => ['max:180'],
|
||||
'external_auth_id' => ['string'],
|
||||
'permissions' => ['array'],
|
||||
'mfa_enforced' => ['string'],
|
||||
]);
|
||||
|
||||
$this->permissionsRepo->saveNewRole($request->all());
|
||||
$this->showSuccessNotification(trans('settings.role_create_success'));
|
||||
$data['permissions'] = array_keys($data['permissions'] ?? []);
|
||||
$data['mfa_enforced'] = ($data['mfa_enforced'] ?? 'false') === 'true';
|
||||
$this->permissionsRepo->saveNewRole($data);
|
||||
|
||||
return redirect('/settings/roles');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing a user role.
|
||||
*
|
||||
* @throws PermissionsException
|
||||
*/
|
||||
public function edit(string $id)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$role = $this->permissionsRepo->getRoleById($id);
|
||||
if ($role->hidden) {
|
||||
throw new PermissionsException(trans('errors.role_cannot_be_edited'));
|
||||
}
|
||||
|
||||
$this->setPageTitle(trans('settings.role_edit'));
|
||||
|
||||
@@ -93,19 +104,21 @@ class RoleController extends Controller
|
||||
|
||||
/**
|
||||
* Updates a user role.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$this->checkPermission('user-roles-manage');
|
||||
$this->validate($request, [
|
||||
$data = $this->validate($request, [
|
||||
'display_name' => ['required', 'min:3', 'max:180'],
|
||||
'description' => ['max:180'],
|
||||
'external_auth_id' => ['string'],
|
||||
'permissions' => ['array'],
|
||||
'mfa_enforced' => ['string'],
|
||||
]);
|
||||
|
||||
$this->permissionsRepo->updateRole($id, $request->all());
|
||||
$this->showSuccessNotification(trans('settings.role_update_success'));
|
||||
$data['permissions'] = array_keys($data['permissions'] ?? []);
|
||||
$data['mfa_enforced'] = ($data['mfa_enforced'] ?? 'false') === 'true';
|
||||
$this->permissionsRepo->updateRole($id, $data);
|
||||
|
||||
return redirect('/settings/roles');
|
||||
}
|
||||
@@ -138,15 +151,14 @@ class RoleController extends Controller
|
||||
$this->checkPermission('user-roles-manage');
|
||||
|
||||
try {
|
||||
$this->permissionsRepo->deleteRole($id, $request->get('migrate_role_id'));
|
||||
$migrateRoleId = intval($request->get('migrate_role_id') ?: "0");
|
||||
$this->permissionsRepo->deleteRole($id, $migrateRoleId);
|
||||
} catch (PermissionsException $e) {
|
||||
$this->showErrorNotification($e->getMessage());
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
$this->showSuccessNotification(trans('settings.role_delete_success'));
|
||||
|
||||
return redirect('/settings/roles');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ use Illuminate\Http\Request;
|
||||
|
||||
class SearchController extends Controller
|
||||
{
|
||||
protected $searchRunner;
|
||||
protected SearchRunner $searchRunner;
|
||||
|
||||
public function __construct(SearchRunner $searchRunner)
|
||||
{
|
||||
@@ -69,7 +69,7 @@ class SearchController extends Controller
|
||||
* Search for a list of entities and return a partial HTML response of matching entities.
|
||||
* Returns the most popular entities if no search is provided.
|
||||
*/
|
||||
public function searchEntitiesAjax(Request $request)
|
||||
public function searchForSelector(Request $request)
|
||||
{
|
||||
$entityTypes = $request->filled('types') ? explode(',', $request->get('types')) : ['page', 'chapter', 'book'];
|
||||
$searchTerm = $request->get('term', false);
|
||||
@@ -83,7 +83,25 @@ class SearchController extends Controller
|
||||
$entities = (new Popular())->run(20, 0, $entityTypes);
|
||||
}
|
||||
|
||||
return view('search.parts.entity-ajax-list', ['entities' => $entities, 'permission' => $permission]);
|
||||
return view('search.parts.entity-selector-list', ['entities' => $entities, 'permission' => $permission]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for a list of entities and return a partial HTML response of matching entities
|
||||
* to be used as a result preview suggestion list for global system searches.
|
||||
*/
|
||||
public function searchSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->get('term', '');
|
||||
$entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 5)['results'];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$entity->setAttribute('preview_content', '');
|
||||
}
|
||||
|
||||
return view('search.parts.entity-suggestion-list', [
|
||||
'entities' => $entities->slice(0, 5)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,20 +4,14 @@ namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Settings\AppSettingsStore;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SettingController extends Controller
|
||||
{
|
||||
protected ImageRepo $imageRepo;
|
||||
|
||||
protected array $settingCategories = ['features', 'customization', 'registration'];
|
||||
|
||||
public function __construct(ImageRepo $imageRepo)
|
||||
{
|
||||
$this->imageRepo = $imageRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle requests to the settings index path.
|
||||
*/
|
||||
@@ -48,37 +42,17 @@ class SettingController extends Controller
|
||||
/**
|
||||
* Update the specified settings in storage.
|
||||
*/
|
||||
public function update(Request $request, string $category)
|
||||
public function update(Request $request, AppSettingsStore $store, string $category)
|
||||
{
|
||||
$this->ensureCategoryExists($category);
|
||||
$this->preventAccessInDemoMode();
|
||||
$this->checkPermission('settings-manage');
|
||||
$this->validate($request, [
|
||||
'app_logo' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'app_logo' => ['nullable', ...$this->getImageValidationRules()],
|
||||
'app_icon' => ['nullable', ...$this->getImageValidationRules()],
|
||||
]);
|
||||
|
||||
// Cycles through posted settings and update them
|
||||
foreach ($request->all() as $name => $value) {
|
||||
$key = str_replace('setting-', '', trim($name));
|
||||
if (strpos($name, 'setting-') !== 0) {
|
||||
continue;
|
||||
}
|
||||
setting()->put($key, $value);
|
||||
}
|
||||
|
||||
// Update logo image if set
|
||||
if ($category === 'customization' && $request->hasFile('app_logo')) {
|
||||
$logoFile = $request->file('app_logo');
|
||||
$this->imageRepo->destroyByType('system');
|
||||
$image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86);
|
||||
setting()->put('app-logo', $image->url);
|
||||
}
|
||||
|
||||
// Clear logo image if requested
|
||||
if ($category === 'customization' && $request->get('app_logo_reset', null)) {
|
||||
$this->imageRepo->destroyByType('system');
|
||||
setting()->remove('app-logo');
|
||||
}
|
||||
$store->storeFromUpdateRequest($request, $category);
|
||||
|
||||
$this->logActivity(ActivityType::SETTINGS_UPDATE, $category);
|
||||
$this->showSuccessNotification(trans('settings.settings_save_success'));
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\TagRepo;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TagController extends Controller
|
||||
{
|
||||
protected TagRepo $tagRepo;
|
||||
|
||||
public function __construct(TagRepo $tagRepo)
|
||||
{
|
||||
$this->tagRepo = $tagRepo;
|
||||
public function __construct(
|
||||
protected TagRepo $tagRepo
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,22 +18,25 @@ class TagController extends Controller
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$search = $request->get('search', '');
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'tags')->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
'usages' => trans('entities.tags_usages'),
|
||||
]);
|
||||
|
||||
$nameFilter = $request->get('name', '');
|
||||
$tags = $this->tagRepo
|
||||
->queryWithTotals($search, $nameFilter)
|
||||
->queryWithTotals($listOptions, $nameFilter)
|
||||
->paginate(50)
|
||||
->appends(array_filter([
|
||||
'search' => $search,
|
||||
->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [
|
||||
'name' => $nameFilter,
|
||||
]));
|
||||
])));
|
||||
|
||||
$this->setPageTitle(trans('entities.tags'));
|
||||
|
||||
return view('tags.index', [
|
||||
'tags' => $tags,
|
||||
'search' => $search,
|
||||
'nameFilter' => $nameFilter,
|
||||
'tags' => $tags,
|
||||
'nameFilter' => $nameFilter,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Auth\Access\SocialAuthService;
|
||||
use BookStack\Auth\Queries\AllUsersPaginatedAndSorted;
|
||||
use BookStack\Auth\Queries\UsersAllPaginatedAndSorted;
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Auth\UserRepo;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Exceptions\UserUpdateException;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -21,9 +21,6 @@ class UserController extends Controller
|
||||
protected UserRepo $userRepo;
|
||||
protected ImageRepo $imageRepo;
|
||||
|
||||
/**
|
||||
* UserController constructor.
|
||||
*/
|
||||
public function __construct(UserRepo $userRepo, ImageRepo $imageRepo)
|
||||
{
|
||||
$this->userRepo = $userRepo;
|
||||
@@ -36,20 +33,23 @@ class UserController extends Controller
|
||||
public function index(Request $request)
|
||||
{
|
||||
$this->checkPermission('users-manage');
|
||||
$listDetails = [
|
||||
'order' => $request->get('order', 'asc'),
|
||||
'search' => $request->get('search', ''),
|
||||
'sort' => $request->get('sort', 'name'),
|
||||
];
|
||||
|
||||
$users = (new AllUsersPaginatedAndSorted())->run(20, $listDetails);
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'users')->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
'email' => trans('auth.email'),
|
||||
'created_at' => trans('common.sort_created_at'),
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
'last_activity_at' => trans('settings.users_latest_activity'),
|
||||
]);
|
||||
|
||||
$users = (new UsersAllPaginatedAndSorted())->run(20, $listOptions);
|
||||
|
||||
$this->setPageTitle(trans('settings.users'));
|
||||
$users->appends($listDetails);
|
||||
$users->appends($listOptions->getPaginationAppends());
|
||||
|
||||
return view('users.index', [
|
||||
'users' => $users,
|
||||
'listDetails' => $listDetails,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -107,9 +107,8 @@ class UserController extends Controller
|
||||
{
|
||||
$this->checkPermissionOrCurrentUser('users-manage', $id);
|
||||
|
||||
/** @var User $user */
|
||||
$user = User::query()->with(['apiTokens', 'mfaValues'])->findOrFail($id);
|
||||
|
||||
$user = $this->userRepo->getById($id);
|
||||
$user->load(['apiTokens', 'mfaValues']);
|
||||
$authMethod = ($user->system_name) ? 'system' : config('auth.method');
|
||||
|
||||
$activeSocialDrivers = $socialAuthService->getActiveDrivers();
|
||||
@@ -165,6 +164,8 @@ class UserController extends Controller
|
||||
// Delete the profile image if reset option is in request
|
||||
if ($request->has('profile_image_reset')) {
|
||||
$this->imageRepo->destroyImage($user->avatar);
|
||||
$user->image_id = 0;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
$redirectUrl = userCan('users-manage') ? '/settings/users' : "/settings/users/{$user->id}";
|
||||
@@ -196,143 +197,10 @@ class UserController extends Controller
|
||||
$this->checkPermissionOrCurrentUser('users-manage', $id);
|
||||
|
||||
$user = $this->userRepo->getById($id);
|
||||
$newOwnerId = $request->get('new_owner_id', null);
|
||||
$newOwnerId = intval($request->get('new_owner_id')) ?: null;
|
||||
|
||||
$this->userRepo->destroy($user, $newOwnerId);
|
||||
|
||||
return redirect('/settings/users');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's preferred book-list display setting.
|
||||
*/
|
||||
public function switchBooksView(Request $request, int $id)
|
||||
{
|
||||
return $this->switchViewType($id, $request, 'books');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's preferred shelf-list display setting.
|
||||
*/
|
||||
public function switchShelvesView(Request $request, int $id)
|
||||
{
|
||||
return $this->switchViewType($id, $request, 'bookshelves');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's preferred shelf-view book list display setting.
|
||||
*/
|
||||
public function switchShelfView(Request $request, int $id)
|
||||
{
|
||||
return $this->switchViewType($id, $request, 'bookshelf');
|
||||
}
|
||||
|
||||
/**
|
||||
* For a type of list, switch with stored view type for a user.
|
||||
*/
|
||||
protected function switchViewType(int $userId, Request $request, string $listName)
|
||||
{
|
||||
$this->checkPermissionOrCurrentUser('users-manage', $userId);
|
||||
|
||||
$viewType = $request->get('view_type');
|
||||
if (!in_array($viewType, ['grid', 'list'])) {
|
||||
$viewType = 'list';
|
||||
}
|
||||
|
||||
$user = $this->userRepo->getById($userId);
|
||||
$key = $listName . '_view_type';
|
||||
setting()->putUser($user, $key, $viewType);
|
||||
|
||||
return redirect()->back(302, [], "/settings/users/$userId");
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the stored sort type for a particular view.
|
||||
*/
|
||||
public function changeSort(Request $request, string $id, string $type)
|
||||
{
|
||||
$validSortTypes = ['books', 'bookshelves', 'shelf_books'];
|
||||
if (!in_array($type, $validSortTypes)) {
|
||||
return redirect()->back(500);
|
||||
}
|
||||
|
||||
return $this->changeListSort($id, $request, $type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle dark mode for the current user.
|
||||
*/
|
||||
public function toggleDarkMode()
|
||||
{
|
||||
$enabled = setting()->getForCurrentUser('dark-mode-enabled', false);
|
||||
setting()->putUser(user(), 'dark-mode-enabled', $enabled ? 'false' : 'true');
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stored section expansion preference for the given user.
|
||||
*/
|
||||
public function updateExpansionPreference(Request $request, string $id, string $key)
|
||||
{
|
||||
$this->checkPermissionOrCurrentUser('users-manage', $id);
|
||||
$keyWhitelist = ['home-details'];
|
||||
if (!in_array($key, $keyWhitelist)) {
|
||||
return response('Invalid key', 500);
|
||||
}
|
||||
|
||||
$newState = $request->get('expand', 'false');
|
||||
|
||||
$user = $this->userRepo->getById($id);
|
||||
setting()->putUser($user, 'section_expansion#' . $key, $newState);
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
public function updateCodeLanguageFavourite(Request $request)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'language' => ['required', 'string', 'max:20'],
|
||||
'active' => ['required', 'bool'],
|
||||
]);
|
||||
|
||||
$currentFavoritesStr = setting()->getForCurrentUser('code-language-favourites', '');
|
||||
$currentFavorites = array_filter(explode(',', $currentFavoritesStr));
|
||||
|
||||
$isFav = in_array($validated['language'], $currentFavorites);
|
||||
if (!$isFav && $validated['active']) {
|
||||
$currentFavorites[] = $validated['language'];
|
||||
} elseif ($isFav && !$validated['active']) {
|
||||
$index = array_search($validated['language'], $currentFavorites);
|
||||
array_splice($currentFavorites, $index, 1);
|
||||
}
|
||||
|
||||
setting()->putUser(user(), 'code-language-favourites', implode(',', $currentFavorites));
|
||||
}
|
||||
|
||||
/**
|
||||
* Changed the stored preference for a list sort order.
|
||||
*/
|
||||
protected function changeListSort(int $userId, Request $request, string $listName)
|
||||
{
|
||||
$this->checkPermissionOrCurrentUser('users-manage', $userId);
|
||||
|
||||
$sort = $request->get('sort');
|
||||
if (!in_array($sort, ['name', 'created_at', 'updated_at', 'default'])) {
|
||||
$sort = 'name';
|
||||
}
|
||||
|
||||
$order = $request->get('order');
|
||||
if (!in_array($order, ['asc', 'desc'])) {
|
||||
$order = 'asc';
|
||||
}
|
||||
|
||||
$user = $this->userRepo->getById($userId);
|
||||
$sortKey = $listName . '_sort';
|
||||
$orderKey = $listName . '_sort_order';
|
||||
setting()->putUser($user, $sortKey, $sort);
|
||||
setting()->putUser($user, $orderKey, $order);
|
||||
|
||||
return redirect()->back(302, [], "/settings/users/$userId");
|
||||
}
|
||||
}
|
||||
|
||||
142
app/Http/Controllers/UserPreferencesController.php
Normal file
142
app/Http/Controllers/UserPreferencesController.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Auth\UserRepo;
|
||||
use BookStack\Settings\UserShortcutMap;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserPreferencesController extends Controller
|
||||
{
|
||||
protected UserRepo $userRepo;
|
||||
|
||||
public function __construct(UserRepo $userRepo)
|
||||
{
|
||||
$this->userRepo = $userRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the user-specific interface shortcuts.
|
||||
*/
|
||||
public function showShortcuts()
|
||||
{
|
||||
$shortcuts = UserShortcutMap::fromUserPreferences();
|
||||
$enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false);
|
||||
|
||||
return view('users.preferences.shortcuts', [
|
||||
'shortcuts' => $shortcuts,
|
||||
'enabled' => $enabled,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user-specific interface shortcuts.
|
||||
*/
|
||||
public function updateShortcuts(Request $request)
|
||||
{
|
||||
$enabled = $request->get('enabled') === 'true';
|
||||
$providedShortcuts = $request->get('shortcut', []);
|
||||
$shortcuts = new UserShortcutMap($providedShortcuts);
|
||||
|
||||
setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson());
|
||||
setting()->putForCurrentUser('ui-shortcuts-enabled', $enabled);
|
||||
|
||||
$this->showSuccessNotification(trans('preferences.shortcuts_update_success'));
|
||||
|
||||
return redirect('/preferences/shortcuts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the preferred view format for a list view of the given type.
|
||||
*/
|
||||
public function changeView(Request $request, string $type)
|
||||
{
|
||||
$valueViewTypes = ['books', 'bookshelves', 'bookshelf'];
|
||||
if (!in_array($type, $valueViewTypes)) {
|
||||
return redirect()->back(500);
|
||||
}
|
||||
|
||||
$view = $request->get('view');
|
||||
if (!in_array($view, ['grid', 'list'])) {
|
||||
$view = 'list';
|
||||
}
|
||||
|
||||
$key = $type . '_view_type';
|
||||
setting()->putForCurrentUser($key, $view);
|
||||
|
||||
return redirect()->back(302, [], "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the stored sort type for a particular view.
|
||||
*/
|
||||
public function changeSort(Request $request, string $type)
|
||||
{
|
||||
$validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles', 'webhooks', 'tags', 'page_revisions'];
|
||||
if (!in_array($type, $validSortTypes)) {
|
||||
return redirect()->back(500);
|
||||
}
|
||||
|
||||
$sort = substr($request->get('sort') ?: 'name', 0, 50);
|
||||
$order = $request->get('order') === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
$sortKey = $type . '_sort';
|
||||
$orderKey = $type . '_sort_order';
|
||||
setting()->putForCurrentUser($sortKey, $sort);
|
||||
setting()->putForCurrentUser($orderKey, $order);
|
||||
|
||||
return redirect()->back(302, [], "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle dark mode for the current user.
|
||||
*/
|
||||
public function toggleDarkMode()
|
||||
{
|
||||
$enabled = setting()->getForCurrentUser('dark-mode-enabled', false);
|
||||
setting()->putForCurrentUser('dark-mode-enabled', $enabled ? 'false' : 'true');
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the stored section expansion preference for the given user.
|
||||
*/
|
||||
public function changeExpansion(Request $request, string $type)
|
||||
{
|
||||
$typeWhitelist = ['home-details'];
|
||||
if (!in_array($type, $typeWhitelist)) {
|
||||
return response('Invalid key', 500);
|
||||
}
|
||||
|
||||
$newState = $request->get('expand', 'false');
|
||||
setting()->putForCurrentUser('section_expansion#' . $type, $newState);
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the favorite status for a code language.
|
||||
*/
|
||||
public function updateCodeLanguageFavourite(Request $request)
|
||||
{
|
||||
$validated = $this->validate($request, [
|
||||
'language' => ['required', 'string', 'max:20'],
|
||||
'active' => ['required', 'bool'],
|
||||
]);
|
||||
|
||||
$currentFavoritesStr = setting()->getForCurrentUser('code-language-favourites', '');
|
||||
$currentFavorites = array_filter(explode(',', $currentFavoritesStr));
|
||||
|
||||
$isFav = in_array($validated['language'], $currentFavorites);
|
||||
if (!$isFav && $validated['active']) {
|
||||
$currentFavorites[] = $validated['language'];
|
||||
} elseif ($isFav && !$validated['active']) {
|
||||
$index = array_search($validated['language'], $currentFavorites);
|
||||
array_splice($currentFavorites, $index, 1);
|
||||
}
|
||||
|
||||
setting()->putForCurrentUser('code-language-favourites', implode(',', $currentFavorites));
|
||||
return response('', 204);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace BookStack\Http\Controllers;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Actions\Queries\WebhooksAllPaginatedAndSorted;
|
||||
use BookStack\Actions\Webhook;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WebhookController extends Controller
|
||||
@@ -18,16 +20,25 @@ class WebhookController extends Controller
|
||||
/**
|
||||
* Show all webhooks configured in the system.
|
||||
*/
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$webhooks = Webhook::query()
|
||||
->orderBy('name', 'desc')
|
||||
->with('trackedEvents')
|
||||
->get();
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'webhooks')->withSortOptions([
|
||||
'name' => trans('common.sort_name'),
|
||||
'endpoint' => trans('settings.webhooks_endpoint'),
|
||||
'created_at' => trans('common.sort_created_at'),
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
'active' => trans('common.status'),
|
||||
]);
|
||||
|
||||
$webhooks = (new WebhooksAllPaginatedAndSorted())->run(20, $listOptions);
|
||||
$webhooks->appends($listOptions->getPaginationAppends());
|
||||
|
||||
$this->setPageTitle(trans('settings.webhooks'));
|
||||
|
||||
return view('settings.webhooks.index', ['webhooks' => $webhooks]);
|
||||
return view('settings.webhooks.index', [
|
||||
'webhooks' => $webhooks,
|
||||
'listOptions' => $listOptions,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,10 +9,8 @@ class Request extends LaravelRequest
|
||||
/**
|
||||
* Override the default request methods to get the scheme and host
|
||||
* to directly use the custom APP_URL, if set.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getSchemeAndHttpHost()
|
||||
public function getSchemeAndHttpHost(): string
|
||||
{
|
||||
$appUrl = config('app.url', null);
|
||||
|
||||
@@ -27,10 +25,8 @@ class Request extends LaravelRequest
|
||||
* Override the default request methods to get the base URL
|
||||
* to directly use the custom APP_URL, if set.
|
||||
* The base URL never ends with a / but should start with one if not empty.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getBaseUrl()
|
||||
public function getBaseUrl(): string
|
||||
{
|
||||
$appUrl = config('app.url', null);
|
||||
|
||||
|
||||
@@ -8,16 +8,16 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exceptions\WhoopsBookStackPrettyHandler;
|
||||
use BookStack\Exceptions\BookStackExceptionHandlerPage;
|
||||
use BookStack\Settings\SettingService;
|
||||
use BookStack\Util\CspService;
|
||||
use GuzzleHttp\Client;
|
||||
use Illuminate\Contracts\Foundation\ExceptionRenderer;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Psr\Http\Client\ClientInterface as HttpClientInterface;
|
||||
use Whoops\Handler\HandlerInterface;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -26,7 +26,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
* @var string[]
|
||||
*/
|
||||
public $bindings = [
|
||||
HandlerInterface::class => WhoopsBookStackPrettyHandler::class,
|
||||
ExceptionRenderer::class => BookStackExceptionHandlerPage::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,11 +24,22 @@ class EventServiceProvider extends ServiceProvider
|
||||
];
|
||||
|
||||
/**
|
||||
* Register any other events for your application.
|
||||
* Register any events for your application.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if events and listeners should be automatically discovered.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function shouldDiscoverEvents()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,14 +19,6 @@ class RouteServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public const HOME = '/';
|
||||
|
||||
/**
|
||||
* This namespace is applied to the controller routes in your routes file.
|
||||
*
|
||||
* In addition, it is set as the URL generator's root namespace.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
|
||||
/**
|
||||
* Define your route model bindings, pattern filters, etc.
|
||||
*
|
||||
@@ -85,7 +77,7 @@ class RouteServiceProvider extends ServiceProvider
|
||||
protected function configureRateLimiting()
|
||||
{
|
||||
RateLimiter::for('api', function (Request $request) {
|
||||
return Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip());
|
||||
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,41 @@
|
||||
namespace BookStack\Providers;
|
||||
|
||||
use BookStack\Translation\FileLoader;
|
||||
use BookStack\Translation\MessageSelector;
|
||||
use Illuminate\Translation\TranslationServiceProvider as BaseProvider;
|
||||
use Illuminate\Translation\Translator;
|
||||
|
||||
class TranslationServiceProvider extends BaseProvider
|
||||
{
|
||||
/**
|
||||
* Register the service provider.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
$this->registerLoader();
|
||||
|
||||
// This is a tweak upon Laravel's based translation service registration to allow
|
||||
// usage of a custom MessageSelector class
|
||||
$this->app->singleton('translator', function ($app) {
|
||||
$loader = $app['translation.loader'];
|
||||
|
||||
// When registering the translator component, we'll need to set the default
|
||||
// locale as well as the fallback locale. So, we'll grab the application
|
||||
// configuration so we can easily get both of these values from there.
|
||||
$locale = $app['config']['app.locale'];
|
||||
|
||||
$trans = new Translator($loader, $locale);
|
||||
$trans->setFallback($app['config']['app.fallback_locale']);
|
||||
$trans->setSelector(new MessageSelector());
|
||||
|
||||
return $trans;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Register the translation line loader.
|
||||
* Overrides the default register action from Laravel so a custom loader can be used.
|
||||
|
||||
@@ -21,8 +21,8 @@ class ValidationRuleServiceProvider extends ServiceProvider
|
||||
|
||||
Validator::extend('safe_url', function ($attribute, $value, $parameters, $validator) {
|
||||
$cleanLinkName = strtolower(trim($value));
|
||||
$isJs = strpos($cleanLinkName, 'javascript:') === 0;
|
||||
$isData = strpos($cleanLinkName, 'data:') === 0;
|
||||
$isJs = str_starts_with($cleanLinkName, 'javascript:');
|
||||
$isData = str_starts_with($cleanLinkName, 'data:');
|
||||
|
||||
return !$isJs && !$isData;
|
||||
});
|
||||
|
||||
@@ -54,10 +54,10 @@ class CrossLinkParser
|
||||
{
|
||||
$links = [];
|
||||
|
||||
$html = '<body>' . $html . '</body>';
|
||||
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument();
|
||||
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
|
||||
$doc->loadHTML($html);
|
||||
|
||||
$xPath = new DOMXPath($doc);
|
||||
$anchors = $xPath->query('//a[@href]');
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace BookStack\References;
|
||||
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
@@ -24,4 +26,10 @@ class Reference extends Model
|
||||
{
|
||||
return $this->morphTo('to');
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'from_id')
|
||||
->whereColumn('references.from_type', '=', 'joint_permissions.entity_type');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\References;
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
@@ -23,8 +24,7 @@ class ReferenceFetcher
|
||||
*/
|
||||
public function getPageReferencesToEntity(Entity $entity): Collection
|
||||
{
|
||||
$baseQuery = $entity->referencesTo()
|
||||
->where('from_type', '=', (new Page())->getMorphClass())
|
||||
$baseQuery = $this->queryPageReferencesToEntity($entity)
|
||||
->with([
|
||||
'from' => fn (Relation $query) => $query->select(Page::$listAttributes),
|
||||
'from.book' => fn (Relation $query) => $query->scopes('visible'),
|
||||
@@ -47,11 +47,8 @@ class ReferenceFetcher
|
||||
*/
|
||||
public function getPageReferenceCountToEntity(Entity $entity): int
|
||||
{
|
||||
$baseQuery = $entity->referencesTo()
|
||||
->where('from_type', '=', (new Page())->getMorphClass());
|
||||
|
||||
$count = $this->permissions->restrictEntityRelationQuery(
|
||||
$baseQuery,
|
||||
$this->queryPageReferencesToEntity($entity),
|
||||
'references',
|
||||
'from_id',
|
||||
'from_type'
|
||||
@@ -59,4 +56,12 @@ class ReferenceFetcher
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
protected function queryPageReferencesToEntity(Entity $entity): Builder
|
||||
{
|
||||
return Reference::query()
|
||||
->where('to_type', '=', $entity->getMorphClass())
|
||||
->where('to_id', '=', $entity->id)
|
||||
->where('from_type', '=', (new Page())->getMorphClass());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,25 +15,18 @@ class SearchIndex
|
||||
{
|
||||
/**
|
||||
* A list of delimiter characters used to break-up parsed content into terms for indexing.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public static $delimiters = " \n\t.,!?:;()[]{}<>`'\"";
|
||||
public static string $delimiters = " \n\t.,!?:;()[]{}<>`'\"";
|
||||
|
||||
/**
|
||||
* @var EntityProvider
|
||||
*/
|
||||
protected $entityProvider;
|
||||
|
||||
public function __construct(EntityProvider $entityProvider)
|
||||
{
|
||||
$this->entityProvider = $entityProvider;
|
||||
public function __construct(
|
||||
protected EntityProvider $entityProvider
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Index the given entity.
|
||||
*/
|
||||
public function indexEntity(Entity $entity)
|
||||
public function indexEntity(Entity $entity): void
|
||||
{
|
||||
$this->deleteEntityTerms($entity);
|
||||
$terms = $this->entityToTermDataArray($entity);
|
||||
@@ -45,7 +38,7 @@ class SearchIndex
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
*/
|
||||
public function indexEntities(array $entities)
|
||||
public function indexEntities(array $entities): void
|
||||
{
|
||||
$terms = [];
|
||||
foreach ($entities as $entity) {
|
||||
@@ -69,7 +62,7 @@ class SearchIndex
|
||||
*
|
||||
* @param callable(Entity, int, int):void|null $progressCallback
|
||||
*/
|
||||
public function indexAllEntities(?callable $progressCallback = null)
|
||||
public function indexAllEntities(?callable $progressCallback = null): void
|
||||
{
|
||||
SearchTerm::query()->truncate();
|
||||
|
||||
@@ -101,7 +94,7 @@ class SearchIndex
|
||||
/**
|
||||
* Delete related Entity search terms.
|
||||
*/
|
||||
public function deleteEntityTerms(Entity $entity)
|
||||
public function deleteEntityTerms(Entity $entity): void
|
||||
{
|
||||
$entity->searchTerms()->delete();
|
||||
}
|
||||
@@ -112,12 +105,12 @@ class SearchIndex
|
||||
*
|
||||
* @returns array<string, int>
|
||||
*/
|
||||
protected function generateTermScoreMapFromText(string $text, int $scoreAdjustment = 1): array
|
||||
protected function generateTermScoreMapFromText(string $text, float $scoreAdjustment = 1): array
|
||||
{
|
||||
$termMap = $this->textToTermCountMap($text);
|
||||
|
||||
foreach ($termMap as $term => $count) {
|
||||
$termMap[$term] = $count * $scoreAdjustment;
|
||||
$termMap[$term] = floor($count * $scoreAdjustment);
|
||||
}
|
||||
|
||||
return $termMap;
|
||||
@@ -145,12 +138,12 @@ class SearchIndex
|
||||
'h6' => 1.5,
|
||||
];
|
||||
|
||||
$html = '<body>' . $html . '</body>';
|
||||
$html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
|
||||
$html = str_ireplace(['<br>', '<br />', '<br/>'], "\n", $html);
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument();
|
||||
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
|
||||
$doc->loadHTML($html);
|
||||
|
||||
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
|
||||
/** @var DOMNode $child */
|
||||
|
||||
@@ -50,7 +50,7 @@ class SearchRunner
|
||||
* The provided count is for each entity to search,
|
||||
* Total returned could be larger and not guaranteed.
|
||||
*
|
||||
* @return array{total: int, count: int, has_more: bool, results: Entity[]}
|
||||
* @return array{total: int, count: int, has_more: bool, results: Collection<Entity>}
|
||||
*/
|
||||
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20): array
|
||||
{
|
||||
@@ -173,6 +173,7 @@ class SearchRunner
|
||||
// Handle exact term matching
|
||||
foreach ($searchOpts->exacts as $inputTerm) {
|
||||
$entityQuery->where(function (EloquentBuilder $query) use ($inputTerm, $entityModelInstance) {
|
||||
$inputTerm = str_replace('\\', '\\\\', $inputTerm);
|
||||
$query->where('name', 'like', '%' . $inputTerm . '%')
|
||||
->orWhere($entityModelInstance->textField, 'like', '%' . $inputTerm . '%');
|
||||
});
|
||||
@@ -218,6 +219,7 @@ class SearchRunner
|
||||
$subQuery->where('entity_type', '=', $entity->getMorphClass());
|
||||
$subQuery->where(function (Builder $query) use ($terms) {
|
||||
foreach ($terms as $inputTerm) {
|
||||
$inputTerm = str_replace('\\', '\\\\', $inputTerm);
|
||||
$query->orWhere('term', 'like', $inputTerm . '%');
|
||||
}
|
||||
});
|
||||
@@ -354,6 +356,9 @@ class SearchRunner
|
||||
$tagValue = (float) trim($connection->getPdo()->quote($tagValue), "'");
|
||||
$query->whereRaw("value {$tagOperator} {$tagValue}");
|
||||
} else {
|
||||
if ($tagOperator === 'like') {
|
||||
$tagValue = str_replace('\\', '\\\\', $tagValue);
|
||||
}
|
||||
$query->where('value', $tagOperator, $tagValue);
|
||||
}
|
||||
} else {
|
||||
|
||||
95
app/Settings/AppSettingsStore.php
Normal file
95
app/Settings/AppSettingsStore.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Settings;
|
||||
|
||||
use BookStack\Uploads\FaviconHandler;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AppSettingsStore
|
||||
{
|
||||
public function __construct(
|
||||
protected ImageRepo $imageRepo,
|
||||
protected FaviconHandler $faviconHandler,
|
||||
) {
|
||||
}
|
||||
|
||||
public function storeFromUpdateRequest(Request $request, string $category)
|
||||
{
|
||||
$this->storeSimpleSettings($request);
|
||||
if ($category === 'customization') {
|
||||
$this->updateAppLogo($request);
|
||||
$this->updateAppIcon($request);
|
||||
}
|
||||
}
|
||||
|
||||
protected function updateAppIcon(Request $request): void
|
||||
{
|
||||
$sizes = [180, 128, 64, 32];
|
||||
|
||||
// Update icon image if set
|
||||
if ($request->hasFile('app_icon')) {
|
||||
$iconFile = $request->file('app_icon');
|
||||
$this->destroyExistingSettingImage('app-icon');
|
||||
$image = $this->imageRepo->saveNew($iconFile, 'system', 0, 256, 256);
|
||||
setting()->put('app-icon', $image->url);
|
||||
|
||||
foreach ($sizes as $size) {
|
||||
$this->destroyExistingSettingImage('app-icon-' . $size);
|
||||
$icon = $this->imageRepo->saveNew($iconFile, 'system', 0, $size, $size);
|
||||
setting()->put('app-icon-' . $size, $icon->url);
|
||||
}
|
||||
|
||||
$this->faviconHandler->saveForUploadedImage($iconFile);
|
||||
}
|
||||
|
||||
// Clear icon image if requested
|
||||
if ($request->get('app_icon_reset')) {
|
||||
$this->destroyExistingSettingImage('app-icon');
|
||||
setting()->remove('app-icon');
|
||||
foreach ($sizes as $size) {
|
||||
$this->destroyExistingSettingImage('app-icon-' . $size);
|
||||
setting()->remove('app-icon-' . $size);
|
||||
}
|
||||
|
||||
$this->faviconHandler->restoreOriginal();
|
||||
}
|
||||
}
|
||||
|
||||
protected function updateAppLogo(Request $request): void
|
||||
{
|
||||
// Update logo image if set
|
||||
if ($request->hasFile('app_logo')) {
|
||||
$logoFile = $request->file('app_logo');
|
||||
$this->destroyExistingSettingImage('app-logo');
|
||||
$image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86);
|
||||
setting()->put('app-logo', $image->url);
|
||||
}
|
||||
|
||||
// Clear logo image if requested
|
||||
if ($request->get('app_logo_reset')) {
|
||||
$this->destroyExistingSettingImage('app-logo');
|
||||
setting()->remove('app-logo');
|
||||
}
|
||||
}
|
||||
|
||||
protected function storeSimpleSettings(Request $request): void
|
||||
{
|
||||
foreach ($request->all() as $name => $value) {
|
||||
if (strpos($name, 'setting-') !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = str_replace('setting-', '', trim($name));
|
||||
setting()->put($key, $value);
|
||||
}
|
||||
}
|
||||
|
||||
protected function destroyExistingSettingImage(string $settingKey)
|
||||
{
|
||||
$existingVal = setting()->get($settingKey);
|
||||
if ($existingVal) {
|
||||
$this->imageRepo->destroyByUrlAndType($existingVal, 'system');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,49 +3,29 @@
|
||||
namespace BookStack\Settings;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use Illuminate\Contracts\Cache\Repository as Cache;
|
||||
|
||||
/**
|
||||
* Class SettingService
|
||||
* The settings are a simple key-value database store.
|
||||
* For non-authenticated users, user settings are stored via the session instead.
|
||||
* A local array-based cache is used to for setting accesses across a request.
|
||||
*/
|
||||
class SettingService
|
||||
{
|
||||
protected $setting;
|
||||
protected $cache;
|
||||
protected $localCache = [];
|
||||
|
||||
protected $cachePrefix = 'setting-';
|
||||
|
||||
/**
|
||||
* SettingService constructor.
|
||||
*/
|
||||
public function __construct(Setting $setting, Cache $cache)
|
||||
{
|
||||
$this->setting = $setting;
|
||||
$this->cache = $cache;
|
||||
}
|
||||
protected array $localCache = [];
|
||||
|
||||
/**
|
||||
* Gets a setting from the database,
|
||||
* If not found, Returns default, Which is false by default.
|
||||
*/
|
||||
public function get(string $key, $default = null)
|
||||
public function get(string $key, $default = null): mixed
|
||||
{
|
||||
if (is_null($default)) {
|
||||
$default = config('setting-defaults.' . $key, false);
|
||||
}
|
||||
|
||||
if (isset($this->localCache[$key])) {
|
||||
return $this->localCache[$key];
|
||||
}
|
||||
|
||||
$value = $this->getValueFromStore($key) ?? $default;
|
||||
$formatted = $this->formatValue($value, $default);
|
||||
$this->localCache[$key] = $formatted;
|
||||
|
||||
return $formatted;
|
||||
return $this->formatValue($value, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,52 +63,78 @@ class SettingService
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a setting value from the cache or database.
|
||||
* Looks at the system defaults if not cached or in database.
|
||||
* Returns null if nothing is found.
|
||||
* Gets a setting value from the local cache.
|
||||
* Will load the local cache if not previously loaded.
|
||||
*/
|
||||
protected function getValueFromStore(string $key)
|
||||
protected function getValueFromStore(string $key): mixed
|
||||
{
|
||||
// Check the cache
|
||||
$cacheKey = $this->cachePrefix . $key;
|
||||
$cacheVal = $this->cache->get($cacheKey, null);
|
||||
if ($cacheVal !== null) {
|
||||
return $cacheVal;
|
||||
$cacheCategory = $this->localCacheCategory($key);
|
||||
if (!isset($this->localCache[$cacheCategory])) {
|
||||
$this->loadToLocalCache($cacheCategory);
|
||||
}
|
||||
|
||||
// Check the database
|
||||
$settingObject = $this->getSettingObjectByKey($key);
|
||||
if ($settingObject !== null) {
|
||||
$value = $settingObject->value;
|
||||
|
||||
if ($settingObject->type === 'array') {
|
||||
$value = json_decode($value, true) ?? [];
|
||||
}
|
||||
|
||||
$this->cache->forever($cacheKey, $value);
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
return $this->localCache[$cacheCategory][$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear an item from the cache completely.
|
||||
* Put the given value into the local cached under the given key.
|
||||
*/
|
||||
protected function clearFromCache(string $key)
|
||||
protected function putValueIntoLocalCache(string $key, mixed $value): void
|
||||
{
|
||||
$cacheKey = $this->cachePrefix . $key;
|
||||
$this->cache->forget($cacheKey);
|
||||
if (isset($this->localCache[$key])) {
|
||||
unset($this->localCache[$key]);
|
||||
$cacheCategory = $this->localCacheCategory($key);
|
||||
if (!isset($this->localCache[$cacheCategory])) {
|
||||
$this->loadToLocalCache($cacheCategory);
|
||||
}
|
||||
|
||||
$this->localCache[$cacheCategory][$key] = $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the category for the given setting key.
|
||||
* Will return 'app' for a general app setting otherwise 'user:<user_id>' for a user setting.
|
||||
*/
|
||||
protected function localCacheCategory(string $key): string
|
||||
{
|
||||
if (str_starts_with($key, 'user:')) {
|
||||
return implode(':', array_slice(explode(':', $key), 0, 2));
|
||||
}
|
||||
|
||||
return 'app';
|
||||
}
|
||||
|
||||
/**
|
||||
* For the given category, load the relevant settings from the database into the local cache.
|
||||
*/
|
||||
protected function loadToLocalCache(string $cacheCategory): void
|
||||
{
|
||||
$query = Setting::query();
|
||||
|
||||
if ($cacheCategory === 'app') {
|
||||
$query->where('setting_key', 'not like', 'user:%');
|
||||
} else {
|
||||
$query->where('setting_key', 'like', $cacheCategory . ':%');
|
||||
}
|
||||
$settings = $query->toBase()->get();
|
||||
|
||||
if (!isset($this->localCache[$cacheCategory])) {
|
||||
$this->localCache[$cacheCategory] = [];
|
||||
}
|
||||
|
||||
foreach ($settings as $setting) {
|
||||
$value = $setting->value;
|
||||
|
||||
if ($setting->type === 'array') {
|
||||
$value = json_decode($value, true) ?? [];
|
||||
}
|
||||
|
||||
$this->localCache[$cacheCategory][$setting->setting_key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a settings value.
|
||||
*/
|
||||
protected function formatValue($value, $default)
|
||||
protected function formatValue(mixed $value, mixed $default): mixed
|
||||
{
|
||||
// Change string booleans to actual booleans
|
||||
if ($value === 'true') {
|
||||
@@ -159,21 +165,22 @@ class SettingService
|
||||
* Add a setting to the database.
|
||||
* Values can be an array or a string.
|
||||
*/
|
||||
public function put(string $key, $value): bool
|
||||
public function put(string $key, mixed $value): bool
|
||||
{
|
||||
$setting = $this->setting->newQuery()->firstOrNew([
|
||||
$setting = Setting::query()->firstOrNew([
|
||||
'setting_key' => $key,
|
||||
]);
|
||||
|
||||
$setting->type = 'string';
|
||||
$setting->value = $value;
|
||||
|
||||
if (is_array($value)) {
|
||||
$setting->type = 'array';
|
||||
$value = $this->formatArrayValue($value);
|
||||
$setting->value = $this->formatArrayValue($value);
|
||||
}
|
||||
|
||||
$setting->value = $value;
|
||||
$setting->save();
|
||||
$this->clearFromCache($key);
|
||||
$this->putValueIntoLocalCache($key, $value);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -194,6 +201,8 @@ class SettingService
|
||||
|
||||
/**
|
||||
* Put a user-specific setting into the database.
|
||||
* Can only take string value types since this may use
|
||||
* the session which is less flexible to data types.
|
||||
*/
|
||||
public function putUser(User $user, string $key, string $value): bool
|
||||
{
|
||||
@@ -206,6 +215,16 @@ class SettingService
|
||||
return $this->put($this->userKey($user->id, $key), $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a user-specific setting into the database for the current access user.
|
||||
* Can only take string value types since this may use
|
||||
* the session which is less flexible to data types.
|
||||
*/
|
||||
public function putForCurrentUser(string $key, string $value): bool
|
||||
{
|
||||
return $this->putUser(user(), $key, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a setting key into a user-specific key.
|
||||
*/
|
||||
@@ -223,15 +242,19 @@ class SettingService
|
||||
if ($setting) {
|
||||
$setting->delete();
|
||||
}
|
||||
$this->clearFromCache($key);
|
||||
|
||||
$cacheCategory = $this->localCacheCategory($key);
|
||||
if (isset($this->localCache[$cacheCategory])) {
|
||||
unset($this->localCache[$cacheCategory][$key]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete settings for a given user id.
|
||||
*/
|
||||
public function deleteUserSettings(string $userId)
|
||||
public function deleteUserSettings(string $userId): void
|
||||
{
|
||||
return $this->setting->newQuery()
|
||||
Setting::query()
|
||||
->where('setting_key', 'like', $this->userKey($userId) . '%')
|
||||
->delete();
|
||||
}
|
||||
@@ -241,7 +264,16 @@ class SettingService
|
||||
*/
|
||||
protected function getSettingObjectByKey(string $key): ?Setting
|
||||
{
|
||||
return $this->setting->newQuery()
|
||||
->where('setting_key', '=', $key)->first();
|
||||
return Setting::query()
|
||||
->where('setting_key', '=', $key)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty the local setting value cache used by this service.
|
||||
*/
|
||||
public function flushCache(): void
|
||||
{
|
||||
$this->localCache = [];
|
||||
}
|
||||
}
|
||||
|
||||
82
app/Settings/UserShortcutMap.php
Normal file
82
app/Settings/UserShortcutMap.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Settings;
|
||||
|
||||
class UserShortcutMap
|
||||
{
|
||||
protected const DEFAULTS = [
|
||||
// Header actions
|
||||
"home_view" => "1",
|
||||
"shelves_view" => "2",
|
||||
"books_view" => "3",
|
||||
"settings_view" => "4",
|
||||
"favourites_view" => "5",
|
||||
"profile_view" => "6",
|
||||
"global_search" => "/",
|
||||
"logout" => "0",
|
||||
|
||||
// Common actions
|
||||
"edit" => "e",
|
||||
"new" => "n",
|
||||
"copy" => "c",
|
||||
"delete" => "d",
|
||||
"favourite" => "f",
|
||||
"export" => "x",
|
||||
"sort" => "s",
|
||||
"permissions" => "p",
|
||||
"move" => "m",
|
||||
"revisions" => "r",
|
||||
|
||||
// Navigation
|
||||
"next" => "ArrowRight",
|
||||
"previous" => "ArrowLeft",
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected array $mapping;
|
||||
|
||||
public function __construct(array $map)
|
||||
{
|
||||
$this->mapping = static::DEFAULTS;
|
||||
$this->merge($map);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the given map into the current shortcut mapping.
|
||||
*/
|
||||
protected function merge(array $map): void
|
||||
{
|
||||
foreach ($map as $key => $value) {
|
||||
if (is_string($value) && isset($this->mapping[$key])) {
|
||||
$this->mapping[$key] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the shortcut defined for the given ID.
|
||||
*/
|
||||
public function getShortcut(string $id): string
|
||||
{
|
||||
return $this->mapping[$id] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert this mapping to JSON.
|
||||
*/
|
||||
public function toJson(): string
|
||||
{
|
||||
return json_encode($this->mapping);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance from the current user's preferences.
|
||||
*/
|
||||
public static function fromUserPreferences(): self
|
||||
{
|
||||
$userKeyMap = setting()->getForCurrentUser('ui-shortcuts');
|
||||
return new self(json_decode($userKeyMap, true) ?: []);
|
||||
}
|
||||
}
|
||||
@@ -65,11 +65,24 @@ class ThemeEvents
|
||||
* Provides the commonmark library environment for customization before it's used to render markdown content.
|
||||
* If the listener returns a non-null value, that will be used as an environment instead.
|
||||
*
|
||||
* @param \League\CommonMark\ConfigurableEnvironmentInterface $environment
|
||||
* @returns \League\CommonMark\ConfigurableEnvironmentInterface|null
|
||||
* @param \League\CommonMark\Environment\Environment $environment
|
||||
* @returns \League\CommonMark\Environment\Environment|null
|
||||
*/
|
||||
const COMMONMARK_ENVIRONMENT_CONFIGURE = 'commonmark_environment_configure';
|
||||
|
||||
/**
|
||||
* OIDC ID token pre-validate event.
|
||||
* Runs just before BookStack validates the user ID token data upon login.
|
||||
* Provides the existing found set of claims for the user as a key-value array,
|
||||
* along with an array of the proceeding access token data provided by the identity platform.
|
||||
* If the listener returns a non-null value, that will replace the existing ID token claim data.
|
||||
*
|
||||
* @param array $idTokenData
|
||||
* @param array $accessTokenData
|
||||
* @returns array|null
|
||||
*/
|
||||
const OIDC_ID_TOKEN_PRE_VALIDATE = 'oidc_id_token_pre_validate';
|
||||
|
||||
/**
|
||||
* Page include parse event.
|
||||
* Runs when a page include tag is being parsed, typically when page content is being processed for viewing.
|
||||
|
||||
19
app/Translation/MessageSelector.php
Normal file
19
app/Translation/MessageSelector.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Translation;
|
||||
|
||||
use Illuminate\Translation\MessageSelector as BaseClass;
|
||||
|
||||
/**
|
||||
* This is a customization of the default Laravel MessageSelector class to tweak pluralization,
|
||||
* so that is uses just the first part of the locale string to provide support with
|
||||
* non-standard locales such as "de_informal".
|
||||
*/
|
||||
class MessageSelector extends BaseClass
|
||||
{
|
||||
public function getPluralIndex($locale, $number)
|
||||
{
|
||||
$locale = explode('_', $locale)[0];
|
||||
return parent::getPluralIndex($locale, $number);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
@@ -9,7 +10,9 @@ use BookStack\Entities\Models\Page;
|
||||
use BookStack\Model;
|
||||
use BookStack\Traits\HasCreatorAndUpdater;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
@@ -27,6 +30,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
class Attachment extends Model
|
||||
{
|
||||
use HasCreatorAndUpdater;
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'order'];
|
||||
protected $hidden = ['path', 'page'];
|
||||
@@ -36,12 +40,10 @@ class Attachment extends Model
|
||||
|
||||
/**
|
||||
* Get the downloadable file name for this upload.
|
||||
*
|
||||
* @return mixed|string
|
||||
*/
|
||||
public function getFileName()
|
||||
public function getFileName(): string
|
||||
{
|
||||
if (strpos($this->name, '.') !== false) {
|
||||
if (str_contains($this->name, '.')) {
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
@@ -56,12 +58,18 @@ class Attachment extends Model
|
||||
return $this->belongsTo(Page::class, 'uploaded_to');
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'uploaded_to')
|
||||
->where('joint_permissions.entity_type', '=', 'page');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the url of this file.
|
||||
*/
|
||||
public function getUrl($openInline = false): string
|
||||
{
|
||||
if ($this->external && strpos($this->path, 'http') !== 0) {
|
||||
if ($this->external && !str_starts_with($this->path, 'http')) {
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ use Illuminate\Contracts\Filesystem\Filesystem as Storage;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Flysystem\Util;
|
||||
use League\Flysystem\WhitespacePathNormalizer;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class AttachmentService
|
||||
@@ -54,7 +54,7 @@ class AttachmentService
|
||||
*/
|
||||
protected function adjustPathForStorageDisk(string $path): string
|
||||
{
|
||||
$path = Util::normalizePath(str_replace('uploads/files/', '', $path));
|
||||
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path));
|
||||
|
||||
if ($this->getStorageDiskName() === 'local_secure_attachments') {
|
||||
return $path;
|
||||
|
||||
110
app/Uploads/FaviconHandler.php
Normal file
110
app/Uploads/FaviconHandler.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Intervention\Image\ImageManager;
|
||||
|
||||
class FaviconHandler
|
||||
{
|
||||
protected string $path;
|
||||
|
||||
public function __construct(
|
||||
protected ImageManager $imageTool
|
||||
) {
|
||||
$this->path = public_path('favicon.ico');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the given UploadedFile instance as the application favicon.
|
||||
*/
|
||||
public function saveForUploadedImage(UploadedFile $file): void
|
||||
{
|
||||
if (!is_writeable($this->path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$imageData = file_get_contents($file->getRealPath());
|
||||
$image = $this->imageTool->make($imageData);
|
||||
$image->resize(32, 32);
|
||||
$bmpData = $image->encode('png');
|
||||
$icoData = $this->pngToIco($bmpData, 32, 32);
|
||||
|
||||
file_put_contents($this->path, $icoData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the original favicon image.
|
||||
* Returned boolean indicates if the copy occurred.
|
||||
*/
|
||||
public function restoreOriginal(): bool
|
||||
{
|
||||
$permissionItem = file_exists($this->path) ? $this->path : dirname($this->path);
|
||||
if (!is_writeable($permissionItem)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return copy($this->getOriginalPath(), $this->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the original favicon image if no favicon image is already in use.
|
||||
* Returns a boolean to indicate if the file exists.
|
||||
*/
|
||||
public function restoreOriginalIfNotExists(): bool
|
||||
{
|
||||
if (file_exists($this->path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->restoreOriginal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the favicon file.
|
||||
*/
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path of the original favicon copy.
|
||||
*/
|
||||
public function getOriginalPath(): string
|
||||
{
|
||||
return public_path('icon.ico');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PNG image data to ICO file format.
|
||||
* Built following the file format info from Wikipedia:
|
||||
* https://en.wikipedia.org/wiki/ICO_(file_format)
|
||||
*/
|
||||
protected function pngToIco(string $bmpData, int $width, int $height): string
|
||||
{
|
||||
// ICO header
|
||||
$header = pack('v', 0x00); // Reserved. Must always be 0
|
||||
$header .= pack('v', 0x01); // Specifies ico image
|
||||
$header .= pack('v', 0x01); // Specifies number of images
|
||||
|
||||
// ICO Image Directory
|
||||
$entry = hex2bin(dechex($width)); // Image width
|
||||
$entry .= hex2bin(dechex($height)); // Image height
|
||||
$entry .= "\0"; // Color palette, typically 0
|
||||
$entry .= "\0"; // Reserved
|
||||
|
||||
// Color planes, Appears to remain 1 for bmp image data
|
||||
$entry .= pack('v', 0x01);
|
||||
// Bits per pixel, can range from 1 to 32. From testing conversion
|
||||
// via intervention from png typically provides this as 24.
|
||||
$entry .= pack('v', 0x00);
|
||||
// Size of the image data in bytes
|
||||
$entry .= pack('V', strlen($bmpData));
|
||||
// Offset of the bmp data from file start
|
||||
$entry .= pack('V', strlen($header) + strlen($entry) + 4);
|
||||
|
||||
// Join & return the combined parts of the ICO image data
|
||||
return $header . $entry . $bmpData;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user