mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-05 16:49:47 +03:00
Compare commits
621 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebd6e4d3a2 | ||
|
|
80374aea5c | ||
|
|
aec772c5eb | ||
|
|
2e4d29e062 | ||
|
|
dce6a82954 | ||
|
|
050d69ea27 | ||
|
|
0cc68b7665 | ||
|
|
75d6b56072 | ||
|
|
ac27b5aebb | ||
|
|
ecbc7344fc | ||
|
|
8a749c6acf | ||
|
|
2ac9efae7d | ||
|
|
a11d565ba4 | ||
|
|
d0dc5e5c5d | ||
|
|
e4642257a6 | ||
|
|
f7418d0600 | ||
|
|
98aed794cc | ||
|
|
623ccd4cfa | ||
|
|
d8672944a5 | ||
|
|
6955b2fd5a | ||
|
|
24f82749ff | ||
|
|
b9941e8e61 | ||
|
|
7101ce3050 | ||
|
|
fbef0d06f2 | ||
|
|
b698bb0e07 | ||
|
|
2d7552aa09 | ||
|
|
ee1e936660 | ||
|
|
50214d5fe6 | ||
|
|
2fe261e207 | ||
|
|
9158a66bff | ||
|
|
7f8b3eff5a | ||
|
|
5736919836 | ||
|
|
c76b5e2ec4 | ||
|
|
092b6d6378 | ||
|
|
f88330202b | ||
|
|
f28ed0ef0b | ||
|
|
27ac122502 | ||
|
|
9da3130a12 | ||
|
|
1afc915aed | ||
|
|
34c63e1c30 | ||
|
|
f092c97748 | ||
|
|
9153be963d | ||
|
|
1cc7c649dc | ||
|
|
e537d0c4e8 | ||
|
|
961e418cb7 | ||
|
|
6edf2c155d | ||
|
|
401c156687 | ||
|
|
760eff397f | ||
|
|
d134639eca | ||
|
|
b86ee6d252 | ||
|
|
0dbf08453f | ||
|
|
26ccb7b644 | ||
|
|
f634b4ea57 | ||
|
|
d198332d3c | ||
|
|
d5465726e2 | ||
|
|
bbe504c559 | ||
|
|
3290ab3ac9 | ||
|
|
5d29d0cc7b | ||
|
|
344b3a3615 | ||
|
|
837fd74bf6 | ||
|
|
2b06e86d53 | ||
|
|
9041e25476 | ||
|
|
1fdf854ea7 | ||
|
|
e9c9792cb9 | ||
|
|
d6235bcf92 | ||
|
|
6a3f4f5e79 | ||
|
|
7b100ef361 | ||
|
|
443415ea0d | ||
|
|
e02bd5e57e | ||
|
|
5f7cd735ea | ||
|
|
89ff0d43bb | ||
|
|
375abca1ee | ||
|
|
031c67ba58 | ||
|
|
764489e30b | ||
|
|
16eedc8264 | ||
|
|
5ae524c25a | ||
|
|
0d7287fc8b | ||
|
|
219da9da9b | ||
|
|
38ce54ea0c | ||
|
|
97ec560282 | ||
|
|
06b5a83d8f | ||
|
|
45dc28ba2a | ||
|
|
6e0a7344fa | ||
|
|
7fa934e7f2 | ||
|
|
a90446796a | ||
|
|
4209f27f1a | ||
|
|
89ec9a5081 | ||
|
|
b987bea37a | ||
|
|
e77c96f6b7 | ||
|
|
9b8a10dd3a | ||
|
|
42f4c9afae | ||
|
|
8d6071cb84 | ||
|
|
49200ca5ce | ||
|
|
34aa4dbf10 | ||
|
|
a21d09fed7 | ||
|
|
50bc2e49c1 | ||
|
|
8776113210 | ||
|
|
397a36cfd0 | ||
|
|
ee24635e06 | ||
|
|
7c8368cc63 | ||
|
|
f93e380d19 | ||
|
|
0bb5654f80 | ||
|
|
89324bf9cc | ||
|
|
9abb207e4d | ||
|
|
8aad8e4a24 | ||
|
|
8681c5f613 | ||
|
|
944ac2e6eb | ||
|
|
75759fb735 | ||
|
|
f47c9a53aa | ||
|
|
480d591acf | ||
|
|
ae40ec10a4 | ||
|
|
180927cdb9 | ||
|
|
f37e7186d9 | ||
|
|
bb7bd903ef | ||
|
|
6c767cd205 | ||
|
|
99aa093e2b | ||
|
|
42b576df55 | ||
|
|
3fbe304cfc | ||
|
|
e0d4a43e1e | ||
|
|
ab6a3144ec | ||
|
|
7a2f54b890 | ||
|
|
8f28bb9e3c | ||
|
|
cb12f76f46 | ||
|
|
3f1b376b2b | ||
|
|
147f038806 | ||
|
|
812675dfc2 | ||
|
|
3b2fb67d78 | ||
|
|
ae91831ba6 | ||
|
|
fcfafbdac5 | ||
|
|
705f81561a | ||
|
|
1c70684a99 | ||
|
|
32e305ef4f | ||
|
|
8c70a69fff | ||
|
|
f0eb4df1e9 | ||
|
|
852f4e61a5 | ||
|
|
d68ee461e0 | ||
|
|
98ce7a0675 | ||
|
|
e6e1b9423d | ||
|
|
b3c93a2188 | ||
|
|
59dbc0b9f1 | ||
|
|
7a43b6d5b7 | ||
|
|
fb8f92e835 | ||
|
|
0d36e3fecf | ||
|
|
b878ccc361 | ||
|
|
2bab892dce | ||
|
|
4fa73be80e | ||
|
|
bd14dc067b | ||
|
|
d4a119b2aa | ||
|
|
2ec8a33927 | ||
|
|
fee3022ad8 | ||
|
|
050ae01f94 | ||
|
|
8e5f7c6425 | ||
|
|
7fdc7c68b9 | ||
|
|
017c7659e5 | ||
|
|
a08ea54615 | ||
|
|
0df5ae0658 | ||
|
|
3fa43c804b | ||
|
|
ebc5a53410 | ||
|
|
62500a9bfa | ||
|
|
a5153ff5af | ||
|
|
3734b0a37e | ||
|
|
4d72ac16a3 | ||
|
|
29404f7e38 | ||
|
|
a7252301c1 | ||
|
|
0825dd17cf | ||
|
|
9dd51c7cff | ||
|
|
854d2fe2dc | ||
|
|
27848cea75 | ||
|
|
d1d999a98a | ||
|
|
5a6e171a7e | ||
|
|
90ffa46331 | ||
|
|
957214b84b | ||
|
|
a2bda11787 | ||
|
|
56204963e7 | ||
|
|
ee579115b6 | ||
|
|
d431141918 | ||
|
|
4ff6b7fc51 | ||
|
|
5d42f36a2b | ||
|
|
fb3491092e | ||
|
|
812c65fa3c | ||
|
|
9b48ee90f0 | ||
|
|
6ad6bcaf82 | ||
|
|
f4ef85d587 | ||
|
|
1c57223a2d | ||
|
|
a1a900035b | ||
|
|
bc8d4c40da | ||
|
|
cb91bd4933 | ||
|
|
f336ce9119 | ||
|
|
f9e040658d | ||
|
|
d1b9d62e40 | ||
|
|
7e70c14a16 | ||
|
|
1b7a1e847e | ||
|
|
011178c302 | ||
|
|
e27b53fc6c | ||
|
|
260c9d528f | ||
|
|
14b5c39e71 | ||
|
|
4c1256f02a | ||
|
|
a7f7200478 | ||
|
|
f2088d3a56 | ||
|
|
affd8df594 | ||
|
|
bc40601d7d | ||
|
|
da6169159d | ||
|
|
b0adb74d62 | ||
|
|
f004cb69d7 | ||
|
|
975ba4f8d8 | ||
|
|
468040edc4 | ||
|
|
840a1ea011 | ||
|
|
72c8b138e1 | ||
|
|
cf73e5f2c6 | ||
|
|
4e8995c3d0 | ||
|
|
67d12cc1df | ||
|
|
7931ab1b91 | ||
|
|
137beb4002 | ||
|
|
2354ce49ba | ||
|
|
d0925e0e91 | ||
|
|
69473d28f3 | ||
|
|
11cf9fd832 | ||
|
|
c89865b574 | ||
|
|
8a1fb300fe | ||
|
|
8c4ed9e0b7 | ||
|
|
c4f3a71652 | ||
|
|
c5259d0195 | ||
|
|
3899b44622 | ||
|
|
58057855f8 | ||
|
|
4cdaa1ad99 | ||
|
|
c737fa8a6b | ||
|
|
c5a0b99d20 | ||
|
|
554d706468 | ||
|
|
4591583deb | ||
|
|
05f9f8f969 | ||
|
|
1f7f26bd29 | ||
|
|
1e028f51eb | ||
|
|
7389b33980 | ||
|
|
70fccfd8d3 | ||
|
|
9ccee6707d | ||
|
|
2de804950b | ||
|
|
e4e130a5da | ||
|
|
3101d76726 | ||
|
|
694da007b6 | ||
|
|
ea2aa626a9 | ||
|
|
9ab485093e | ||
|
|
93d9c77595 | ||
|
|
71e760e345 | ||
|
|
34d15230dc | ||
|
|
af5517ad59 | ||
|
|
002b093e82 | ||
|
|
2e9000b18c | ||
|
|
b24f5b7392 | ||
|
|
3caf308f6b | ||
|
|
e01baac15d | ||
|
|
f573e09004 | ||
|
|
f4dd38ea94 | ||
|
|
aad22384cb | ||
|
|
8176ca153a | ||
|
|
f86bb27a83 | ||
|
|
a9ee2e6889 | ||
|
|
d9f0c9eee8 | ||
|
|
7b508dac3d | ||
|
|
3ca64da4a5 | ||
|
|
6a6f00058f | ||
|
|
cd929b2555 | ||
|
|
400e584911 | ||
|
|
9c90e798df | ||
|
|
c519f707e8 | ||
|
|
e024b03a61 | ||
|
|
d9e9c1735a | ||
|
|
56da25b07a | ||
|
|
24f4febcd5 | ||
|
|
5f5b6ff0be | ||
|
|
8f9923c7c1 | ||
|
|
7be7caacd5 | ||
|
|
bcd06c1d56 | ||
|
|
e01a0e61d9 | ||
|
|
f0049e346b | ||
|
|
b7f84171c6 | ||
|
|
cb5b4392f4 | ||
|
|
4eb76699a9 | ||
|
|
a48a1d80ae | ||
|
|
b3b8da0fe7 | ||
|
|
a2440e20bc | ||
|
|
f3f72fde6b | ||
|
|
9023487d99 | ||
|
|
d77c0d3ddd | ||
|
|
cffa0a0cf3 | ||
|
|
7b4e36eb38 | ||
|
|
f3eb7c4208 | ||
|
|
2fcb0b6db2 | ||
|
|
ea0ac2a853 | ||
|
|
76049f0cdb | ||
|
|
0143fe88d3 | ||
|
|
0b89642610 | ||
|
|
bc8d2d8209 | ||
|
|
f832a9545e | ||
|
|
aad2f54c15 | ||
|
|
0cd44a6e7d | ||
|
|
79e386f457 | ||
|
|
387b6620e4 | ||
|
|
d76bdbc976 | ||
|
|
89de7a60c6 | ||
|
|
42c6179350 | ||
|
|
01c2c92710 | ||
|
|
4b770ee2dc | ||
|
|
c47997bbb7 | ||
|
|
9d78af2c1d | ||
|
|
a8d933753e | ||
|
|
2853feb9c4 | ||
|
|
86e7386db9 | ||
|
|
ce9a788fb9 | ||
|
|
6b8083244d | ||
|
|
94bf7e2e0c | ||
|
|
9cf05944f6 | ||
|
|
e6e6d25974 | ||
|
|
8f90996cef | ||
|
|
2332401854 | ||
|
|
afe1a04239 | ||
|
|
f459a68535 | ||
|
|
1d875ccfb7 | ||
|
|
2b4b7c68cc | ||
|
|
ad8d8dde2d | ||
|
|
23f9b4d217 | ||
|
|
bfbd0fc168 | ||
|
|
77b57c068f | ||
|
|
40d939394b | ||
|
|
7e04f70bf3 | ||
|
|
4fb85a9a5c | ||
|
|
55dc86037f | ||
|
|
2989852520 | ||
|
|
4daac5a114 | ||
|
|
82baab66cc | ||
|
|
b0a4d3d059 | ||
|
|
943cb7810b | ||
|
|
2d4f708c79 | ||
|
|
376640db25 | ||
|
|
9cfded1311 | ||
|
|
dde2ea743f | ||
|
|
ddd45dde6b | ||
|
|
a99cbcfe12 | ||
|
|
c5e9dfa168 | ||
|
|
83d2a3c763 | ||
|
|
f3d7d06536 | ||
|
|
bd1971c827 | ||
|
|
61b3bc10a3 | ||
|
|
1a224e1719 | ||
|
|
e89348b02a | ||
|
|
451300606f | ||
|
|
459659a680 | ||
|
|
4487ea576f | ||
|
|
0c8bd581ae | ||
|
|
f9f4a87e1b | ||
|
|
f4fda8d80c | ||
|
|
6d66682620 | ||
|
|
ab52f3367a | ||
|
|
5ee79d16c9 | ||
|
|
a1ea4006e0 | ||
|
|
a721405202 | ||
|
|
d20aacb732 | ||
|
|
65fa96e405 | ||
|
|
736d6afb7d | ||
|
|
0bcd1795cb | ||
|
|
47887ec595 | ||
|
|
9078188939 | ||
|
|
ed0aad1a7a | ||
|
|
43749cd94e | ||
|
|
107df6c28f | ||
|
|
c1d1ec5b89 | ||
|
|
12c282597d | ||
|
|
c9d0e22132 | ||
|
|
0801955a26 | ||
|
|
3ed1ffdbeb | ||
|
|
5c59cfb020 | ||
|
|
3ca15ad68a | ||
|
|
36f0a68f1b | ||
|
|
ed981cbab1 | ||
|
|
f69af8933c | ||
|
|
46d71a181e | ||
|
|
8d8da31fdd | ||
|
|
0d9b5a9d90 | ||
|
|
8b211ed461 | ||
|
|
9dd69b04b8 | ||
|
|
0c6f598d91 | ||
|
|
df94b73e29 | ||
|
|
7d4b941abf | ||
|
|
d181106df3 | ||
|
|
75110813e6 | ||
|
|
1e41546e51 | ||
|
|
f39b565a1c | ||
|
|
77cd550fae | ||
|
|
96d9077479 | ||
|
|
be1d691529 | ||
|
|
8cde362f6f | ||
|
|
388343aeb0 | ||
|
|
ba25dda031 | ||
|
|
85f59b5275 | ||
|
|
65d4505079 | ||
|
|
663f81a2b1 | ||
|
|
f145ffc930 | ||
|
|
19d7e26dda | ||
|
|
a13b9d8d14 | ||
|
|
8c67011a1d | ||
|
|
8da856bac3 | ||
|
|
90ec40691a | ||
|
|
d676e1e824 | ||
|
|
0a05119aa5 | ||
|
|
abc283fc64 | ||
|
|
e72ade727d | ||
|
|
c8b123bfac | ||
|
|
88012449f3 | ||
|
|
e00d88f45d | ||
|
|
3fe666f36a | ||
|
|
3f271ebecb | ||
|
|
7c597a05f6 | ||
|
|
16e023985d | ||
|
|
43cbab2822 | ||
|
|
1a3505c899 | ||
|
|
2930025f51 | ||
|
|
39fcf3a68f | ||
|
|
6ce34fe6cc | ||
|
|
3c3aed58aa | ||
|
|
73f36b279e | ||
|
|
2b817e7d24 | ||
|
|
cb10ad804f | ||
|
|
eeccc2ef10 | ||
|
|
b030c1398b | ||
|
|
4759fa1e1f | ||
|
|
cb1c2db282 | ||
|
|
4866a3a198 | ||
|
|
340c9ec7a1 | ||
|
|
49498cfaf9 | ||
|
|
3a4aa81115 | ||
|
|
d20c74babf | ||
|
|
9fda0df798 | ||
|
|
6fa699a835 | ||
|
|
78920d7d65 | ||
|
|
35a47a273b | ||
|
|
89dfa43e73 | ||
|
|
2c74dfd1d4 | ||
|
|
e6864a9cff | ||
|
|
60e319c4b4 | ||
|
|
24b31b624c | ||
|
|
a0fe6147d8 | ||
|
|
221d910ff2 | ||
|
|
bef2045df1 | ||
|
|
f021823287 | ||
|
|
60014989f5 | ||
|
|
57b10f195e | ||
|
|
3a8a476906 | ||
|
|
328bc88f02 | ||
|
|
2a99e23e6d | ||
|
|
b855bbaaea | ||
|
|
96436839f1 | ||
|
|
b4f29a85ab | ||
|
|
4a2a044f3d | ||
|
|
ca09ed916f | ||
|
|
dbefda055f | ||
|
|
b1e95eb39f | ||
|
|
b3da77b8f9 | ||
|
|
93ef8c97b6 | ||
|
|
420b29f32f | ||
|
|
d795af04df | ||
|
|
d2ed98d20d | ||
|
|
ebc69a8f2c | ||
|
|
d5ce6b680c | ||
|
|
1a345b74bb | ||
|
|
8ffc3a4abf | ||
|
|
44013721f0 | ||
|
|
16222de5fa | ||
|
|
ebfe946160 | ||
|
|
5d2aad6a9e | ||
|
|
8fb016d1bf | ||
|
|
c216a6a210 | ||
|
|
26af9acc6c | ||
|
|
c8a7acb6c7 | ||
|
|
d3b39fbe50 | ||
|
|
ac7b2dd1bf | ||
|
|
f1a8ad4980 | ||
|
|
d5b7fff102 | ||
|
|
0930e8519c | ||
|
|
ff8dadefee | ||
|
|
2b0ae23da0 | ||
|
|
63cb6015a8 | ||
|
|
5a7fb20116 | ||
|
|
829f808800 | ||
|
|
0dfe5cb66b | ||
|
|
14bccae6bd | ||
|
|
b97c150ac8 | ||
|
|
0c5723d76e | ||
|
|
bec61a56c0 | ||
|
|
1b46aa8756 | ||
|
|
f14e6e8f2d | ||
|
|
0003ce61cd | ||
|
|
d76bbb2954 | ||
|
|
478067483f | ||
|
|
eff539f89b | ||
|
|
214992650d | ||
|
|
492ffff0a4 | ||
|
|
956eb1308f | ||
|
|
0cc215f8c3 | ||
|
|
e8e38f1f7b | ||
|
|
7dc80a9e14 | ||
|
|
e49afdbd72 | ||
|
|
56254bdb66 | ||
|
|
25654b2322 | ||
|
|
27339079f7 | ||
|
|
55e52e45fb | ||
|
|
c979e6465e | ||
|
|
c30a9d3564 | ||
|
|
59d1fb2d10 | ||
|
|
08a8c0070e | ||
|
|
cb770c534d | ||
|
|
6749faa89a | ||
|
|
82e8b1577e | ||
|
|
4dce03c0d3 | ||
|
|
7233c1c7b2 | ||
|
|
1309a01131 | ||
|
|
affae2e3c4 | ||
|
|
1a90b98b8f | ||
|
|
da4308bb0f | ||
|
|
0333185b6d | ||
|
|
83f89f64e8 | ||
|
|
135022136a | ||
|
|
12f96bb1a4 | ||
|
|
678314a0c5 | ||
|
|
0887c39694 | ||
|
|
078e8e7dc3 | ||
|
|
038015f852 | ||
|
|
7c12920dc8 | ||
|
|
895f656897 | ||
|
|
31dbf132b9 | ||
|
|
b5281bc9ca | ||
|
|
3625f12abe | ||
|
|
55d61fceb2 | ||
|
|
2325a307a5 | ||
|
|
d2b49084b0 | ||
|
|
8594f42584 | ||
|
|
dd7463259a | ||
|
|
d23b24b8db | ||
|
|
1c859e94e0 | ||
|
|
981807220c | ||
|
|
a2231c3604 | ||
|
|
622adc5450 | ||
|
|
95e496d16f | ||
|
|
883e18f7c4 | ||
|
|
c5aad29c72 | ||
|
|
ea62fe6004 | ||
|
|
5ae9ed1e22 | ||
|
|
b6be8a2bb9 | ||
|
|
65dd7ad1e9 | ||
|
|
f991948c49 | ||
|
|
ee6a2339b6 | ||
|
|
fd26f54b99 | ||
|
|
11a1a6fb16 | ||
|
|
882c609296 | ||
|
|
77ad819970 | ||
|
|
2835e5be93 | ||
|
|
856fca8289 | ||
|
|
48d0095aa2 | ||
|
|
176a0dcd59 | ||
|
|
94b0f70bfa | ||
|
|
36d7ff77a9 | ||
|
|
fb16ac326f | ||
|
|
5947f59a04 | ||
|
|
1843d80fb7 | ||
|
|
6252b46395 | ||
|
|
20ecaa5c5a | ||
|
|
08b2a77d41 | ||
|
|
3e8e9a23cf | ||
|
|
1253711c7d | ||
|
|
963d8f4693 | ||
|
|
0de4d6d223 | ||
|
|
06f694bad2 | ||
|
|
58b83b64c8 | ||
|
|
dfe4cde6ee | ||
|
|
41689a1e65 | ||
|
|
2ae8026903 | ||
|
|
dcb36b27a0 | ||
|
|
83082c32ef | ||
|
|
1e112f78d8 | ||
|
|
9283f28e31 | ||
|
|
7f5fc9fbe3 | ||
|
|
ce566bea2a | ||
|
|
63ce3c9add | ||
|
|
f0470afb4c | ||
|
|
f8e6172582 | ||
|
|
7a8505f812 | ||
|
|
9806907d53 | ||
|
|
2b3726702d | ||
|
|
2b46b00f29 | ||
|
|
536ad14276 | ||
|
|
a318775cfc | ||
|
|
9e0b8a9fb6 | ||
|
|
7c692ec588 | ||
|
|
da0dc7292c | ||
|
|
045710ea08 | ||
|
|
c6ad16dba6 | ||
|
|
4ea1f0c633 | ||
|
|
f5077c17f4 | ||
|
|
c73773930e | ||
|
|
1782618c64 | ||
|
|
a01bb92989 | ||
|
|
a2bcf765a8 | ||
|
|
130dc05517 | ||
|
|
572d8b3700 | ||
|
|
e0d9380055 | ||
|
|
15647a0409 | ||
|
|
e88dbe4db3 | ||
|
|
84c501bcf4 | ||
|
|
c8b6f622f4 | ||
|
|
ef211a76ae | ||
|
|
921131f999 | ||
|
|
0cde2704d0 | ||
|
|
db4093d523 | ||
|
|
e33b587b87 | ||
|
|
c8be6ee8a6 | ||
|
|
46e6e239dc | ||
|
|
eb653bda16 | ||
|
|
9e1c8ec82a | ||
|
|
2cd7a48044 | ||
|
|
d089623aac | ||
|
|
8d7febe482 | ||
|
|
4cbd1a9eb5 | ||
|
|
07626669da |
@@ -42,7 +42,7 @@ APP_TIMEZONE=UTC
|
||||
# overrides can be made. Defaults to disabled.
|
||||
APP_THEME=false
|
||||
|
||||
# Trusted Proxies
|
||||
# Trusted proxies
|
||||
# Used to indicate trust of systems that proxy to the application so
|
||||
# certain header values (Such as "X-Forwarded-For") can be used from the
|
||||
# incoming proxy request to provide origin detail.
|
||||
@@ -58,6 +58,13 @@ DB_DATABASE=database_database
|
||||
DB_USERNAME=database_username
|
||||
DB_PASSWORD=database_user_password
|
||||
|
||||
# MySQL specific connection options
|
||||
# Path to Certificate Authority (CA) certificate file for your MySQL instance.
|
||||
# When this option is used host name identity verification will be performed
|
||||
# which checks the hostname, used by the client, against names within the
|
||||
# certificate itself (Common Name or Subject Alternative Name).
|
||||
MYSQL_ATTR_SSL_CA="/path/to/ca.pem"
|
||||
|
||||
# Mail system to use
|
||||
# Can be 'smtp' or 'sendmail'
|
||||
MAIL_DRIVER=smtp
|
||||
@@ -136,6 +143,10 @@ STORAGE_URL=false
|
||||
# Can be 'standard', 'ldap', 'saml2' or 'oidc'
|
||||
AUTH_METHOD=standard
|
||||
|
||||
# Automatically initiate login via external auth system if it's the only auth method.
|
||||
# Works with saml2 or oidc auth methods.
|
||||
AUTH_AUTO_INITIATE=false
|
||||
|
||||
# Social authentication configuration
|
||||
# All disabled by default.
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/third-party-auth/
|
||||
@@ -216,6 +227,7 @@ LDAP_DUMP_USER_DETAILS=false
|
||||
LDAP_USER_TO_GROUPS=false
|
||||
LDAP_GROUP_ATTRIBUTE="memberOf"
|
||||
LDAP_REMOVE_FROM_GROUPS=false
|
||||
LDAP_DUMP_USER_GROUPS=false
|
||||
|
||||
# SAML authentication configuration
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
|
||||
@@ -251,7 +263,11 @@ OIDC_ISSUER_DISCOVER=false
|
||||
OIDC_PUBLIC_KEY=null
|
||||
OIDC_AUTH_ENDPOINT=null
|
||||
OIDC_TOKEN_ENDPOINT=null
|
||||
OIDC_ADDITIONAL_SCOPES=null
|
||||
OIDC_DUMP_USER_DETAILS=false
|
||||
OIDC_USER_TO_GROUPS=false
|
||||
OIDC_GROUPS_CLAIM=groups
|
||||
OIDC_REMOVE_FROM_GROUPS=false
|
||||
|
||||
# Disable default third-party services such as Gravatar and Draw.IO
|
||||
# Service-specific options will override this option
|
||||
@@ -266,7 +282,7 @@ AVATAR_URL=
|
||||
# Enable diagrams.net integration
|
||||
# Can simply be true/false to enable/disable the integration.
|
||||
# Alternatively, It can be URL to the diagrams.net instance you want to use.
|
||||
# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1
|
||||
# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1&configure=1
|
||||
DRAWIO=true
|
||||
|
||||
# Default item listing view
|
||||
@@ -283,7 +299,7 @@ APP_DEFAULT_DARK_MODE=false
|
||||
# Page revision limit
|
||||
# Number of page revisions to keep in the system before deleting old revisions.
|
||||
# If set to 'false' a limit will not be enforced.
|
||||
REVISION_LIMIT=50
|
||||
REVISION_LIMIT=100
|
||||
|
||||
# Recycle Bin Lifetime
|
||||
# The number of days that content will remain in the recycle bin before
|
||||
@@ -324,6 +340,13 @@ ALLOW_UNTRUSTED_SERVER_FETCHING=false
|
||||
# Setting this option will also auto-adjust cookies to be SameSite=None.
|
||||
ALLOWED_IFRAME_HOSTS=null
|
||||
|
||||
# A list of sources/hostnames that can be loaded within iframes within BookStack.
|
||||
# Space separated if multiple. BookStack host domain is auto-inferred.
|
||||
# Can be set to a lone "*" to allow all sources for iframe content (Not advised).
|
||||
# Defaults to a set of common services.
|
||||
# Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
|
||||
ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com"
|
||||
|
||||
# The default and maximum item-counts for listing API requests.
|
||||
API_DEFAULT_ITEM_COUNT=100
|
||||
API_MAX_ITEM_COUNT=500
|
||||
@@ -338,3 +361,11 @@ API_REQUESTS_PER_MIN=180
|
||||
# user identifier (Username or email).
|
||||
LOG_FAILED_LOGIN_MESSAGE=false
|
||||
LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver
|
||||
|
||||
# Alter the precision of IP addresses stored by BookStack.
|
||||
# Should be a number between 0 and 4, where 4 retains the full IP address
|
||||
# and 0 completely hides the IP address. As an example, a value of 2 for the
|
||||
# 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
|
||||
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1,3 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [ssddanbrown]
|
||||
ko_fi: ssddanbrown
|
||||
1
.github/ISSUE_TEMPLATE/api_request.yml
vendored
1
.github/ISSUE_TEMPLATE/api_request.yml
vendored
@@ -1,6 +1,5 @@
|
||||
name: New API Endpoint or API Ability
|
||||
description: Request a new endpoint or API feature be added
|
||||
title: "[API Request]: "
|
||||
labels: [":nut_and_bolt: API Request"]
|
||||
body:
|
||||
- type: textarea
|
||||
|
||||
10
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
10
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,6 +1,5 @@
|
||||
name: Bug Report
|
||||
description: Create a report to help us improve or fix things
|
||||
title: "[Bug Report]: "
|
||||
labels: [":bug: Bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
@@ -36,6 +35,15 @@ body:
|
||||
description: Provide any additional context and screenshots here to help us solve this issue
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: browserdetails
|
||||
attributes:
|
||||
label: Browser Details
|
||||
description: |
|
||||
If this is an issue that occurs when using the BookStack interface, please provide details of the browser used which presents the reported issue.
|
||||
placeholder: (eg. Firefox 97 (64-bit) on Windows 11)
|
||||
validations:
|
||||
required: false
|
||||
- type: input
|
||||
id: bsversion
|
||||
attributes:
|
||||
|
||||
10
.github/ISSUE_TEMPLATE/config.yml
vendored
10
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,9 +1,13 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord chat support
|
||||
- name: Discord Chat Support
|
||||
url: https://discord.gg/ztkBqR2
|
||||
about: Realtime support / chat with the community and the team.
|
||||
about: Realtime support & chat with the BookStack community and the team.
|
||||
|
||||
- name: Debugging & Common Issues
|
||||
url: https://www.bookstackapp.com/docs/admin/debugging/
|
||||
about: Find details on how to debug issues and view common issues with thier resolutions.
|
||||
about: Find details on how to debug issues and view common issues with their resolutions.
|
||||
|
||||
- name: Official Support Plans
|
||||
url: https://www.bookstackapp.com/support/
|
||||
about: View our official support plans that offer assured support for business.
|
||||
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,6 +1,5 @@
|
||||
name: Feature Request
|
||||
description: Request a new language to be added to CrowdIn for you to translate
|
||||
title: "[Feature Request]: "
|
||||
description: Request a new feature or idea to be added to BookStack
|
||||
labels: [":hammer: Feature Request"]
|
||||
body:
|
||||
- type: textarea
|
||||
@@ -13,8 +12,41 @@ body:
|
||||
- type: textarea
|
||||
id: benefits
|
||||
attributes:
|
||||
label: Describe the benefits this feature would bring to BookStack users
|
||||
description: Explain the measurable benefits this feature would achieve for existing BookStack users
|
||||
label: Describe the benefits this would bring to existing BookStack users
|
||||
description: |
|
||||
Explain the measurable benefits this feature would achieve for existing BookStack users.
|
||||
These benefits should details outcomes in terms of what this request solves/achieves, and should not be specific to implementation.
|
||||
This helps us understand the core desired goal so that a variety of potential implementations could be explored.
|
||||
This field is important. Lack if input here may lead to early issue closure.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: already_achieved
|
||||
attributes:
|
||||
label: Can the goal of this request already be achieved via other means?
|
||||
description: |
|
||||
Yes/No. If yes, please describe how the requested approach fits in with the existing method.
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: confirm-search
|
||||
attributes:
|
||||
label: Have you searched for an existing open/closed issue?
|
||||
description: |
|
||||
To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue) for any existing issues that cover the fundemental benefit/goal of your request.
|
||||
options:
|
||||
- label: I have searched for existing issues and none cover my fundemental request
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: existing_usage
|
||||
attributes:
|
||||
label: How long have you been using BookStack?
|
||||
options:
|
||||
- Not using yet, just scoping
|
||||
- 0 to 6 months
|
||||
- 6 months to 1 year
|
||||
- 1 to 5 years
|
||||
- Over 5 years
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/language_request.yml
vendored
5
.github/ISSUE_TEMPLATE/language_request.yml
vendored
@@ -1,6 +1,5 @@
|
||||
name: Language Request
|
||||
description: Request a new language to be added to CrowdIn for you to translate
|
||||
title: "[Language Request]: "
|
||||
description: Request a new language to be added to Crowdin for you to translate
|
||||
labels: [":earth_africa: Translations"]
|
||||
assignees:
|
||||
- ssddanbrown
|
||||
@@ -24,7 +23,7 @@ body:
|
||||
This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack).
|
||||
Please don't use this template to request a new language that you are not prepared to provide translations for.
|
||||
options:
|
||||
- label: I confirm I'm offering to help translate for this new language via CrowdIn.
|
||||
- label: I confirm I'm offering to help translate for this new language via Crowdin.
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/support_request.yml
vendored
1
.github/ISSUE_TEMPLATE/support_request.yml
vendored
@@ -1,6 +1,5 @@
|
||||
name: Support Request
|
||||
description: Request support for a specific problem you have not been able to solve yourself
|
||||
title: "[Support Request]: "
|
||||
labels: [":dog2: Support"]
|
||||
body:
|
||||
- type: checkboxes
|
||||
|
||||
73
.github/translators.txt
vendored
73
.github/translators.txt
vendored
@@ -55,6 +55,8 @@ Name :: Languages
|
||||
@Baptistou :: French
|
||||
@arcoai :: Spanish
|
||||
@Jokuna :: Korean
|
||||
@smartshogu :: German; German Informal
|
||||
@samadha56 :: Persian
|
||||
cipi1965 :: Italian
|
||||
Mykola Ronik (Mantikor) :: Ukrainian
|
||||
furkanoyk :: Turkish
|
||||
@@ -136,7 +138,7 @@ Xiphoseer :: German
|
||||
MerlinSVK (merlinsvk) :: Slovak
|
||||
Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
|
||||
MatthieuParis :: French
|
||||
Douradinho :: Portuguese, Brazilian
|
||||
Douradinho :: Portuguese, Brazilian; Portuguese
|
||||
Gaku Yaguchi (tama11) :: Japanese
|
||||
johnroyer :: Chinese Traditional
|
||||
jackaaa :: Chinese Traditional
|
||||
@@ -158,14 +160,14 @@ HenrijsS :: Latvian
|
||||
Pascal R-B (pborgner) :: German
|
||||
Boris (Ginfred) :: Russian
|
||||
Jonas Anker Rasmussen (jonasanker) :: Danish
|
||||
Gerwin de Keijzer (gdekeijzer) :: Dutch; German; German Informal
|
||||
Gerwin de Keijzer (gdekeijzer) :: Dutch; German Informal; German
|
||||
kometchtech :: Japanese
|
||||
Auri (Atalonica) :: Catalan
|
||||
Francesco Franchina (ffranchina) :: Italian
|
||||
Aimrane Kds (aimrane.kds) :: Arabic
|
||||
whenwesober :: Indonesian
|
||||
Rem (remkovdhoef) :: Dutch
|
||||
syn7ax69 :: Bulgarian; Turkish
|
||||
syn7ax69 :: Bulgarian; Turkish; German
|
||||
Blaade :: French
|
||||
Behzad HosseinPoor (behzad.hp) :: Persian
|
||||
Ole Aldric (Swoy) :: Norwegian Bokmal
|
||||
@@ -174,7 +176,7 @@ Alexander Predl (Harveyhase68) :: German
|
||||
Rem (Rem9000) :: Dutch
|
||||
Michał Stelmach (stelmach-web) :: Polish
|
||||
arniom :: French
|
||||
REMOVED_USER :: Turkish
|
||||
REMOVED_USER :: ; Dutch; Turkish
|
||||
林祖年 (contagion) :: Chinese Traditional
|
||||
Siamak Guodarzi (siamakgoudarzi88) :: Persian
|
||||
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
|
||||
@@ -215,3 +217,66 @@ Saeed (saeed205) :: Persian
|
||||
Julesdevops :: French
|
||||
peter cerny (posli.to.semka) :: Slovak
|
||||
Pavel Karlin (pavelkarlin) :: Russian
|
||||
SmokingCrop :: Dutch
|
||||
Maciej Lebiest (Szwendacz) :: Polish
|
||||
DiscordDigital :: German; German Informal
|
||||
Gábor Marton (dodver) :: Hungarian
|
||||
Jasell :: Swedish
|
||||
Ghost_chu (ghostchu) :: Chinese Simplified
|
||||
Ravid Shachar (ravidshachar) :: Hebrew
|
||||
Helga Guchshenskaya (guchshenskaya) :: Russian
|
||||
daniel chou (chou0214) :: Chinese Traditional
|
||||
Manolis PATRIARCHE (m.patriarche) :: French
|
||||
Mohammed Haboubi (haboubi92) :: Arabic
|
||||
roncallyt :: Portuguese, Brazilian
|
||||
goegol :: Dutch
|
||||
msevgen :: Turkish
|
||||
Khroners :: French
|
||||
MASOUD HOSSEINY (masoudme) :: Persian
|
||||
Thomerson Roncally (roncallyt) :: Portuguese, Brazilian
|
||||
metaarch :: Bulgarian
|
||||
Xabi (xabikip) :: Basque
|
||||
pedromcsousa :: Portuguese
|
||||
Nir Louk (looknear) :: Hebrew
|
||||
Alex (qianmengnet) :: Chinese Simplified
|
||||
stothew :: German
|
||||
sgenc :: Turkish
|
||||
Shukrullo (vodiylik) :: Uzbek
|
||||
William W. (Nevnt) :: Chinese Traditional
|
||||
eamaro :: Portuguese
|
||||
Ypsilon-dev :: Arabic
|
||||
Hieu Vuong Trung (vuongtrunghieu) :: Vietnamese
|
||||
David Clubb (davidoclubb) :: Welsh
|
||||
welles freire (wellesximenes) :: Portuguese, Brazilian
|
||||
Magnus Jensen (MagnusHJensen) :: Danish
|
||||
Hesley Magno (hesleymagno) :: Portuguese, Brazilian
|
||||
Éric Gaspar (erga) :: French
|
||||
Fr3shlama :: German
|
||||
DSR :: Spanish, Argentina
|
||||
Andrii Bodnar (andrii-bodnar) :: Ukrainian
|
||||
Younes el Anjri (younesea28) :: Dutch
|
||||
Guclu Ozturk (gucluoz) :: Turkish
|
||||
Atmis :: French
|
||||
redjack666 :: Chinese Traditional
|
||||
Ashita007 :: Russian
|
||||
lihaorr :: Chinese Simplified
|
||||
Marcus Silber (marcus.silber82) :: German
|
||||
PellNet :: Croatian
|
||||
Winetradr :: German
|
||||
Sebastian Klaus (sebklaus) :: German
|
||||
Filip Antala (AntalaFilip) :: Slovak
|
||||
mcgong (GongMingCai) :: Chinese Simplified; Chinese Traditional
|
||||
Nanang Setia Budi (sefidananang) :: Indonesian
|
||||
Андрей Павлов (andrei.pavlov) :: Russian
|
||||
Alex Navarro (alex.n.navarro) :: Portuguese, Brazilian
|
||||
Ji-Hyeon Gim (PotatoGim) :: Korean
|
||||
Mihai Ochian (soulstorm19) :: Romanian
|
||||
HeartCore :: German Informal; German
|
||||
simon.pct :: French
|
||||
okaeiz :: Persian
|
||||
Naoto Ishikawa (na3shkw) :: Japanese
|
||||
sdhadi :: Persian
|
||||
DerLinkman (derlinkman) :: German; German Informal
|
||||
TurnArabic :: Arabic
|
||||
Martin Sebek (sebekmartin) :: Czech
|
||||
Kuchinashi Hoshikawa (kuchinashi) :: Chinese Simplified
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
name: phpstan
|
||||
name: analyse-php
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.3']
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
php-version: 8.1
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
||||
|
||||
- name: Get Composer Cache Directory
|
||||
@@ -29,13 +21,14 @@ jobs:
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v1
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
key: ${{ runner.os }}-composer-8.1
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Install composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction --ansi
|
||||
|
||||
- name: Run PHPStan
|
||||
run: php${{ matrix.php }} ./vendor/bin/phpstan analyse --memory-limit=2G
|
||||
- name: Run static analysis check
|
||||
run: composer check-static
|
||||
19
.github/workflows/lint-php.yml
vendored
Normal file
19
.github/workflows/lint-php.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
name: lint-php
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.1
|
||||
tools: phpcs
|
||||
|
||||
- name: Run formatting check
|
||||
run: composer lint
|
||||
16
.github/workflows/test-migrations.yml
vendored
16
.github/workflows/test-migrations.yml
vendored
@@ -1,19 +1,14 @@
|
||||
name: test-migrations
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.3', '7.4', '8.0', '8.1']
|
||||
php: ['7.4', '8.0', '8.1']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
@@ -29,10 +24,11 @@ jobs:
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v1
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Start MySQL
|
||||
run: |
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
name: phpunit
|
||||
name: test-php
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
pull_request:
|
||||
branches-ignore:
|
||||
- l10n_development
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.3', '7.4', '8.0', '8.1']
|
||||
php: ['7.4', '8.0', '8.1']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
@@ -29,10 +24,11 @@ jobs:
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v1
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Start Database
|
||||
run: |
|
||||
@@ -53,5 +49,5 @@ jobs:
|
||||
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
|
||||
php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
|
||||
|
||||
- name: phpunit
|
||||
- name: Run PHP tests
|
||||
run: php${{ matrix.php }} ./vendor/bin/phpunit
|
||||
@@ -2,21 +2,15 @@
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ActivityLogger
|
||||
{
|
||||
protected $permissionService;
|
||||
|
||||
public function __construct(PermissionService $permissionService)
|
||||
{
|
||||
$this->permissionService = $permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a generic activity event to the database.
|
||||
*
|
||||
@@ -35,8 +29,10 @@ class ActivityLogger
|
||||
}
|
||||
|
||||
$activity->save();
|
||||
|
||||
$this->setNotification($type);
|
||||
$this->dispatchWebhooks($type, $detail);
|
||||
Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,12 +40,10 @@ class ActivityLogger
|
||||
*/
|
||||
protected function newActivityForUser(string $type): Activity
|
||||
{
|
||||
$ip = request()->ip() ?? '';
|
||||
|
||||
return (new Activity())->forceFill([
|
||||
'type' => strtolower($type),
|
||||
'user_id' => user()->id,
|
||||
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
|
||||
'ip' => IpFormatter::fromCurrentRequest()->format(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
@@ -13,11 +13,11 @@ use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class ActivityQueries
|
||||
{
|
||||
protected $permissionService;
|
||||
protected PermissionApplicator $permissions;
|
||||
|
||||
public function __construct(PermissionService $permissionService)
|
||||
public function __construct(PermissionApplicator $permissions)
|
||||
{
|
||||
$this->permissionService = $permissionService;
|
||||
$this->permissions = $permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,8 +25,8 @@ class ActivityQueries
|
||||
*/
|
||||
public function latest(int $count = 20, int $page = 0): array
|
||||
{
|
||||
$activityList = $this->permissionService
|
||||
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->with(['user', 'entity'])
|
||||
->skip($count * $page)
|
||||
@@ -78,8 +78,8 @@ class ActivityQueries
|
||||
*/
|
||||
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
||||
{
|
||||
$activityList = $this->permissionService
|
||||
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->where('user_id', '=', $user->id)
|
||||
->skip($count * $page)
|
||||
|
||||
@@ -16,17 +16,22 @@ class ActivityType
|
||||
const CHAPTER_MOVE = 'chapter_move';
|
||||
|
||||
const BOOK_CREATE = 'book_create';
|
||||
const BOOK_CREATE_FROM_CHAPTER = 'book_create_from_chapter';
|
||||
const BOOK_UPDATE = 'book_update';
|
||||
const BOOK_DELETE = 'book_delete';
|
||||
const BOOK_SORT = 'book_sort';
|
||||
|
||||
const BOOKSHELF_CREATE = 'bookshelf_create';
|
||||
const BOOKSHELF_CREATE_FROM_BOOK = 'bookshelf_create_from_book';
|
||||
const BOOKSHELF_UPDATE = 'bookshelf_update';
|
||||
const BOOKSHELF_DELETE = 'bookshelf_delete';
|
||||
|
||||
const COMMENTED_ON = 'commented_on';
|
||||
const PERMISSIONS_UPDATE = 'permissions_update';
|
||||
|
||||
const REVISION_RESTORE = 'revision_restore';
|
||||
const REVISION_DELETE = 'revision_delete';
|
||||
|
||||
const SETTINGS_UPDATE = 'settings_update';
|
||||
const MAINTENANCE_ACTION_RUN = 'maintenance_action_run';
|
||||
|
||||
|
||||
@@ -3,17 +3,14 @@
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Model;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -24,31 +21,16 @@ class DispatchWebhookJob implements ShouldQueue
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/**
|
||||
* @var Webhook
|
||||
*/
|
||||
protected $webhook;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $event;
|
||||
protected Webhook $webhook;
|
||||
protected string $event;
|
||||
protected User $initiator;
|
||||
protected int $initiatedTime;
|
||||
|
||||
/**
|
||||
* @var string|Loggable
|
||||
*/
|
||||
protected $detail;
|
||||
|
||||
/**
|
||||
* @var User
|
||||
*/
|
||||
protected $initiator;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected $initiatedTime;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
@@ -70,8 +52,8 @@ class DispatchWebhookJob implements ShouldQueue
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail);
|
||||
$webhookData = $themeResponse ?? $this->buildWebhookData();
|
||||
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime);
|
||||
$webhookData = $themeResponse ?? WebhookFormatter::getDefault($this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime)->format();
|
||||
$lastError = null;
|
||||
|
||||
try {
|
||||
@@ -97,36 +79,4 @@ class DispatchWebhookJob implements ShouldQueue
|
||||
|
||||
$this->webhook->save();
|
||||
}
|
||||
|
||||
protected function buildWebhookData(): array
|
||||
{
|
||||
$textParts = [
|
||||
$this->initiator->name,
|
||||
trans('activities.' . $this->event),
|
||||
];
|
||||
|
||||
if ($this->detail instanceof Entity) {
|
||||
$textParts[] = '"' . $this->detail->name . '"';
|
||||
}
|
||||
|
||||
$data = [
|
||||
'event' => $this->event,
|
||||
'text' => implode(' ', $textParts),
|
||||
'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
|
||||
'triggered_by' => $this->initiator->attributesToArray(),
|
||||
'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
|
||||
'webhook_id' => $this->webhook->id,
|
||||
'webhook_name' => $this->webhook->name,
|
||||
];
|
||||
|
||||
if (method_exists($this->detail, 'getUrl')) {
|
||||
$data['url'] = $this->detail->getUrl();
|
||||
}
|
||||
|
||||
if ($this->detail instanceof Model) {
|
||||
$data['related_item'] = $this->detail->attributesToArray();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
81
app/Actions/IpFormatter.php
Normal file
81
app/Actions/IpFormatter.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
class IpFormatter
|
||||
{
|
||||
protected string $ip;
|
||||
protected int $precision;
|
||||
|
||||
public function __construct(string $ip, int $precision)
|
||||
{
|
||||
$this->ip = trim($ip);
|
||||
$this->precision = max(0, min($precision, 4));
|
||||
}
|
||||
|
||||
public function format(): string
|
||||
{
|
||||
if (empty($this->ip) || $this->precision === 4) {
|
||||
return $this->ip;
|
||||
}
|
||||
|
||||
return $this->isIpv6() ? $this->maskIpv6() : $this->maskIpv4();
|
||||
}
|
||||
|
||||
protected function maskIpv4(): string
|
||||
{
|
||||
$exploded = $this->explodeAndExpandIp('.', 4);
|
||||
$maskGroupCount = min(4 - $this->precision, count($exploded));
|
||||
|
||||
for ($i = 0; $i < $maskGroupCount; $i++) {
|
||||
$exploded[3 - $i] = 'x';
|
||||
}
|
||||
|
||||
return implode('.', $exploded);
|
||||
}
|
||||
|
||||
protected function maskIpv6(): string
|
||||
{
|
||||
$exploded = $this->explodeAndExpandIp(':', 8);
|
||||
$maskGroupCount = min(8 - ($this->precision * 2), count($exploded));
|
||||
|
||||
for ($i = 0; $i < $maskGroupCount; $i++) {
|
||||
$exploded[7 - $i] = 'x';
|
||||
}
|
||||
|
||||
return implode(':', $exploded);
|
||||
}
|
||||
|
||||
protected function isIpv6(): bool
|
||||
{
|
||||
return strpos($this->ip, ':') !== false;
|
||||
}
|
||||
|
||||
protected function explodeAndExpandIp(string $separator, int $targetLength): array
|
||||
{
|
||||
$exploded = explode($separator, $this->ip);
|
||||
|
||||
while (count($exploded) < $targetLength) {
|
||||
$emptyIndex = array_search('', $exploded) ?: count($exploded) - 1;
|
||||
array_splice($exploded, $emptyIndex, 0, '0');
|
||||
}
|
||||
|
||||
$emptyIndex = array_search('', $exploded);
|
||||
if ($emptyIndex !== false) {
|
||||
$exploded[$emptyIndex] = '0';
|
||||
}
|
||||
|
||||
return $exploded;
|
||||
}
|
||||
|
||||
public static function fromCurrentRequest(): self
|
||||
{
|
||||
$ip = request()->ip() ?? '';
|
||||
|
||||
if (config('app.env') === 'demo') {
|
||||
$ip = '127.0.0.1';
|
||||
}
|
||||
|
||||
return new self($ip, config('app.ip_address_precision'));
|
||||
}
|
||||
}
|
||||
49
app/Actions/TagClassGenerator.php
Normal file
49
app/Actions/TagClassGenerator.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
class TagClassGenerator
|
||||
{
|
||||
protected array $tags;
|
||||
|
||||
/**
|
||||
* @param Tag[] $tags
|
||||
*/
|
||||
public function __construct(array $tags)
|
||||
{
|
||||
$this->tags = $tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function generate(): array
|
||||
{
|
||||
$classes = [];
|
||||
|
||||
foreach ($this->tags as $tag) {
|
||||
$name = $this->normalizeTagClassString($tag->name);
|
||||
$value = $this->normalizeTagClassString($tag->value);
|
||||
$classes[] = 'tag-name-' . $name;
|
||||
if ($value) {
|
||||
$classes[] = 'tag-value-' . $value;
|
||||
$classes[] = 'tag-pair-' . $name . '-' . $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($classes);
|
||||
}
|
||||
|
||||
public function generateAsString(): string
|
||||
{
|
||||
return implode(' ', $this->generate());
|
||||
}
|
||||
|
||||
protected function normalizeTagClassString(string $value): string
|
||||
{
|
||||
$value = str_replace(' ', '', strtolower($value));
|
||||
$value = str_replace('-', '', strtolower($value));
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -10,12 +10,11 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TagRepo
|
||||
{
|
||||
protected $tag;
|
||||
protected $permissionService;
|
||||
protected PermissionApplicator $permissions;
|
||||
|
||||
public function __construct(PermissionService $ps)
|
||||
public function __construct(PermissionApplicator $permissions)
|
||||
{
|
||||
$this->permissionService = $ps;
|
||||
$this->permissions = $permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -28,10 +27,10 @@ class TagRepo
|
||||
'name',
|
||||
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
|
||||
DB::raw('COUNT(id) as usages'),
|
||||
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Page\', 1, 0)) as page_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Chapter\', 1, 0)) as chapter_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Book\', 1, 0)) as book_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'BookStack\\\\BookShelf\', 1, 0)) as shelf_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'page\', 1, 0)) as page_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'chapter\', 1, 0)) as chapter_count'),
|
||||
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');
|
||||
|
||||
@@ -51,7 +50,7 @@ class TagRepo
|
||||
});
|
||||
}
|
||||
|
||||
return $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
|
||||
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,7 +69,7 @@ class TagRepo
|
||||
$query = $query->orderBy('count', 'desc')->take(50);
|
||||
}
|
||||
|
||||
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
|
||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
|
||||
return $query->get(['name'])->pluck('name');
|
||||
}
|
||||
@@ -96,7 +95,7 @@ class TagRepo
|
||||
$query = $query->where('name', '=', $tagName);
|
||||
}
|
||||
|
||||
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
|
||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
|
||||
return $query->get(['value'])->pluck('value');
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
*/
|
||||
class Webhook extends Model implements Loggable
|
||||
{
|
||||
protected $fillable = ['name', 'endpoint', 'timeout'];
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'endpoint', 'timeout'];
|
||||
|
||||
protected $casts = [
|
||||
'last_called_at' => 'datetime',
|
||||
'last_errored_at' => 'datetime',
|
||||
|
||||
124
app/Actions/WebhookFormatter.php
Normal file
124
app/Actions/WebhookFormatter.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class WebhookFormatter
|
||||
{
|
||||
protected Webhook $webhook;
|
||||
protected string $event;
|
||||
protected User $initiator;
|
||||
protected int $initiatedTime;
|
||||
|
||||
/**
|
||||
* @var string|Loggable
|
||||
*/
|
||||
protected $detail;
|
||||
|
||||
/**
|
||||
* @var array{condition: callable(string, Model):bool, format: callable(Model):void}[]
|
||||
*/
|
||||
protected $modelFormatters = [];
|
||||
|
||||
public function __construct(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime)
|
||||
{
|
||||
$this->webhook = $webhook;
|
||||
$this->event = $event;
|
||||
$this->initiator = $initiator;
|
||||
$this->initiatedTime = $initiatedTime;
|
||||
$this->detail = is_object($detail) ? clone $detail : $detail;
|
||||
}
|
||||
|
||||
public function format(): array
|
||||
{
|
||||
$data = [
|
||||
'event' => $this->event,
|
||||
'text' => $this->formatText(),
|
||||
'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
|
||||
'triggered_by' => $this->initiator->attributesToArray(),
|
||||
'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
|
||||
'webhook_id' => $this->webhook->id,
|
||||
'webhook_name' => $this->webhook->name,
|
||||
];
|
||||
|
||||
if (method_exists($this->detail, 'getUrl')) {
|
||||
$data['url'] = $this->detail->getUrl();
|
||||
}
|
||||
|
||||
if ($this->detail instanceof Model) {
|
||||
$data['related_item'] = $this->formatModel();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(string, Model):bool $condition
|
||||
* @param callable(Model):void $format
|
||||
*/
|
||||
public function addModelFormatter(callable $condition, callable $format): void
|
||||
{
|
||||
$this->modelFormatters[] = [
|
||||
'condition' => $condition,
|
||||
'format' => $format,
|
||||
];
|
||||
}
|
||||
|
||||
public function addDefaultModelFormatters(): void
|
||||
{
|
||||
// Load entity owner, creator, updater details
|
||||
$this->addModelFormatter(
|
||||
fn ($event, $model) => ($model instanceof Entity),
|
||||
fn ($model) => $model->load(['ownedBy', 'createdBy', 'updatedBy'])
|
||||
);
|
||||
|
||||
// Load revision detail for page update and create events
|
||||
$this->addModelFormatter(
|
||||
fn ($event, $model) => ($model instanceof Page && ($event === ActivityType::PAGE_CREATE || $event === ActivityType::PAGE_UPDATE)),
|
||||
fn ($model) => $model->load('currentRevision')
|
||||
);
|
||||
}
|
||||
|
||||
protected function formatModel(): array
|
||||
{
|
||||
/** @var Model $model */
|
||||
$model = $this->detail;
|
||||
$model->unsetRelations();
|
||||
|
||||
foreach ($this->modelFormatters as $formatter) {
|
||||
if ($formatter['condition']($this->event, $model)) {
|
||||
$formatter['format']($model);
|
||||
}
|
||||
}
|
||||
|
||||
return $model->toArray();
|
||||
}
|
||||
|
||||
protected function formatText(): string
|
||||
{
|
||||
$textParts = [
|
||||
$this->initiator->name,
|
||||
trans('activities.' . $this->event),
|
||||
];
|
||||
|
||||
if ($this->detail instanceof Entity) {
|
||||
$textParts[] = '"' . $this->detail->name . '"';
|
||||
}
|
||||
|
||||
return implode(' ', $textParts);
|
||||
}
|
||||
|
||||
public static function getDefault(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime): self
|
||||
{
|
||||
$instance = new self($event, $webhook, $detail, $initiator, $initiatedTime);
|
||||
$instance->addDefaultModelFormatters();
|
||||
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
*/
|
||||
class WebhookTrackedEvent extends Model
|
||||
{
|
||||
protected $fillable = ['event'];
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['event'];
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\Http\Controllers\Api\ApiController;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use ReflectionClass;
|
||||
use ReflectionException;
|
||||
use ReflectionMethod;
|
||||
@@ -100,11 +102,37 @@ class ApiDocsGenerator
|
||||
$this->controllerClasses[$className] = $class;
|
||||
}
|
||||
|
||||
$rules = $class->getValdationRules()[$methodName] ?? [];
|
||||
$rules = collect($class->getValidationRules()[$methodName] ?? [])->map(function ($validations) {
|
||||
return array_map(function ($validation) {
|
||||
return $this->getValidationAsString($validation);
|
||||
}, $validations);
|
||||
})->toArray();
|
||||
|
||||
return empty($rules) ? null : $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given validation message to a readable string.
|
||||
*/
|
||||
protected function getValidationAsString($validation): string
|
||||
{
|
||||
if (is_string($validation)) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
if (is_object($validation) && method_exists($validation, '__toString')) {
|
||||
return strval($validation);
|
||||
}
|
||||
|
||||
if ($validation instanceof Password) {
|
||||
return 'min:8';
|
||||
}
|
||||
|
||||
$class = get_class($validation);
|
||||
|
||||
throw new Exception("Cannot provide string representation of rule for class: {$class}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse out the description text from a class method comment.
|
||||
*/
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ListingResponseBuilder
|
||||
@@ -12,6 +14,11 @@ class ListingResponseBuilder
|
||||
protected $request;
|
||||
protected $fields;
|
||||
|
||||
/**
|
||||
* @var array<callable>
|
||||
*/
|
||||
protected $resultModifiers = [];
|
||||
|
||||
protected $filterOperators = [
|
||||
'eq' => '=',
|
||||
'ne' => '!=',
|
||||
@@ -24,6 +31,7 @@ class ListingResponseBuilder
|
||||
|
||||
/**
|
||||
* ListingResponseBuilder constructor.
|
||||
* The given fields will be forced visible within the model results.
|
||||
*/
|
||||
public function __construct(Builder $query, Request $request, array $fields)
|
||||
{
|
||||
@@ -35,12 +43,16 @@ class ListingResponseBuilder
|
||||
/**
|
||||
* Get the response from this builder.
|
||||
*/
|
||||
public function toResponse()
|
||||
public function toResponse(): JsonResponse
|
||||
{
|
||||
$filteredQuery = $this->filterQuery($this->query);
|
||||
|
||||
$total = $filteredQuery->count();
|
||||
$data = $this->fetchData($filteredQuery);
|
||||
$data = $this->fetchData($filteredQuery)->each(function ($model) {
|
||||
foreach ($this->resultModifiers as $modifier) {
|
||||
$modifier($model);
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
@@ -49,7 +61,17 @@ class ListingResponseBuilder
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the data to return in the response.
|
||||
* Add a callback to modify each element of the results.
|
||||
*
|
||||
* @param (callable(Model)) $modifier
|
||||
*/
|
||||
public function modifyResults($modifier): void
|
||||
{
|
||||
$this->resultModifiers[] = $modifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the data to return within the response.
|
||||
*/
|
||||
protected function fetchData(Builder $query): Collection
|
||||
{
|
||||
|
||||
@@ -28,10 +28,8 @@ class GroupSyncService
|
||||
*/
|
||||
protected function externalIdMatchesGroupNames(string $externalId, array $groupNames): bool
|
||||
{
|
||||
$externalAuthIds = explode(',', strtolower($externalId));
|
||||
|
||||
foreach ($externalAuthIds as $externalAuthId) {
|
||||
if (in_array(trim($externalAuthId), $groupNames)) {
|
||||
foreach ($this->parseRoleExternalAuthId($externalId) as $externalAuthId) {
|
||||
if (in_array($externalAuthId, $groupNames)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +37,18 @@ class GroupSyncService
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function parseRoleExternalAuthId(string $externalId): array
|
||||
{
|
||||
$inputIds = preg_split('/(?<!\\\),/', strtolower($externalId));
|
||||
$cleanIds = [];
|
||||
|
||||
foreach ($inputIds as $inputId) {
|
||||
$cleanIds[] = str_replace('\,', ',', trim($inputId));
|
||||
}
|
||||
|
||||
return $cleanIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an array of group names to BookStack system roles.
|
||||
* Formats group names to be lower-case and hyphenated.
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Auth\Access\Guards;
|
||||
use BookStack\Auth\Access\LdapService;
|
||||
use BookStack\Auth\Access\RegistrationService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Exceptions\LdapException;
|
||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
@@ -15,7 +16,7 @@ use Illuminate\Support\Str;
|
||||
|
||||
class LdapSessionGuard extends ExternalBaseSessionGuard
|
||||
{
|
||||
protected $ldapService;
|
||||
protected LdapService $ldapService;
|
||||
|
||||
/**
|
||||
* LdapSessionGuard constructor.
|
||||
@@ -59,8 +60,9 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
|
||||
* @param array $credentials
|
||||
* @param bool $remember
|
||||
*
|
||||
* @throws LdapException*@throws \BookStack\Exceptions\JsonDebugException
|
||||
* @throws LoginAttemptException
|
||||
* @throws LdapException
|
||||
* @throws JsonDebugException
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
@@ -84,7 +86,7 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
|
||||
try {
|
||||
$user = $this->createNewFromLdapAndCreds($userDetails, $credentials);
|
||||
} catch (UserRegistrationException $exception) {
|
||||
throw new LoginAttemptException($exception->message);
|
||||
throw new LoginAttemptException($exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,17 @@ use Illuminate\Support\Facades\Log;
|
||||
*/
|
||||
class LdapService
|
||||
{
|
||||
protected $ldap;
|
||||
protected $groupSyncService;
|
||||
protected Ldap $ldap;
|
||||
protected GroupSyncService $groupSyncService;
|
||||
protected UserAvatars $userAvatars;
|
||||
|
||||
/**
|
||||
* @var resource
|
||||
*/
|
||||
protected $ldapConnection;
|
||||
protected $userAvatars;
|
||||
protected $config;
|
||||
protected $enabled;
|
||||
|
||||
protected array $config;
|
||||
protected bool $enabled;
|
||||
|
||||
/**
|
||||
* LdapService constructor.
|
||||
@@ -100,7 +105,7 @@ class LdapService
|
||||
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
|
||||
'dn' => $user['dn'],
|
||||
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
|
||||
'avatar'=> $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
|
||||
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
|
||||
];
|
||||
|
||||
if ($this->config['dump_user_details']) {
|
||||
@@ -274,6 +279,7 @@ class LdapService
|
||||
* Get the groups a user is a part of on ldap.
|
||||
*
|
||||
* @throws LdapException
|
||||
* @throws JsonDebugException
|
||||
*/
|
||||
public function getUserGroups(string $userName): array
|
||||
{
|
||||
@@ -285,8 +291,17 @@ class LdapService
|
||||
}
|
||||
|
||||
$userGroups = $this->groupFilter($user);
|
||||
$allGroups = $this->getGroupsRecursive($userGroups, []);
|
||||
|
||||
return $this->getGroupsRecursive($userGroups, []);
|
||||
if ($this->config['dump_user_groups']) {
|
||||
throw new JsonDebugException([
|
||||
'details_from_ldap' => $user,
|
||||
'parsed_direct_user_groups' => $userGroups,
|
||||
'parsed_recursive_user_groups' => $allGroups,
|
||||
]);
|
||||
}
|
||||
|
||||
return $allGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -369,6 +384,7 @@ class LdapService
|
||||
* Sync the LDAP groups to the user roles for the current user.
|
||||
*
|
||||
* @throws LdapException
|
||||
* @throws JsonDebugException
|
||||
*/
|
||||
public function syncGroups(User $user, string $username)
|
||||
{
|
||||
|
||||
9
app/Auth/Access/Oidc/OidcException.php
Normal file
9
app/Auth/Access/Oidc/OidcException.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
use Exception;
|
||||
|
||||
class OidcException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
class OidcIssuerDiscoveryException extends \Exception
|
||||
use Exception;
|
||||
|
||||
class OidcIssuerDiscoveryException extends Exception
|
||||
{
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
*/
|
||||
protected $tokenEndpoint;
|
||||
|
||||
/**
|
||||
* Scopes to use for the OIDC authorization call.
|
||||
*/
|
||||
protected array $scopes = ['openid', 'profile', 'email'];
|
||||
|
||||
/**
|
||||
* Returns the base URL for authorizing a client.
|
||||
*/
|
||||
@@ -54,6 +59,15 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an additional scope to this provider upon the default.
|
||||
*/
|
||||
public function addScope(string $scope): void
|
||||
{
|
||||
$this->scopes[] = $scope;
|
||||
$this->scopes = array_unique($this->scopes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default scopes used by this provider.
|
||||
*
|
||||
@@ -62,7 +76,7 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
*/
|
||||
protected function getDefaultScopes(): array
|
||||
{
|
||||
return ['openid', 'profile', 'email'];
|
||||
return $this->scopes;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,22 +2,18 @@
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
use function auth;
|
||||
use BookStack\Auth\Access\GroupSyncService;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\RegistrationService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Exceptions\OpenIdConnectException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use function config;
|
||||
use Exception;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
|
||||
use Psr\Http\Client\ClientExceptionInterface;
|
||||
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
||||
use Psr\Http\Client\ClientInterface as HttpClient;
|
||||
use function trans;
|
||||
use function url;
|
||||
|
||||
/**
|
||||
* Class OpenIdConnectService
|
||||
@@ -25,23 +21,31 @@ use function url;
|
||||
*/
|
||||
class OidcService
|
||||
{
|
||||
protected $registrationService;
|
||||
protected $loginService;
|
||||
protected $httpClient;
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
protected HttpClient $httpClient;
|
||||
protected GroupSyncService $groupService;
|
||||
|
||||
/**
|
||||
* OpenIdService constructor.
|
||||
*/
|
||||
public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient)
|
||||
{
|
||||
public function __construct(
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService,
|
||||
HttpClient $httpClient,
|
||||
GroupSyncService $groupService
|
||||
) {
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
$this->httpClient = $httpClient;
|
||||
$this->groupService = $groupService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate an authorization flow.
|
||||
*
|
||||
* @throws OidcException
|
||||
*
|
||||
* @return array{url: string, state: string}
|
||||
*/
|
||||
public function login(): array
|
||||
@@ -57,14 +61,15 @@ class OidcService
|
||||
|
||||
/**
|
||||
* Process the Authorization response from the authorization server and
|
||||
* return the matching, or new if registration active, user matched to
|
||||
* the authorization server.
|
||||
* Returns null if not authenticated.
|
||||
* return the matching, or new if registration active, user matched to the
|
||||
* authorization server. Throws if the user cannot be auth if not authenticated.
|
||||
*
|
||||
* @throws Exception
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws JsonDebugException
|
||||
* @throws OidcException
|
||||
* @throws StoppedAuthenticationException
|
||||
* @throws IdentityProviderException
|
||||
*/
|
||||
public function processAuthorizeResponse(?string $authorizationCode): ?User
|
||||
public function processAuthorizeResponse(?string $authorizationCode): User
|
||||
{
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
@@ -78,8 +83,7 @@ class OidcService
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws OidcIssuerDiscoveryException
|
||||
* @throws ClientExceptionInterface
|
||||
* @throws OidcException
|
||||
*/
|
||||
protected function getProviderSettings(): OidcProviderSettings
|
||||
{
|
||||
@@ -100,7 +104,11 @@ class OidcService
|
||||
|
||||
// Run discovery
|
||||
if ($config['discover'] ?? false) {
|
||||
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
|
||||
try {
|
||||
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
|
||||
} catch (OidcIssuerDiscoveryException $exception) {
|
||||
throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$settings->validate();
|
||||
@@ -113,10 +121,31 @@ class OidcService
|
||||
*/
|
||||
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
|
||||
{
|
||||
return new OidcOAuthProvider($settings->arrayForProvider(), [
|
||||
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [
|
||||
'httpClient' => $this->httpClient,
|
||||
'optionProvider' => new HttpBasicAuthOptionProvider(),
|
||||
]);
|
||||
|
||||
foreach ($this->getAdditionalScopes() as $scope) {
|
||||
$provider->addScope($scope);
|
||||
}
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get any user-defined addition/custom scopes to apply to the authentication request.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
protected function getAdditionalScopes(): array
|
||||
{
|
||||
$scopeConfig = $this->config()['additional_scopes'] ?: '';
|
||||
|
||||
$scopeArr = explode(',', $scopeConfig);
|
||||
$scopeArr = array_map(fn (string $scope) => trim($scope), $scopeArr);
|
||||
|
||||
return array_filter($scopeArr);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,10 +170,32 @@ class OidcService
|
||||
return implode(' ', $displayName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the assigned groups from the id token.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
protected function getUserGroups(OidcIdToken $token): array
|
||||
{
|
||||
$groupsAttr = $this->config()['groups_claim'];
|
||||
if (empty($groupsAttr)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groupsList = Arr::get($token->getAllClaims(), $groupsAttr);
|
||||
if (!is_array($groupsList)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($groupsList, function ($val) {
|
||||
return is_string($val);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the details of a user from an ID token.
|
||||
*
|
||||
* @return array{name: string, email: string, external_id: string}
|
||||
* @return array{name: string, email: string, external_id: string, groups: string[]}
|
||||
*/
|
||||
protected function getUserDetails(OidcIdToken $token): array
|
||||
{
|
||||
@@ -154,6 +205,7 @@ class OidcService
|
||||
'external_id' => $id,
|
||||
'email' => $token->getClaim('email'),
|
||||
'name' => $this->getUserDisplayName($token, $id),
|
||||
'groups' => $this->getUserGroups($token),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -161,9 +213,8 @@ class OidcService
|
||||
* Processes a received access token for a user. Login the user when
|
||||
* they exist, optionally registering them automatically.
|
||||
*
|
||||
* @throws OpenIdConnectException
|
||||
* @throws OidcException
|
||||
* @throws JsonDebugException
|
||||
* @throws UserRegistrationException
|
||||
* @throws StoppedAuthenticationException
|
||||
*/
|
||||
protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User
|
||||
@@ -182,28 +233,34 @@ class OidcService
|
||||
try {
|
||||
$idToken->validate($settings->clientId);
|
||||
} catch (OidcInvalidTokenException $exception) {
|
||||
throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}");
|
||||
throw new OidcException("ID token validate failed with error: {$exception->getMessage()}");
|
||||
}
|
||||
|
||||
$userDetails = $this->getUserDetails($idToken);
|
||||
$isLoggedIn = auth()->check();
|
||||
|
||||
if (empty($userDetails['email'])) {
|
||||
throw new OpenIdConnectException(trans('errors.oidc_no_email_address'));
|
||||
throw new OidcException(trans('errors.oidc_no_email_address'));
|
||||
}
|
||||
|
||||
if ($isLoggedIn) {
|
||||
throw new OpenIdConnectException(trans('errors.oidc_already_logged_in'), '/login');
|
||||
throw new OidcException(trans('errors.oidc_already_logged_in'));
|
||||
}
|
||||
|
||||
$user = $this->registrationService->findOrRegister(
|
||||
$userDetails['name'],
|
||||
$userDetails['email'],
|
||||
$userDetails['external_id']
|
||||
);
|
||||
try {
|
||||
$user = $this->registrationService->findOrRegister(
|
||||
$userDetails['name'],
|
||||
$userDetails['email'],
|
||||
$userDetails['external_id']
|
||||
);
|
||||
} catch (UserRegistrationException $exception) {
|
||||
throw new OidcException($exception->getMessage());
|
||||
}
|
||||
|
||||
if ($user === null) {
|
||||
throw new OpenIdConnectException(trans('errors.oidc_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
|
||||
if ($this->shouldSyncGroups()) {
|
||||
$groups = $userDetails['groups'];
|
||||
$detachExisting = $this->config()['remove_from_groups'];
|
||||
$this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
|
||||
}
|
||||
|
||||
$this->loginService->login($user, 'oidc');
|
||||
@@ -218,4 +275,12 @@ class OidcService
|
||||
{
|
||||
return config('oidc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if groups should be synced.
|
||||
*/
|
||||
protected function shouldSyncGroups(): bool
|
||||
{
|
||||
return $this->config()['user_to_groups'] !== false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,8 @@ class RegistrationService
|
||||
}
|
||||
|
||||
// Create the user
|
||||
$newUser = $this->userRepo->registerNew($userData, $emailConfirmed);
|
||||
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
|
||||
$newUser->attachDefaultRole();
|
||||
|
||||
// Assign social account if given
|
||||
if ($socialAccount) {
|
||||
|
||||
@@ -109,9 +109,10 @@ class Saml2Service
|
||||
$errors = $toolkit->getErrors();
|
||||
|
||||
if (!empty($errors)) {
|
||||
throw new Error(
|
||||
'Invalid ACS Response: ' . implode(', ', $errors)
|
||||
);
|
||||
$reason = $toolkit->getLastErrorReason();
|
||||
$message = 'Invalid ACS Response; Errors: ' . implode(', ', $errors);
|
||||
$message .= $reason ? "; Reason: {$reason}" : '';
|
||||
throw new Error($message);
|
||||
}
|
||||
|
||||
if (!$toolkit->isAuthenticated()) {
|
||||
|
||||
405
app/Auth/Permissions/JointPermissionBuilder.php
Normal file
405
app/Auth/Permissions/JointPermissionBuilder.php
Normal file
@@ -0,0 +1,405 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Joint permissions provide a pre-query "cached" table of view permissions for all core entity
|
||||
* types for all roles in the system. This class generates out that table for different scenarios.
|
||||
*/
|
||||
class JointPermissionBuilder
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<int, SimpleEntityData>>
|
||||
*/
|
||||
protected $entityCache;
|
||||
|
||||
/**
|
||||
* Re-generate all entity permission from scratch.
|
||||
*/
|
||||
public function rebuildForAll()
|
||||
{
|
||||
JointPermission::query()->truncate();
|
||||
|
||||
// Get all roles (Should be the most limited dimension)
|
||||
$roles = Role::query()->with('permissions')->get()->all();
|
||||
|
||||
// Chunk through all books
|
||||
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
|
||||
$this->buildJointPermissionsForBooks($books, $roles);
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
|
||||
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
|
||||
$this->createManyJointPermissions($shelves->all(), $roles);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entity jointPermissions for a particular entity.
|
||||
*/
|
||||
public function rebuildForEntity(Entity $entity)
|
||||
{
|
||||
$entities = [$entity];
|
||||
if ($entity instanceof Book) {
|
||||
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
|
||||
$this->buildJointPermissionsForBooks($books, Role::query()->with('permissions')->get()->all(), true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BookChild $entity */
|
||||
if ($entity->book) {
|
||||
$entities[] = $entity->book;
|
||||
}
|
||||
|
||||
if ($entity instanceof Page && $entity->chapter_id) {
|
||||
$entities[] = $entity->chapter;
|
||||
}
|
||||
|
||||
if ($entity instanceof Chapter) {
|
||||
foreach ($entity->pages as $page) {
|
||||
$entities[] = $page;
|
||||
}
|
||||
}
|
||||
|
||||
$this->buildJointPermissionsForEntities($entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the entity jointPermissions for a particular role.
|
||||
*/
|
||||
public function rebuildForRole(Role $role)
|
||||
{
|
||||
$roles = [$role];
|
||||
$role->jointPermissions()->delete();
|
||||
$role->load('permissions');
|
||||
|
||||
// Chunk through all books
|
||||
$this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
|
||||
$this->buildJointPermissionsForBooks($books, $roles);
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->select(['id', 'restricted', 'owned_by'])
|
||||
->chunk(50, function ($shelves) use ($roles) {
|
||||
$this->createManyJointPermissions($shelves->all(), $roles);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the local entity cache and ensure it's empty.
|
||||
*
|
||||
* @param SimpleEntityData[] $entities
|
||||
*/
|
||||
protected function readyEntityCache(array $entities)
|
||||
{
|
||||
$this->entityCache = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
if (!isset($this->entityCache[$entity->type])) {
|
||||
$this->entityCache[$entity->type] = [];
|
||||
}
|
||||
|
||||
$this->entityCache[$entity->type][$entity->id] = $entity;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a book via ID, Checks local cache.
|
||||
*/
|
||||
protected function getBook(int $bookId): SimpleEntityData
|
||||
{
|
||||
return $this->entityCache['book'][$bookId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a chapter via ID, Checks local cache.
|
||||
*/
|
||||
protected function getChapter(int $chapterId): SimpleEntityData
|
||||
{
|
||||
return $this->entityCache['chapter'][$chapterId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a query for fetching a book with its children.
|
||||
*/
|
||||
protected function bookFetchQuery(): Builder
|
||||
{
|
||||
return Book::query()->withTrashed()
|
||||
->select(['id', 'restricted', 'owned_by'])->with([
|
||||
'chapters' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
|
||||
},
|
||||
'pages' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build joint permissions for the given book and role combinations.
|
||||
*/
|
||||
protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
|
||||
{
|
||||
$entities = clone $books;
|
||||
|
||||
/** @var Book $book */
|
||||
foreach ($books->all() as $book) {
|
||||
foreach ($book->getRelation('chapters') as $chapter) {
|
||||
$entities->push($chapter);
|
||||
}
|
||||
foreach ($book->getRelation('pages') as $page) {
|
||||
$entities->push($page);
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteOld) {
|
||||
$this->deleteManyJointPermissionsForEntities($entities->all());
|
||||
}
|
||||
|
||||
$this->createManyJointPermissions($entities->all(), $roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entity jointPermissions for a collection of entities.
|
||||
*/
|
||||
protected function buildJointPermissionsForEntities(array $entities)
|
||||
{
|
||||
$roles = Role::query()->get()->values()->all();
|
||||
$this->deleteManyJointPermissionsForEntities($entities);
|
||||
$this->createManyJointPermissions($entities, $roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all the entity jointPermissions for a list of entities.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
*/
|
||||
protected function deleteManyJointPermissionsForEntities(array $entities)
|
||||
{
|
||||
$simpleEntities = $this->entitiesToSimpleEntities($entities);
|
||||
$idsByType = $this->entitiesToTypeIdMap($simpleEntities);
|
||||
|
||||
DB::transaction(function () use ($idsByType) {
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
foreach (array_chunk($ids, 1000) as $idChunk) {
|
||||
DB::table('joint_permissions')
|
||||
->where('entity_type', '=', $type)
|
||||
->whereIn('entity_id', $idChunk)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Entity[] $entities
|
||||
*
|
||||
* @return SimpleEntityData[]
|
||||
*/
|
||||
protected function entitiesToSimpleEntities(array $entities): array
|
||||
{
|
||||
$simpleEntities = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$attrs = $entity->getAttributes();
|
||||
$simple = new SimpleEntityData();
|
||||
$simple->id = $attrs['id'];
|
||||
$simple->type = $entity->getMorphClass();
|
||||
$simple->restricted = boolval($attrs['restricted'] ?? 0);
|
||||
$simple->owned_by = $attrs['owned_by'] ?? 0;
|
||||
$simple->book_id = $attrs['book_id'] ?? null;
|
||||
$simple->chapter_id = $attrs['chapter_id'] ?? null;
|
||||
$simpleEntities[] = $simple;
|
||||
}
|
||||
|
||||
return $simpleEntities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create & Save entity jointPermissions for many entities and roles.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
* @param Role[] $roles
|
||||
*/
|
||||
protected function createManyJointPermissions(array $originalEntities, array $roles)
|
||||
{
|
||||
$entities = $this->entitiesToSimpleEntities($originalEntities);
|
||||
$this->readyEntityCache($entities);
|
||||
$jointPermissions = [];
|
||||
|
||||
// Create a mapping of entity restricted statuses
|
||||
$entityRestrictedMap = [];
|
||||
foreach ($entities as $entity) {
|
||||
$entityRestrictedMap[$entity->type . ':' . $entity->id] = $entity->restricted;
|
||||
}
|
||||
|
||||
// Fetch related entity permissions
|
||||
$permissions = $this->getEntityPermissionsForEntities($entities);
|
||||
|
||||
// Create a mapping of explicit entity permissions
|
||||
$permissionMap = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id;
|
||||
$isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
|
||||
$permissionMap[$key] = $isRestricted;
|
||||
}
|
||||
|
||||
// Create a mapping of role permissions
|
||||
$rolePermissionMap = [];
|
||||
foreach ($roles as $role) {
|
||||
foreach ($role->permissions as $permission) {
|
||||
$rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Create Joint Permission Data
|
||||
foreach ($entities as $entity) {
|
||||
foreach ($roles as $role) {
|
||||
$jointPermissions[] = $this->createJointPermissionData(
|
||||
$entity,
|
||||
$role->getRawAttribute('id'),
|
||||
$permissionMap,
|
||||
$rolePermissionMap,
|
||||
$role->system_name === 'admin'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($jointPermissions) {
|
||||
foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
|
||||
DB::table('joint_permissions')->insert($jointPermissionChunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* From the given entity list, provide back a mapping of entity types to
|
||||
* the ids of that given type. The type used is the DB morph class.
|
||||
*
|
||||
* @param SimpleEntityData[] $entities
|
||||
*
|
||||
* @return array<string, int[]>
|
||||
*/
|
||||
protected function entitiesToTypeIdMap(array $entities): array
|
||||
{
|
||||
$idsByType = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
if (!isset($idsByType[$entity->type])) {
|
||||
$idsByType[$entity->type] = [];
|
||||
}
|
||||
|
||||
$idsByType[$entity->type][] = $entity->id;
|
||||
}
|
||||
|
||||
return $idsByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permissions for all the given entities.
|
||||
*
|
||||
* @param SimpleEntityData[] $entities
|
||||
*
|
||||
* @return EntityPermission[]
|
||||
*/
|
||||
protected function getEntityPermissionsForEntities(array $entities): array
|
||||
{
|
||||
$idsByType = $this->entitiesToTypeIdMap($entities);
|
||||
$permissionFetch = EntityPermission::query()
|
||||
->where('action', '=', 'view')
|
||||
->where(function (Builder $query) use ($idsByType) {
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
$query->orWhere(function (Builder $query) use ($type, $ids) {
|
||||
$query->where('restrictable_type', '=', $type)->whereIn('restrictable_id', $ids);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return $permissionFetch->get()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create entity permission data for an entity and role
|
||||
* for a particular action.
|
||||
*/
|
||||
protected function createJointPermissionData(SimpleEntityData $entity, int $roleId, array $permissionMap, array $rolePermissionMap, bool $isAdminRole): array
|
||||
{
|
||||
$permissionPrefix = $entity->type . '-view';
|
||||
$roleHasPermission = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-all']);
|
||||
$roleHasPermissionOwn = isset($rolePermissionMap[$roleId . ':' . $permissionPrefix . '-own']);
|
||||
|
||||
if ($isAdminRole) {
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, true, true);
|
||||
}
|
||||
|
||||
if ($entity->restricted) {
|
||||
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $roleId);
|
||||
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, $hasAccess, $hasAccess);
|
||||
}
|
||||
|
||||
if ($entity->type === 'book' || $entity->type === 'bookshelf') {
|
||||
return $this->createJointPermissionDataArray($entity, $roleId, $roleHasPermission, $roleHasPermissionOwn);
|
||||
}
|
||||
|
||||
// For chapters and pages, Check if explicit permissions are set on the Book.
|
||||
$book = $this->getBook($entity->book_id);
|
||||
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $roleId);
|
||||
$hasPermissiveAccessToParents = !$book->restricted;
|
||||
|
||||
// For pages with a chapter, Check if explicit permissions are set on the Chapter
|
||||
if ($entity->type === 'page' && $entity->chapter_id !== 0) {
|
||||
$chapter = $this->getChapter($entity->chapter_id);
|
||||
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
|
||||
if ($chapter->restricted) {
|
||||
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $roleId);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->createJointPermissionDataArray(
|
||||
$entity,
|
||||
$roleId,
|
||||
($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
|
||||
($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for an active restriction in an entity map.
|
||||
*/
|
||||
protected function mapHasActiveRestriction(array $entityMap, SimpleEntityData $entity, int $roleId): bool
|
||||
{
|
||||
$key = $entity->type . ':' . $entity->id . ':' . $roleId;
|
||||
|
||||
return $entityMap[$key] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
return [
|
||||
'entity_id' => $entity->id,
|
||||
'entity_type' => $entity->type,
|
||||
'has_permission' => $permissionAll,
|
||||
'has_permission_own' => $permissionOwn,
|
||||
'owned_by' => $entity->owned_by,
|
||||
'role_id' => $roleId,
|
||||
];
|
||||
}
|
||||
}
|
||||
258
app/Auth/Permissions/PermissionApplicator.php
Normal file
258
app/Auth/Permissions/PermissionApplicator.php
Normal file
@@ -0,0 +1,258 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use BookStack\Traits\HasCreatorAndUpdater;
|
||||
use BookStack\Traits\HasOwner;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class PermissionApplicator
|
||||
{
|
||||
/**
|
||||
* Checks if an entity has a restriction set upon it.
|
||||
*
|
||||
* @param HasCreatorAndUpdater|HasOwner $ownable
|
||||
*/
|
||||
public function checkOwnableUserAccess(Model $ownable, string $permission): bool
|
||||
{
|
||||
$explodedPermission = explode('-', $permission);
|
||||
$action = $explodedPermission[1] ?? $explodedPermission[0];
|
||||
$fullPermission = count($explodedPermission) > 1 ? $permission : $ownable->getMorphClass() . '-' . $permission;
|
||||
|
||||
$user = $this->currentUser();
|
||||
$userRoleIds = $this->getCurrentUserRoleIds();
|
||||
|
||||
$allRolePermission = $user->can($fullPermission . '-all');
|
||||
$ownRolePermission = $user->can($fullPermission . '-own');
|
||||
$nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
|
||||
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
|
||||
$ownableFieldVal = $ownable->getAttribute($ownerField);
|
||||
|
||||
if (is_null($ownableFieldVal)) {
|
||||
throw new InvalidArgumentException("{$ownerField} field used but has not been loaded");
|
||||
}
|
||||
|
||||
$isOwner = $user->id === $ownableFieldVal;
|
||||
$hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission);
|
||||
|
||||
// Handle non entity specific jointPermissions
|
||||
if (in_array($explodedPermission[0], $nonJointPermissions)) {
|
||||
return $hasRolePermission;
|
||||
}
|
||||
|
||||
$hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $action);
|
||||
|
||||
return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are permissions that are applicable for the given entity item, action and roles.
|
||||
* Returns null when no entity permissions are in force.
|
||||
*/
|
||||
protected function hasEntityPermission(Entity $entity, array $userRoleIds, string $action): ?bool
|
||||
{
|
||||
$adminRoleId = Role::getSystemRole('admin')->id;
|
||||
if (in_array($adminRoleId, $userRoleIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$chain = [$entity];
|
||||
if ($entity instanceof Page && $entity->chapter_id) {
|
||||
$chain[] = $entity->chapter;
|
||||
}
|
||||
|
||||
if ($entity instanceof Page || $entity instanceof Chapter) {
|
||||
$chain[] = $entity->book;
|
||||
}
|
||||
|
||||
foreach ($chain as $currentEntity) {
|
||||
if (is_null($currentEntity->restricted)) {
|
||||
throw new InvalidArgumentException('Entity restricted field used but has not been loaded');
|
||||
}
|
||||
|
||||
if ($currentEntity->restricted) {
|
||||
return $currentEntity->permissions()
|
||||
->whereIn('role_id', $userRoleIds)
|
||||
->where('action', '=', $action)
|
||||
->count() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user has the given permission for any items in the system.
|
||||
* Can be passed an entity instance to filter on a specific type.
|
||||
*/
|
||||
public function checkUserHasEntityPermissionOnAny(string $action, string $entityClass = ''): bool
|
||||
{
|
||||
if (strpos($action, '-') !== false) {
|
||||
throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
|
||||
}
|
||||
|
||||
$permissionQuery = EntityPermission::query()
|
||||
->where('action', '=', $action)
|
||||
->whereIn('role_id', $this->getCurrentUserRoleIds());
|
||||
|
||||
if (!empty($entityClass)) {
|
||||
/** @var Entity $entityInstance */
|
||||
$entityInstance = app()->make($entityClass);
|
||||
$permissionQuery = $permissionQuery->where('restrictable_type', '=', $entityInstance->getMorphClass());
|
||||
}
|
||||
|
||||
$hasPermission = $permissionQuery->count() > 0;
|
||||
|
||||
return $hasPermission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit the given entity query so that the query will only
|
||||
* return items that the user has view permission for.
|
||||
*/
|
||||
public function restrictEntityQuery(Builder $query): Builder
|
||||
{
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the given page query to ensure draft items are not visible
|
||||
* unless created by the given user.
|
||||
*/
|
||||
public function restrictDraftsOnPageQuery(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $query) {
|
||||
$query->where('draft', '=', false)
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->where('draft', '=', true)
|
||||
->where('owned_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
{
|
||||
$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)
|
||||
->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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add conditions to a query for a model that's a relation of a page, so only the model results
|
||||
* on visible pages are returned by the query.
|
||||
* Is effectively the same as "restrictEntityRelationQuery" but takes into account page drafts
|
||||
* while not expecting a polymorphic relation, Just a simpler one-page-to-many-relations set-up.
|
||||
*/
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
$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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user.
|
||||
*/
|
||||
protected function currentUser(): User
|
||||
{
|
||||
return user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles for the current logged-in user.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
protected function getCurrentUserRoleIds(): array
|
||||
{
|
||||
if (auth()->guest()) {
|
||||
return [Role::getSystemRole('public')->id];
|
||||
}
|
||||
|
||||
return $this->currentUser()->roles->pluck('id')->values()->all();
|
||||
}
|
||||
}
|
||||
@@ -1,719 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Model;
|
||||
use BookStack\Traits\HasCreatorAndUpdater;
|
||||
use BookStack\Traits\HasOwner;
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Throwable;
|
||||
|
||||
class PermissionService
|
||||
{
|
||||
/**
|
||||
* @var ?array
|
||||
*/
|
||||
protected $userRoles = null;
|
||||
|
||||
/**
|
||||
* @var ?User
|
||||
*/
|
||||
protected $currentUserModel = null;
|
||||
|
||||
/**
|
||||
* @var Connection
|
||||
*/
|
||||
protected $db;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $entityCache;
|
||||
|
||||
/**
|
||||
* PermissionService constructor.
|
||||
*/
|
||||
public function __construct(Connection $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the database connection.
|
||||
*/
|
||||
public function setConnection(Connection $connection)
|
||||
{
|
||||
$this->db = $connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the local entity cache and ensure it's empty.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
*/
|
||||
protected function readyEntityCache(array $entities = [])
|
||||
{
|
||||
$this->entityCache = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$class = get_class($entity);
|
||||
if (!isset($this->entityCache[$class])) {
|
||||
$this->entityCache[$class] = collect();
|
||||
}
|
||||
$this->entityCache[$class]->put($entity->id, $entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a book via ID, Checks local cache.
|
||||
*/
|
||||
protected function getBook(int $bookId): ?Book
|
||||
{
|
||||
if (isset($this->entityCache[Book::class]) && $this->entityCache[Book::class]->has($bookId)) {
|
||||
return $this->entityCache[Book::class]->get($bookId);
|
||||
}
|
||||
|
||||
return Book::query()->withTrashed()->find($bookId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a chapter via ID, Checks local cache.
|
||||
*/
|
||||
protected function getChapter(int $chapterId): ?Chapter
|
||||
{
|
||||
if (isset($this->entityCache[Chapter::class]) && $this->entityCache[Chapter::class]->has($chapterId)) {
|
||||
return $this->entityCache[Chapter::class]->get($chapterId);
|
||||
}
|
||||
|
||||
return Chapter::query()
|
||||
->withTrashed()
|
||||
->find($chapterId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles for the current logged in user.
|
||||
*/
|
||||
protected function getCurrentUserRoles(): array
|
||||
{
|
||||
if (!is_null($this->userRoles)) {
|
||||
return $this->userRoles;
|
||||
}
|
||||
|
||||
if (auth()->guest()) {
|
||||
$this->userRoles = [Role::getSystemRole('public')->id];
|
||||
} else {
|
||||
$this->userRoles = $this->currentUser()->roles->pluck('id')->values()->all();
|
||||
}
|
||||
|
||||
return $this->userRoles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-generate all entity permission from scratch.
|
||||
*/
|
||||
public function buildJointPermissions()
|
||||
{
|
||||
JointPermission::query()->truncate();
|
||||
$this->readyEntityCache();
|
||||
|
||||
// Get all roles (Should be the most limited dimension)
|
||||
$roles = Role::query()->with('permissions')->get()->all();
|
||||
|
||||
// Chunk through all books
|
||||
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
|
||||
$this->buildJointPermissionsForBooks($books, $roles);
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
|
||||
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
|
||||
$this->buildJointPermissionsForShelves($shelves, $roles);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a query for fetching a book with it's children.
|
||||
*/
|
||||
protected function bookFetchQuery(): Builder
|
||||
{
|
||||
return Book::query()->withTrashed()
|
||||
->select(['id', 'restricted', 'owned_by'])->with([
|
||||
'chapters' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
|
||||
},
|
||||
'pages' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build joint permissions for the given shelf and role combinations.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
protected function buildJointPermissionsForShelves(EloquentCollection $shelves, array $roles, bool $deleteOld = false)
|
||||
{
|
||||
if ($deleteOld) {
|
||||
$this->deleteManyJointPermissionsForEntities($shelves->all());
|
||||
}
|
||||
$this->createManyJointPermissions($shelves->all(), $roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build joint permissions for the given book and role combinations.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
|
||||
{
|
||||
$entities = clone $books;
|
||||
|
||||
/** @var Book $book */
|
||||
foreach ($books->all() as $book) {
|
||||
foreach ($book->getRelation('chapters') as $chapter) {
|
||||
$entities->push($chapter);
|
||||
}
|
||||
foreach ($book->getRelation('pages') as $page) {
|
||||
$entities->push($page);
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteOld) {
|
||||
$this->deleteManyJointPermissionsForEntities($entities->all());
|
||||
}
|
||||
$this->createManyJointPermissions($entities->all(), $roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entity jointPermissions for a particular entity.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function buildJointPermissionsForEntity(Entity $entity)
|
||||
{
|
||||
$entities = [$entity];
|
||||
if ($entity instanceof Book) {
|
||||
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
|
||||
$this->buildJointPermissionsForBooks($books, Role::query()->get()->all(), true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BookChild $entity */
|
||||
if ($entity->book) {
|
||||
$entities[] = $entity->book;
|
||||
}
|
||||
|
||||
if ($entity instanceof Page && $entity->chapter_id) {
|
||||
$entities[] = $entity->chapter;
|
||||
}
|
||||
|
||||
if ($entity instanceof Chapter) {
|
||||
foreach ($entity->pages as $page) {
|
||||
$entities[] = $page;
|
||||
}
|
||||
}
|
||||
|
||||
$this->buildJointPermissionsForEntities($entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entity jointPermissions for a collection of entities.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function buildJointPermissionsForEntities(array $entities)
|
||||
{
|
||||
$roles = Role::query()->get()->values()->all();
|
||||
$this->deleteManyJointPermissionsForEntities($entities);
|
||||
$this->createManyJointPermissions($entities, $roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the entity jointPermissions for a particular role.
|
||||
*/
|
||||
public function buildJointPermissionForRole(Role $role)
|
||||
{
|
||||
$roles = [$role];
|
||||
$this->deleteManyJointPermissionsForRoles($roles);
|
||||
|
||||
// Chunk through all books
|
||||
$this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
|
||||
$this->buildJointPermissionsForBooks($books, $roles);
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->select(['id', 'restricted', 'owned_by'])
|
||||
->chunk(50, function ($shelves) use ($roles) {
|
||||
$this->buildJointPermissionsForShelves($shelves, $roles);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the entity jointPermissions attached to a particular role.
|
||||
*/
|
||||
public function deleteJointPermissionsForRole(Role $role)
|
||||
{
|
||||
$this->deleteManyJointPermissionsForRoles([$role]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all of the entity jointPermissions for a list of entities.
|
||||
*
|
||||
* @param Role[] $roles
|
||||
*/
|
||||
protected function deleteManyJointPermissionsForRoles($roles)
|
||||
{
|
||||
$roleIds = array_map(function ($role) {
|
||||
return $role->id;
|
||||
}, $roles);
|
||||
JointPermission::query()->whereIn('role_id', $roleIds)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the entity jointPermissions for a particular entity.
|
||||
*
|
||||
* @param Entity $entity
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function deleteJointPermissionsForEntity(Entity $entity)
|
||||
{
|
||||
$this->deleteManyJointPermissionsForEntities([$entity]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all of the entity jointPermissions for a list of entities.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
protected function deleteManyJointPermissionsForEntities(array $entities)
|
||||
{
|
||||
if (count($entities) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->transaction(function () use ($entities) {
|
||||
foreach (array_chunk($entities, 1000) as $entityChunk) {
|
||||
$query = $this->db->table('joint_permissions');
|
||||
foreach ($entityChunk as $entity) {
|
||||
$query->orWhere(function (QueryBuilder $query) use ($entity) {
|
||||
$query->where('entity_id', '=', $entity->id)
|
||||
->where('entity_type', '=', $entity->getMorphClass());
|
||||
});
|
||||
}
|
||||
$query->delete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create & Save entity jointPermissions for many entities and roles.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
* @param Role[] $roles
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
protected function createManyJointPermissions(array $entities, array $roles)
|
||||
{
|
||||
$this->readyEntityCache($entities);
|
||||
$jointPermissions = [];
|
||||
|
||||
// Fetch Entity Permissions and create a mapping of entity restricted statuses
|
||||
$entityRestrictedMap = [];
|
||||
$permissionFetch = EntityPermission::query();
|
||||
foreach ($entities as $entity) {
|
||||
$entityRestrictedMap[$entity->getMorphClass() . ':' . $entity->id] = boolval($entity->getRawAttribute('restricted'));
|
||||
$permissionFetch->orWhere(function ($query) use ($entity) {
|
||||
$query->where('restrictable_id', '=', $entity->id)->where('restrictable_type', '=', $entity->getMorphClass());
|
||||
});
|
||||
}
|
||||
$permissions = $permissionFetch->get();
|
||||
|
||||
// Create a mapping of explicit entity permissions
|
||||
$permissionMap = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id . ':' . $permission->action;
|
||||
$isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
|
||||
$permissionMap[$key] = $isRestricted;
|
||||
}
|
||||
|
||||
// Create a mapping of role permissions
|
||||
$rolePermissionMap = [];
|
||||
foreach ($roles as $role) {
|
||||
foreach ($role->permissions as $permission) {
|
||||
$rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Create Joint Permission Data
|
||||
foreach ($entities as $entity) {
|
||||
foreach ($roles as $role) {
|
||||
foreach ($this->getActions($entity) as $action) {
|
||||
$jointPermissions[] = $this->createJointPermissionData($entity, $role, $action, $permissionMap, $rolePermissionMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->transaction(function () use ($jointPermissions) {
|
||||
foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
|
||||
$this->db->table('joint_permissions')->insert($jointPermissionChunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions related to an entity.
|
||||
*/
|
||||
protected function getActions(Entity $entity): array
|
||||
{
|
||||
$baseActions = ['view', 'update', 'delete'];
|
||||
if ($entity instanceof Chapter || $entity instanceof Book) {
|
||||
$baseActions[] = 'page-create';
|
||||
}
|
||||
if ($entity instanceof Book) {
|
||||
$baseActions[] = 'chapter-create';
|
||||
}
|
||||
|
||||
return $baseActions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create entity permission data for an entity and role
|
||||
* for a particular action.
|
||||
*/
|
||||
protected function createJointPermissionData(Entity $entity, Role $role, string $action, array $permissionMap, array $rolePermissionMap): array
|
||||
{
|
||||
$permissionPrefix = (strpos($action, '-') === false ? ($entity->getType() . '-') : '') . $action;
|
||||
$roleHasPermission = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-all']);
|
||||
$roleHasPermissionOwn = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-own']);
|
||||
$explodedAction = explode('-', $action);
|
||||
$restrictionAction = end($explodedAction);
|
||||
|
||||
if ($role->system_name === 'admin') {
|
||||
return $this->createJointPermissionDataArray($entity, $role, $action, true, true);
|
||||
}
|
||||
|
||||
if ($entity->restricted) {
|
||||
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $role, $restrictionAction);
|
||||
|
||||
return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
|
||||
}
|
||||
|
||||
if ($entity instanceof Book || $entity instanceof Bookshelf) {
|
||||
return $this->createJointPermissionDataArray($entity, $role, $action, $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, $role, $restrictionAction);
|
||||
$hasPermissiveAccessToParents = !$book->restricted;
|
||||
|
||||
// For pages with a chapter, Check if explicit permissions are set on the Chapter
|
||||
if ($entity instanceof Page && intval($entity->chapter_id) !== 0) {
|
||||
$chapter = $this->getChapter($entity->chapter_id);
|
||||
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
|
||||
if ($chapter->restricted) {
|
||||
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $role, $restrictionAction);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->createJointPermissionDataArray(
|
||||
$entity,
|
||||
$role,
|
||||
$action,
|
||||
($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
|
||||
($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for an active restriction in an entity map.
|
||||
*/
|
||||
protected function mapHasActiveRestriction(array $entityMap, Entity $entity, Role $role, string $action): bool
|
||||
{
|
||||
$key = $entity->getMorphClass() . ':' . $entity->getRawAttribute('id') . ':' . $role->getRawAttribute('id') . ':' . $action;
|
||||
|
||||
return $entityMap[$key] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an array of data with the information of an entity jointPermissions.
|
||||
* Used to build data for bulk insertion.
|
||||
*/
|
||||
protected function createJointPermissionDataArray(Entity $entity, Role $role, string $action, bool $permissionAll, bool $permissionOwn): array
|
||||
{
|
||||
return [
|
||||
'role_id' => $role->getRawAttribute('id'),
|
||||
'entity_id' => $entity->getRawAttribute('id'),
|
||||
'entity_type' => $entity->getMorphClass(),
|
||||
'action' => $action,
|
||||
'has_permission' => $permissionAll,
|
||||
'has_permission_own' => $permissionOwn,
|
||||
'owned_by' => $entity->getRawAttribute('owned_by'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an entity has a restriction set upon it.
|
||||
*
|
||||
* @param HasCreatorAndUpdater|HasOwner $ownable
|
||||
*/
|
||||
public function checkOwnableUserAccess(Model $ownable, string $permission): bool
|
||||
{
|
||||
$explodedPermission = explode('-', $permission);
|
||||
|
||||
$baseQuery = $ownable->newQuery()->where('id', '=', $ownable->id);
|
||||
$action = end($explodedPermission);
|
||||
$user = $this->currentUser();
|
||||
|
||||
$nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
|
||||
|
||||
// Handle non entity specific jointPermissions
|
||||
if (in_array($explodedPermission[0], $nonJointPermissions)) {
|
||||
$allPermission = $user && $user->can($permission . '-all');
|
||||
$ownPermission = $user && $user->can($permission . '-own');
|
||||
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
|
||||
$isOwner = $user && $user->id === $ownable->$ownerField;
|
||||
|
||||
return $allPermission || ($isOwner && $ownPermission);
|
||||
}
|
||||
|
||||
// Handle abnormal create jointPermissions
|
||||
if ($action === 'create') {
|
||||
$action = $permission;
|
||||
}
|
||||
|
||||
$hasAccess = $this->entityRestrictionQuery($baseQuery, $action)->count() > 0;
|
||||
$this->clean();
|
||||
|
||||
return $hasAccess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user has the given permission for any items in the system.
|
||||
* Can be passed an entity instance to filter on a specific type.
|
||||
*/
|
||||
public function checkUserHasPermissionOnAnything(string $permission, ?string $entityClass = null): bool
|
||||
{
|
||||
$userRoleIds = $this->currentUser()->roles()->select('id')->pluck('id')->toArray();
|
||||
$userId = $this->currentUser()->id;
|
||||
|
||||
$permissionQuery = JointPermission::query()
|
||||
->where('action', '=', $permission)
|
||||
->whereIn('role_id', $userRoleIds)
|
||||
->where(function (Builder $query) use ($userId) {
|
||||
$this->addJointHasPermissionCheck($query, $userId);
|
||||
});
|
||||
|
||||
if (!is_null($entityClass)) {
|
||||
$entityInstance = app($entityClass);
|
||||
$permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
|
||||
}
|
||||
|
||||
$hasPermission = $permissionQuery->count() > 0;
|
||||
$this->clean();
|
||||
|
||||
return $hasPermission;
|
||||
}
|
||||
|
||||
/**
|
||||
* The general query filter to remove all entities
|
||||
* that the current user does not have access to.
|
||||
*/
|
||||
protected function entityRestrictionQuery(Builder $query, string $action): Builder
|
||||
{
|
||||
$q = $query->where(function ($parentQuery) use ($action) {
|
||||
$parentQuery->whereHas('jointPermissions', function ($permissionQuery) use ($action) {
|
||||
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
|
||||
->where('action', '=', $action)
|
||||
->where(function (Builder $query) {
|
||||
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$this->clean();
|
||||
|
||||
return $q;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limited the given entity query so that the query will only
|
||||
* return items that the user has permission for the given ability.
|
||||
*/
|
||||
public function restrictEntityQuery(Builder $query, string $ability = 'view'): Builder
|
||||
{
|
||||
$this->clean();
|
||||
|
||||
return $query->where(function (Builder $parentQuery) use ($ability) {
|
||||
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) use ($ability) {
|
||||
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
|
||||
->where('action', '=', $ability)
|
||||
->where(function (Builder $query) {
|
||||
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the given page query to ensure draft items are not visible
|
||||
* unless created by the given user.
|
||||
*/
|
||||
public function enforceDraftVisibilityOnQuery(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $query) {
|
||||
$query->where('draft', '=', false)
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->where('draft', '=', true)
|
||||
->where('owned_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add restrictions for a generic entity.
|
||||
*/
|
||||
public function enforceEntityRestrictions(Entity $entity, Builder $query, string $action = 'view'): Builder
|
||||
{
|
||||
if ($entity instanceof Page) {
|
||||
// Prevent drafts being visible to others.
|
||||
$this->enforceDraftVisibilityOnQuery($query);
|
||||
}
|
||||
|
||||
return $this->entityRestrictionQuery($query, $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view')
|
||||
{
|
||||
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
|
||||
$pageMorphClass = (new Page())->getMorphClass();
|
||||
|
||||
$q = $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
|
||||
/** @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'])
|
||||
->where('joint_permissions.action', '=', $action)
|
||||
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
|
||||
->where(function (QueryBuilder $query) {
|
||||
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
|
||||
});
|
||||
})->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);
|
||||
});
|
||||
});
|
||||
|
||||
$this->clean();
|
||||
|
||||
return $q;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add conditions to a query to filter the selection to related entities
|
||||
* where view permissions are granted.
|
||||
*/
|
||||
public function filterRelatedEntity(string $entityClass, Builder $query, string $tableName, string $entityIdColumn): Builder
|
||||
{
|
||||
$fullEntityIdColumn = $tableName . '.' . $entityIdColumn;
|
||||
$instance = new $entityClass();
|
||||
$morphClass = $instance->getMorphClass();
|
||||
|
||||
$existsQuery = function ($permissionQuery) use ($fullEntityIdColumn, $morphClass) {
|
||||
/** @var Builder $permissionQuery */
|
||||
$permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
|
||||
->whereColumn('joint_permissions.entity_id', '=', $fullEntityIdColumn)
|
||||
->where('joint_permissions.entity_type', '=', $morphClass)
|
||||
->where('joint_permissions.action', '=', 'view')
|
||||
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
|
||||
->where(function (QueryBuilder $query) {
|
||||
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
|
||||
});
|
||||
};
|
||||
|
||||
$q = $query->where(function ($query) use ($existsQuery, $fullEntityIdColumn) {
|
||||
$query->whereExists($existsQuery)
|
||||
->orWhere($fullEntityIdColumn, '=', 0);
|
||||
});
|
||||
|
||||
if ($instance instanceof Page) {
|
||||
// Prevent visibility of non-owned draft pages
|
||||
$q->whereExists(function (QueryBuilder $query) use ($fullEntityIdColumn) {
|
||||
$query->select('id')->from('pages')
|
||||
->whereColumn('pages.id', '=', $fullEntityIdColumn)
|
||||
->where(function (QueryBuilder $query) {
|
||||
$query->where('pages.draft', '=', false)
|
||||
->orWhere('pages.owned_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$this->clean();
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user.
|
||||
*/
|
||||
private function currentUser(): User
|
||||
{
|
||||
if (is_null($this->currentUserModel)) {
|
||||
$this->currentUserModel = user();
|
||||
}
|
||||
|
||||
return $this->currentUserModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean the cached user elements.
|
||||
*/
|
||||
private function clean(): void
|
||||
{
|
||||
$this->currentUserModel = null;
|
||||
$this->userRoles = null;
|
||||
}
|
||||
}
|
||||
@@ -11,20 +11,15 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class PermissionsRepo
|
||||
{
|
||||
protected $permission;
|
||||
protected $role;
|
||||
protected $permissionService;
|
||||
|
||||
protected JointPermissionBuilder $permissionBuilder;
|
||||
protected $systemRoles = ['admin', 'public'];
|
||||
|
||||
/**
|
||||
* PermissionsRepo constructor.
|
||||
*/
|
||||
public function __construct(RolePermission $permission, Role $role, PermissionService $permissionService)
|
||||
public function __construct(JointPermissionBuilder $permissionBuilder)
|
||||
{
|
||||
$this->permission = $permission;
|
||||
$this->role = $role;
|
||||
$this->permissionService = $permissionService;
|
||||
$this->permissionBuilder = $permissionBuilder;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +27,7 @@ class PermissionsRepo
|
||||
*/
|
||||
public function getAllRoles(): Collection
|
||||
{
|
||||
return $this->role->all();
|
||||
return Role::query()->get();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,7 +35,7 @@ class PermissionsRepo
|
||||
*/
|
||||
public function getAllRolesExcept(Role $role): Collection
|
||||
{
|
||||
return $this->role->where('id', '!=', $role->id)->get();
|
||||
return Role::query()->where('id', '!=', $role->id)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,7 +43,7 @@ class PermissionsRepo
|
||||
*/
|
||||
public function getRoleById($id): Role
|
||||
{
|
||||
return $this->role->newQuery()->findOrFail($id);
|
||||
return Role::query()->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,13 +51,14 @@ class PermissionsRepo
|
||||
*/
|
||||
public function saveNewRole(array $roleData): Role
|
||||
{
|
||||
$role = $this->role->newInstance($roleData);
|
||||
$role = new Role($roleData);
|
||||
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
|
||||
$role->save();
|
||||
|
||||
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
||||
$this->assignRolePermissions($role, $permissions);
|
||||
$this->permissionService->buildJointPermissionForRole($role);
|
||||
$this->permissionBuilder->rebuildForRole($role);
|
||||
|
||||
Activity::add(ActivityType::ROLE_CREATE, $role);
|
||||
|
||||
return $role;
|
||||
@@ -74,8 +70,7 @@ class PermissionsRepo
|
||||
*/
|
||||
public function updateRole($roleId, array $roleData)
|
||||
{
|
||||
/** @var Role $role */
|
||||
$role = $this->role->newQuery()->findOrFail($roleId);
|
||||
$role = $this->getRoleById($roleId);
|
||||
|
||||
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
||||
if ($role->system_name === 'admin') {
|
||||
@@ -93,12 +88,13 @@ class PermissionsRepo
|
||||
$role->fill($roleData);
|
||||
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
|
||||
$role->save();
|
||||
$this->permissionService->buildJointPermissionForRole($role);
|
||||
$this->permissionBuilder->rebuildForRole($role);
|
||||
|
||||
Activity::add(ActivityType::ROLE_UPDATE, $role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign an list of permission names to an role.
|
||||
* Assign a list of permission names to a role.
|
||||
*/
|
||||
protected function assignRolePermissions(Role $role, array $permissionNameArray = [])
|
||||
{
|
||||
@@ -106,7 +102,7 @@ class PermissionsRepo
|
||||
$permissionNameArray = array_values($permissionNameArray);
|
||||
|
||||
if ($permissionNameArray) {
|
||||
$permissions = $this->permission->newQuery()
|
||||
$permissions = RolePermission::query()
|
||||
->whereIn('name', $permissionNameArray)
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
@@ -126,8 +122,7 @@ class PermissionsRepo
|
||||
*/
|
||||
public function deleteRole($roleId, $migrateRoleId)
|
||||
{
|
||||
/** @var Role $role */
|
||||
$role = $this->role->newQuery()->findOrFail($roleId);
|
||||
$role = $this->getRoleById($roleId);
|
||||
|
||||
// Prevent deleting admin role or default registration role.
|
||||
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
|
||||
@@ -137,14 +132,14 @@ class PermissionsRepo
|
||||
}
|
||||
|
||||
if ($migrateRoleId) {
|
||||
$newRole = $this->role->newQuery()->find($migrateRoleId);
|
||||
$newRole = Role::query()->find($migrateRoleId);
|
||||
if ($newRole) {
|
||||
$users = $role->users()->pluck('id')->toArray();
|
||||
$newRole->users()->sync($users);
|
||||
}
|
||||
}
|
||||
|
||||
$this->permissionService->deleteJointPermissionsForRole($role);
|
||||
$role->jointPermissions()->delete();
|
||||
Activity::add(ActivityType::ROLE_DELETE, $role);
|
||||
$role->delete();
|
||||
}
|
||||
|
||||
13
app/Auth/Permissions/SimpleEntityData.php
Normal file
13
app/Auth/Permissions/SimpleEntityData.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
class SimpleEntityData
|
||||
{
|
||||
public int $id;
|
||||
public string $type;
|
||||
public bool $restricted;
|
||||
public int $owned_by;
|
||||
public ?int $book_id;
|
||||
public ?int $chapter_id;
|
||||
}
|
||||
39
app/Auth/Queries/AllUsersPaginatedAndSorted.php
Normal file
39
app/Auth/Queries/AllUsersPaginatedAndSorted.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Queries;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Get all the users with their permissions in a paginated format.
|
||||
* Note: Due to the use of email search this should only be used when
|
||||
* user is assumed to be trusted. (Admin users).
|
||||
* Email search can be abused to extract email addresses.
|
||||
*/
|
||||
class AllUsersPaginatedAndSorted
|
||||
{
|
||||
/**
|
||||
* @param array{sort: string, order: string, search: string} $sortData
|
||||
*/
|
||||
public function run(int $count, array $sortData): LengthAwarePaginator
|
||||
{
|
||||
$sort = $sortData['sort'];
|
||||
|
||||
$query = User::query()->select(['*'])
|
||||
->scopes(['withLastActivityAt'])
|
||||
->with(['roles', 'avatar'])
|
||||
->withCount('mfaValues')
|
||||
->orderBy($sort, $sortData['order']);
|
||||
|
||||
if ($sortData['search']) {
|
||||
$term = '%' . $sortData['search'] . '%';
|
||||
$query->where(function ($query) use ($term) {
|
||||
$query->where('name', 'like', $term)
|
||||
->orWhere('email', 'like', $term);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->paginate($count);
|
||||
}
|
||||
}
|
||||
30
app/Auth/Queries/UserContentCounts.php
Normal file
30
app/Auth/Queries/UserContentCounts.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Queries;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
|
||||
/**
|
||||
* Get asset created counts for the given user.
|
||||
*/
|
||||
class UserContentCounts
|
||||
{
|
||||
/**
|
||||
* @return array{pages: int, chapters: int, books: int, shelves: int}
|
||||
*/
|
||||
public function run(User $user): array
|
||||
{
|
||||
$createdBy = ['created_by' => $user->id];
|
||||
|
||||
return [
|
||||
'pages' => Page::visible()->where($createdBy)->count(),
|
||||
'chapters' => Chapter::visible()->where($createdBy)->count(),
|
||||
'books' => Book::visible()->where($createdBy)->count(),
|
||||
'shelves' => Bookshelf::visible()->where($createdBy)->count(),
|
||||
];
|
||||
}
|
||||
}
|
||||
37
app/Auth/Queries/UserRecentlyCreatedContent.php
Normal file
37
app/Auth/Queries/UserRecentlyCreatedContent.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Queries;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
/**
|
||||
* Get the recently created content for the provided user.
|
||||
*/
|
||||
class UserRecentlyCreatedContent
|
||||
{
|
||||
/**
|
||||
* @return array{pages: Collection, chapters: Collection, books: Collection, shelves: Collection}
|
||||
*/
|
||||
public function run(User $user, int $count): array
|
||||
{
|
||||
$query = function (Builder $query) use ($user, $count) {
|
||||
return $query->orderBy('created_at', 'desc')
|
||||
->where('created_by', '=', $user->id)
|
||||
->take($count)
|
||||
->get();
|
||||
};
|
||||
|
||||
return [
|
||||
'pages' => $query(Page::visible()->where('draft', '=', false)),
|
||||
'chapters' => $query(Chapter::visible()),
|
||||
'books' => $query(Book::visible()),
|
||||
'shelves' => $query(Bookshelf::visible()),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,8 @@ class Role extends Model implements Loggable
|
||||
|
||||
protected $fillable = ['display_name', 'description', 'external_auth_id'];
|
||||
|
||||
protected $hidden = ['pivot'];
|
||||
|
||||
/**
|
||||
* The roles that belong to the role.
|
||||
*/
|
||||
|
||||
@@ -72,22 +72,25 @@ 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',
|
||||
'created_at', 'updated_at', 'image_id', 'roles', 'avatar', 'user_id',
|
||||
];
|
||||
|
||||
/**
|
||||
* This holds the user's permissions when loaded.
|
||||
*
|
||||
* @var ?Collection
|
||||
*/
|
||||
protected $permissions;
|
||||
protected ?Collection $permissions;
|
||||
|
||||
/**
|
||||
* This holds the user's avatar URL when loaded to prevent re-calculating within the same request.
|
||||
*/
|
||||
protected string $avatarUrl = '';
|
||||
|
||||
/**
|
||||
* This holds the default user when loaded.
|
||||
*
|
||||
* @var null|User
|
||||
*/
|
||||
protected static $defaultUser = null;
|
||||
protected static ?User $defaultUser = null;
|
||||
|
||||
/**
|
||||
* Returns the default public user.
|
||||
@@ -165,7 +168,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions belonging to a the current user.
|
||||
* Get all permissions belonging to the current user.
|
||||
*/
|
||||
protected function permissions(): Collection
|
||||
{
|
||||
@@ -235,12 +238,18 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (!empty($this->avatarUrl)) {
|
||||
return $this->avatarUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
$avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default;
|
||||
} catch (Exception $err) {
|
||||
$avatar = $default;
|
||||
}
|
||||
|
||||
$this->avatarUrl = $avatar;
|
||||
|
||||
return $avatar;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,30 +2,29 @@
|
||||
|
||||
namespace BookStack\Auth;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Access\UserInviteService;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Exceptions\UserUpdateException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Uploads\UserAvatars;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class UserRepo
|
||||
{
|
||||
protected $userAvatar;
|
||||
protected UserAvatars $userAvatar;
|
||||
protected UserInviteService $inviteService;
|
||||
|
||||
/**
|
||||
* UserRepo constructor.
|
||||
*/
|
||||
public function __construct(UserAvatars $userAvatar)
|
||||
public function __construct(UserAvatars $userAvatar, UserInviteService $inviteService)
|
||||
{
|
||||
$this->userAvatar = $userAvatar;
|
||||
$this->inviteService = $inviteService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,70 +52,164 @@ class UserRepo
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the users with their permissions.
|
||||
* Create a new basic instance of user with the given pre-validated data.
|
||||
*
|
||||
* @param array{name: string, email: string, password: ?string, external_auth_id: ?string, language: ?string, roles: ?array} $data
|
||||
*/
|
||||
public function getAllUsers(): Collection
|
||||
public function createWithoutActivity(array $data, bool $emailConfirmed = false): User
|
||||
{
|
||||
return User::query()->with('roles', 'avatar')->orderBy('name', 'asc')->get();
|
||||
}
|
||||
$user = new User();
|
||||
$user->name = $data['name'];
|
||||
$user->email = $data['email'];
|
||||
$user->password = bcrypt(empty($data['password']) ? Str::random(32) : $data['password']);
|
||||
$user->email_confirmed = $emailConfirmed;
|
||||
$user->external_auth_id = $data['external_auth_id'] ?? '';
|
||||
|
||||
/**
|
||||
* Get all the users with their permissions in a paginated format.
|
||||
* Note: Due to the use of email search this should only be used when
|
||||
* user is assumed to be trusted. (Admin users).
|
||||
* Email search can be abused to extract email addresses.
|
||||
*/
|
||||
public function getAllUsersPaginatedAndSorted(int $count, array $sortData): LengthAwarePaginator
|
||||
{
|
||||
$sort = $sortData['sort'];
|
||||
$user->refreshSlug();
|
||||
$user->save();
|
||||
|
||||
$query = User::query()->select(['*'])
|
||||
->scopes(['withLastActivityAt'])
|
||||
->with(['roles', 'avatar'])
|
||||
->withCount('mfaValues')
|
||||
->orderBy($sort, $sortData['order']);
|
||||
|
||||
if ($sortData['search']) {
|
||||
$term = '%' . $sortData['search'] . '%';
|
||||
$query->where(function ($query) use ($term) {
|
||||
$query->where('name', 'like', $term)
|
||||
->orWhere('email', 'like', $term);
|
||||
});
|
||||
if (!empty($data['language'])) {
|
||||
setting()->putUser($user, 'language', $data['language']);
|
||||
}
|
||||
|
||||
return $query->paginate($count);
|
||||
}
|
||||
if (isset($data['roles'])) {
|
||||
$this->setUserRoles($user, $data['roles']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new user and attaches a role to them.
|
||||
*/
|
||||
public function registerNew(array $data, bool $emailConfirmed = false): User
|
||||
{
|
||||
$user = $this->create($data, $emailConfirmed);
|
||||
$user->attachDefaultRole();
|
||||
$this->downloadAndAssignUserAvatar($user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a user to a system-level role.
|
||||
* As per "createWithoutActivity" but records a "create" activity.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
* @param array{name: string, email: string, password: ?string, external_auth_id: ?string, language: ?string, roles: ?array} $data
|
||||
*/
|
||||
public function attachSystemRole(User $user, string $systemRoleName)
|
||||
public function create(array $data, bool $sendInvite = false): User
|
||||
{
|
||||
$role = Role::getSystemRole($systemRoleName);
|
||||
if (is_null($role)) {
|
||||
throw new NotFoundException("Role '{$systemRoleName}' not found");
|
||||
$user = $this->createWithoutActivity($data, true);
|
||||
|
||||
if ($sendInvite) {
|
||||
$this->inviteService->sendInvitation($user);
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::USER_CREATE, $user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given user with the given data.
|
||||
*
|
||||
* @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array<int>, language: ?string} $data
|
||||
*
|
||||
* @throws UserUpdateException
|
||||
*/
|
||||
public function update(User $user, array $data, bool $manageUsersAllowed): User
|
||||
{
|
||||
if (!empty($data['name'])) {
|
||||
$user->name = $data['name'];
|
||||
$user->refreshSlug();
|
||||
}
|
||||
|
||||
if (!empty($data['email']) && $manageUsersAllowed) {
|
||||
$user->email = $data['email'];
|
||||
}
|
||||
|
||||
if (!empty($data['external_auth_id']) && $manageUsersAllowed) {
|
||||
$user->external_auth_id = $data['external_auth_id'];
|
||||
}
|
||||
|
||||
if (isset($data['roles']) && $manageUsersAllowed) {
|
||||
$this->setUserRoles($user, $data['roles']);
|
||||
}
|
||||
|
||||
if (!empty($data['password'])) {
|
||||
$user->password = bcrypt($data['password']);
|
||||
}
|
||||
|
||||
if (!empty($data['language'])) {
|
||||
setting()->putUser($user, 'language', $data['language']);
|
||||
}
|
||||
|
||||
$user->save();
|
||||
Activity::add(ActivityType::USER_UPDATE, $user);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given user from storage, Delete all related content.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(User $user, ?int $newOwnerId = null)
|
||||
{
|
||||
$this->ensureDeletable($user);
|
||||
|
||||
$user->socialAccounts()->delete();
|
||||
$user->apiTokens()->delete();
|
||||
$user->favourites()->delete();
|
||||
$user->mfaValues()->delete();
|
||||
$user->delete();
|
||||
|
||||
// Delete user profile images
|
||||
$this->userAvatar->destroyAllForUser($user);
|
||||
|
||||
if (!empty($newOwnerId)) {
|
||||
$newOwner = User::query()->find($newOwnerId);
|
||||
if (!is_null($newOwner)) {
|
||||
$this->migrateOwnership($user, $newOwner);
|
||||
}
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::USER_DELETE, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws NotifyException
|
||||
*/
|
||||
protected function ensureDeletable(User $user): void
|
||||
{
|
||||
if ($this->isOnlyAdmin($user)) {
|
||||
throw new NotifyException(trans('errors.users_cannot_delete_only_admin'), $user->getEditUrl());
|
||||
}
|
||||
|
||||
if ($user->system_name === 'public') {
|
||||
throw new NotifyException(trans('errors.users_cannot_delete_guest'), $user->getEditUrl());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate ownership of items in the system from one user to another.
|
||||
*/
|
||||
protected function migrateOwnership(User $fromUser, User $toUser)
|
||||
{
|
||||
$entities = (new EntityProvider())->all();
|
||||
foreach ($entities as $instance) {
|
||||
$instance->newQuery()->where('owned_by', '=', $fromUser->id)
|
||||
->update(['owned_by' => $toUser->id]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an avatar image for a user and set it as their avatar.
|
||||
* Returns early if avatars disabled or not set in config.
|
||||
*/
|
||||
protected function downloadAndAssignUserAvatar(User $user): void
|
||||
{
|
||||
try {
|
||||
$this->userAvatar->fetchAndAssignToUser($user);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to save user avatar image');
|
||||
}
|
||||
$user->attachRole($role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the give user is the only admin.
|
||||
*/
|
||||
public function isOnlyAdmin(User $user): bool
|
||||
protected function isOnlyAdmin(User $user): bool
|
||||
{
|
||||
if (!$user->hasSystemRole('admin')) {
|
||||
return false;
|
||||
@@ -135,7 +228,7 @@ class UserRepo
|
||||
*
|
||||
* @throws UserUpdateException
|
||||
*/
|
||||
public function setUserRoles(User $user, array $roles)
|
||||
protected function setUserRoles(User $user, array $roles)
|
||||
{
|
||||
if ($this->demotingLastAdmin($user, $roles)) {
|
||||
throw new UserUpdateException(trans('errors.role_cannot_remove_only_admin'), $user->getEditUrl());
|
||||
@@ -159,117 +252,4 @@ class UserRepo
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new basic instance of user.
|
||||
*/
|
||||
public function create(array $data, bool $emailConfirmed = false): User
|
||||
{
|
||||
$details = [
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => bcrypt($data['password']),
|
||||
'email_confirmed' => $emailConfirmed,
|
||||
'external_auth_id' => $data['external_auth_id'] ?? '',
|
||||
];
|
||||
|
||||
$user = new User();
|
||||
$user->forceFill($details);
|
||||
$user->refreshSlug();
|
||||
$user->save();
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the given user from storage, Delete all related content.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function destroy(User $user, ?int $newOwnerId = null)
|
||||
{
|
||||
$user->socialAccounts()->delete();
|
||||
$user->apiTokens()->delete();
|
||||
$user->favourites()->delete();
|
||||
$user->mfaValues()->delete();
|
||||
$user->delete();
|
||||
|
||||
// Delete user profile images
|
||||
$this->userAvatar->destroyAllForUser($user);
|
||||
|
||||
if (!empty($newOwnerId)) {
|
||||
$newOwner = User::query()->find($newOwnerId);
|
||||
if (!is_null($newOwner)) {
|
||||
$this->migrateOwnership($user, $newOwner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate ownership of items in the system from one user to another.
|
||||
*/
|
||||
protected function migrateOwnership(User $fromUser, User $toUser)
|
||||
{
|
||||
$entities = (new EntityProvider())->all();
|
||||
foreach ($entities as $instance) {
|
||||
$instance->newQuery()->where('owned_by', '=', $fromUser->id)
|
||||
->update(['owned_by' => $toUser->id]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recently created content for this given user.
|
||||
*/
|
||||
public function getRecentlyCreated(User $user, int $count = 20): array
|
||||
{
|
||||
$query = function (Builder $query) use ($user, $count) {
|
||||
return $query->orderBy('created_at', 'desc')
|
||||
->where('created_by', '=', $user->id)
|
||||
->take($count)
|
||||
->get();
|
||||
};
|
||||
|
||||
return [
|
||||
'pages' => $query(Page::visible()->where('draft', '=', false)),
|
||||
'chapters' => $query(Chapter::visible()),
|
||||
'books' => $query(Book::visible()),
|
||||
'shelves' => $query(Bookshelf::visible()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get asset created counts for the give user.
|
||||
*/
|
||||
public function getAssetCounts(User $user): array
|
||||
{
|
||||
$createdBy = ['created_by' => $user->id];
|
||||
|
||||
return [
|
||||
'pages' => Page::visible()->where($createdBy)->count(),
|
||||
'chapters' => Chapter::visible()->where($createdBy)->count(),
|
||||
'books' => Book::visible()->where($createdBy)->count(),
|
||||
'shelves' => Bookshelf::visible()->where($createdBy)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles in the system that are assignable to a user.
|
||||
*/
|
||||
public function getAllRoles(): Collection
|
||||
{
|
||||
return Role::query()->orderBy('display_name', 'asc')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an avatar image for a user and set it as their avatar.
|
||||
* Returns early if avatars disabled or not set in config.
|
||||
*/
|
||||
public function downloadAndAssignUserAvatar(User $user): void
|
||||
{
|
||||
try {
|
||||
$this->userAvatar->fetchAndAssignToUser($user);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to save user avatar image');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ return [
|
||||
// The number of revisions to keep in the database.
|
||||
// Once this limit is reached older revisions will be deleted.
|
||||
// If set to false then a limit will not be enforced.
|
||||
'revision_limit' => env('REVISION_LIMIT', 50),
|
||||
'revision_limit' => env('REVISION_LIMIT', 100),
|
||||
|
||||
// The number of days that content will remain in the recycle bin before
|
||||
// being considered for auto-removal. It is not a guarantee that content will
|
||||
@@ -57,6 +57,17 @@ return [
|
||||
// Space separated if multiple. BookStack host domain is auto-inferred.
|
||||
'iframe_hosts' => env('ALLOWED_IFRAME_HOSTS', null),
|
||||
|
||||
// A list of sources/hostnames that can be loaded within iframes within BookStack.
|
||||
// Space separated if multiple. BookStack host domain is auto-inferred.
|
||||
// Can be set to a lone "*" to allow all sources for iframe content (Not advised).
|
||||
// Defaults to a set of common services.
|
||||
// Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
|
||||
'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'),
|
||||
|
||||
// Alter the precision of IP addresses stored by BookStack.
|
||||
// Integer value between 0 (IP hidden) to 4 (Full IP usage)
|
||||
'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),
|
||||
|
||||
// Application timezone for back-end date functions.
|
||||
'timezone' => env('APP_TIMEZONE', 'UTC'),
|
||||
|
||||
@@ -64,7 +75,7 @@ return [
|
||||
'locale' => env('APP_LANG', 'en'),
|
||||
|
||||
// Locales available
|
||||
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'],
|
||||
'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'],
|
||||
|
||||
// Application Fallback Locale
|
||||
'fallback_locale' => 'en',
|
||||
@@ -190,12 +201,9 @@ return [
|
||||
|
||||
// Third Party
|
||||
'ImageTool' => Intervention\Image\Facades\Image::class,
|
||||
'DomPDF' => Barryvdh\DomPDF\Facade::class,
|
||||
'SnappyPDF' => Barryvdh\Snappy\Facades\SnappyPdf::class,
|
||||
|
||||
// Custom BookStack
|
||||
'Activity' => BookStack\Facades\Activity::class,
|
||||
'Permissions' => BookStack\Facades\Permissions::class,
|
||||
'Theme' => BookStack\Facades\Theme::class,
|
||||
],
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ return [
|
||||
// Options: standard, ldap, saml2, oidc
|
||||
'method' => env('AUTH_METHOD', 'standard'),
|
||||
|
||||
// Automatically initiate login via external auth system if it's the sole auth method.
|
||||
// Works with saml2 or oidc auth methods.
|
||||
'auto_initiate' => env('AUTH_AUTO_INITIATE', false),
|
||||
|
||||
// Authentication Defaults
|
||||
// This option controls the default authentication "guard" and password
|
||||
// reset options for your application.
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* Configuration should be altered via the `.env` file or environment variables.
|
||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||
*/
|
||||
|
||||
$dompdfPaperSizeMap = [
|
||||
'a4' => 'a4',
|
||||
'letter' => 'letter',
|
||||
@@ -15,8 +16,8 @@ $dompdfPaperSizeMap = [
|
||||
return [
|
||||
|
||||
'show_warnings' => false, // Throw an Exception on warnings from dompdf
|
||||
'orientation' => 'portrait',
|
||||
'defines' => [
|
||||
|
||||
'options' => [
|
||||
/**
|
||||
* The location of the DOMPDF font directory.
|
||||
*
|
||||
@@ -77,15 +78,25 @@ return [
|
||||
'chroot' => realpath(public_path()),
|
||||
|
||||
/**
|
||||
* Whether to use Unicode fonts or not.
|
||||
* Protocol whitelist.
|
||||
*
|
||||
* When set to true the PDF backend must be set to "CPDF" and fonts must be
|
||||
* loaded via load_font.php.
|
||||
* Protocols and PHP wrappers allowed in URIs, and the validation rules
|
||||
* that determine if a resouce may be loaded. Full support is not guaranteed
|
||||
* for the protocols/wrappers specified
|
||||
* by this array.
|
||||
*
|
||||
* When enabled, dompdf can support all Unicode glyphs. Any glyphs used in a
|
||||
* document must be present in your fonts, however.
|
||||
* @var array
|
||||
*/
|
||||
'unicode_enabled' => true,
|
||||
'allowed_protocols' => [
|
||||
'file://' => ['rules' => []],
|
||||
'http://' => ['rules' => []],
|
||||
'https://' => ['rules' => []],
|
||||
],
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
'log_output_file' => null,
|
||||
|
||||
/**
|
||||
* Whether to enable font subsetting or not.
|
||||
@@ -156,6 +167,15 @@ return [
|
||||
*/
|
||||
'default_paper_size' => $dompdfPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'a4',
|
||||
|
||||
/**
|
||||
* The default paper orientation.
|
||||
*
|
||||
* The orientation of the page (portrait or landscape).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
'default_paper_orientation' => 'portrait',
|
||||
|
||||
/**
|
||||
* The default font family.
|
||||
*
|
||||
@@ -258,10 +278,13 @@ return [
|
||||
'enable_css_float' => true,
|
||||
|
||||
/**
|
||||
* Use the more-than-experimental HTML5 Lib parser.
|
||||
* Use the HTML5 Lib parser.
|
||||
*
|
||||
* @deprecated This feature is now always on in dompdf 2.x
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
'enable_html5parser' => true,
|
||||
|
||||
'enable_html5_parser' => true,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -32,4 +32,16 @@ return [
|
||||
// OAuth2 endpoints.
|
||||
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
|
||||
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
|
||||
|
||||
// Add extra scopes, upon those required, to the OIDC authentication request
|
||||
// Multiple values can be provided comma seperated.
|
||||
'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
|
||||
|
||||
// Group sync options
|
||||
// Enable syncing, upon login, of OIDC groups to BookStack roles
|
||||
'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
|
||||
// Attribute, within a OIDC ID token, to find group names within
|
||||
'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'),
|
||||
// When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups.
|
||||
'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false),
|
||||
];
|
||||
|
||||
@@ -119,6 +119,7 @@ return [
|
||||
'ldap' => [
|
||||
'server' => env('LDAP_SERVER', false),
|
||||
'dump_user_details' => env('LDAP_DUMP_USER_DETAILS', false),
|
||||
'dump_user_groups' => env('LDAP_DUMP_USER_GROUPS', false),
|
||||
'dn' => env('LDAP_DN', false),
|
||||
'pass' => env('LDAP_PASS', false),
|
||||
'base_dn' => env('LDAP_BASE_DN', false),
|
||||
|
||||
@@ -72,7 +72,7 @@ return [
|
||||
// to the server if the browser has a HTTPS connection. This will keep
|
||||
// the cookie from being sent to you if it can not be done securely.
|
||||
'secure' => env('SESSION_SECURE_COOKIE', null)
|
||||
?? Str::startsWith(env('APP_URL'), 'https:'),
|
||||
?? Str::startsWith(env('APP_URL', ''), 'https:'),
|
||||
|
||||
// HTTP Access Only
|
||||
// Setting this value to true will prevent JavaScript from accessing the
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* Configuration should be altered via the `.env` file or environment variables.
|
||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||
*/
|
||||
|
||||
$snappyPaperSizeMap = [
|
||||
'a4' => 'A4',
|
||||
'letter' => 'Letter',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\UserRepo;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use Illuminate\Console\Command;
|
||||
@@ -84,9 +85,8 @@ class CreateAdmin extends Command
|
||||
return SymfonyCommand::FAILURE;
|
||||
}
|
||||
|
||||
$user = $this->userRepo->create($validator->validated());
|
||||
$this->userRepo->attachSystemRole($user, 'admin');
|
||||
$this->userRepo->downloadAndAssignUserAvatar($user);
|
||||
$user = $this->userRepo->createWithoutActivity($validator->validated());
|
||||
$user->attachRole(Role::getSystemRole('admin'));
|
||||
$user->email_confirmed = true;
|
||||
$user->save();
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ class DeleteUsers extends Command
|
||||
*/
|
||||
protected $signature = 'bookstack:delete-users';
|
||||
|
||||
protected $user;
|
||||
|
||||
protected $userRepo;
|
||||
|
||||
/**
|
||||
@@ -26,9 +24,8 @@ class DeleteUsers extends Command
|
||||
*/
|
||||
protected $description = 'Delete users that are not "admin" or system users';
|
||||
|
||||
public function __construct(User $user, UserRepo $userRepo)
|
||||
public function __construct(UserRepo $userRepo)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->userRepo = $userRepo;
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -38,8 +35,8 @@ class DeleteUsers extends Command
|
||||
$confirm = $this->ask('This will delete all users from the system that are not "admin" or system users. Are you sure you want to continue? (Type "yes" to continue)');
|
||||
$numDeleted = 0;
|
||||
if (strtolower(trim($confirm)) === 'yes') {
|
||||
$totalUsers = $this->user->count();
|
||||
$users = $this->user->where('system_name', '=', null)->with('roles')->get();
|
||||
$totalUsers = User::query()->count();
|
||||
$users = User::query()->whereNull('system_name')->with('roles')->get();
|
||||
foreach ($users as $user) {
|
||||
if ($user->hasSystemRole('admin')) {
|
||||
// don't delete users with "admin" role
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Console\Commands;
|
||||
use BookStack\Actions\Comment;
|
||||
use BookStack\Actions\CommentRepo;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegenerateCommentContent extends Command
|
||||
{
|
||||
@@ -43,9 +44,9 @@ class RegenerateCommentContent extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$connection = \DB::getDefaultConnection();
|
||||
$connection = DB::getDefaultConnection();
|
||||
if ($this->option('database') !== null) {
|
||||
\DB::setDefaultConnection($this->option('database'));
|
||||
DB::setDefaultConnection($this->option('database'));
|
||||
}
|
||||
|
||||
Comment::query()->chunk(100, function ($comments) {
|
||||
@@ -55,7 +56,9 @@ class RegenerateCommentContent extends Command
|
||||
}
|
||||
});
|
||||
|
||||
\DB::setDefaultConnection($connection);
|
||||
DB::setDefaultConnection($connection);
|
||||
$this->comment('Comment HTML content has been regenerated');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Auth\Permissions\JointPermissionBuilder;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegeneratePermissions extends Command
|
||||
{
|
||||
@@ -21,19 +22,14 @@ class RegeneratePermissions extends Command
|
||||
*/
|
||||
protected $description = 'Regenerate all system permissions';
|
||||
|
||||
/**
|
||||
* The service to handle the permission system.
|
||||
*
|
||||
* @var PermissionService
|
||||
*/
|
||||
protected $permissionService;
|
||||
protected JointPermissionBuilder $permissionBuilder;
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*/
|
||||
public function __construct(PermissionService $permissionService)
|
||||
public function __construct(JointPermissionBuilder $permissionBuilder)
|
||||
{
|
||||
$this->permissionService = $permissionService;
|
||||
$this->permissionBuilder = $permissionBuilder;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@@ -44,15 +40,17 @@ class RegeneratePermissions extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$connection = \DB::getDefaultConnection();
|
||||
if ($this->option('database') !== null) {
|
||||
\DB::setDefaultConnection($this->option('database'));
|
||||
$this->permissionService->setConnection(\DB::connection($this->option('database')));
|
||||
$connection = DB::getDefaultConnection();
|
||||
|
||||
if ($this->option('database')) {
|
||||
DB::setDefaultConnection($this->option('database'));
|
||||
}
|
||||
|
||||
$this->permissionService->buildJointPermissions();
|
||||
$this->permissionBuilder->rebuildForAll();
|
||||
|
||||
\DB::setDefaultConnection($connection);
|
||||
DB::setDefaultConnection($connection);
|
||||
$this->comment('Permissions regenerated');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
59
app/Console/Commands/RegenerateReferences.php
Normal file
59
app/Console/Commands/RegenerateReferences.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\References\ReferenceStore;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegenerateReferences extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bookstack:regenerate-references {--database= : The database connection to use.}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Regenerate all the cross-item model reference index';
|
||||
|
||||
protected ReferenceStore $references;
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(ReferenceStore $references)
|
||||
{
|
||||
$this->references = $references;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$connection = DB::getDefaultConnection();
|
||||
|
||||
if ($this->option('database')) {
|
||||
DB::setDefaultConnection($this->option('database'));
|
||||
}
|
||||
|
||||
$this->references->updateForAllPages();
|
||||
|
||||
DB::setDefaultConnection($connection);
|
||||
|
||||
$this->comment('References have been regenerated');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Tools\SearchIndex;
|
||||
use BookStack\Search\SearchIndex;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
@@ -57,11 +58,16 @@ abstract class BookChild extends Entity
|
||||
*/
|
||||
public function changeBook(int $newBookId): Entity
|
||||
{
|
||||
$oldUrl = $this->getUrl();
|
||||
$this->book_id = $newBookId;
|
||||
$this->refreshSlug();
|
||||
$this->save();
|
||||
$this->refresh();
|
||||
|
||||
if ($oldUrl !== $this->getUrl()) {
|
||||
app()->make(ReferenceUpdater::class)->updateEntityPageReferences($this, $oldUrl);
|
||||
}
|
||||
|
||||
// Update all child pages if a chapter
|
||||
if ($this instanceof Chapter) {
|
||||
foreach ($this->pages()->withTrashed()->get() as $page) {
|
||||
|
||||
@@ -86,15 +86,11 @@ class Bookshelf extends Entity implements HasCoverImage
|
||||
*/
|
||||
public function coverImageTypeKey(): string
|
||||
{
|
||||
return 'cover_shelf';
|
||||
return 'cover_bookshelf';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this shelf contains the given book.
|
||||
*
|
||||
* @param Book $book
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function contains(Book $book): bool
|
||||
{
|
||||
@@ -103,8 +99,6 @@ class Bookshelf extends Entity implements HasCoverImage
|
||||
|
||||
/**
|
||||
* Add a book to the end of this shelf.
|
||||
*
|
||||
* @param Book $book
|
||||
*/
|
||||
public function appendBook(Book $book)
|
||||
{
|
||||
|
||||
@@ -10,10 +10,16 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $deleted_by
|
||||
* @property string $deletable_type
|
||||
* @property int $deletable_id
|
||||
* @property Deletable $deletable
|
||||
*/
|
||||
class Deletion extends Model implements Loggable
|
||||
{
|
||||
protected $hidden = [];
|
||||
|
||||
/**
|
||||
* Get the related deletable record.
|
||||
*/
|
||||
|
||||
@@ -9,15 +9,18 @@ use BookStack\Actions\Tag;
|
||||
use BookStack\Actions\View;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Entities\Tools\SearchIndex;
|
||||
use BookStack\Auth\Permissions\JointPermissionBuilder;
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Facades\Permissions;
|
||||
use BookStack\Interfaces\Deletable;
|
||||
use BookStack\Interfaces\Favouritable;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Interfaces\Sluggable;
|
||||
use BookStack\Interfaces\Viewable;
|
||||
use BookStack\Model;
|
||||
use BookStack\References\Reference;
|
||||
use BookStack\Search\SearchIndex;
|
||||
use BookStack\Search\SearchTerm;
|
||||
use BookStack\Traits\HasCreatorAndUpdater;
|
||||
use BookStack\Traits\HasOwner;
|
||||
use Carbon\Carbon;
|
||||
@@ -43,7 +46,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property Collection $tags
|
||||
*
|
||||
* @method static Entity|Builder visible()
|
||||
* @method static Entity|Builder hasPermission(string $permission)
|
||||
* @method static Builder withLastView()
|
||||
* @method static Builder withViewCount()
|
||||
*/
|
||||
@@ -68,15 +70,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
*/
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
return $this->scopeHasPermission($query, 'view');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope the query to those entities that the current user has the given permission for.
|
||||
*/
|
||||
public function scopeHasPermission(Builder $query, string $permission)
|
||||
{
|
||||
return Permissions::restrictEntityQuery($query, $permission);
|
||||
return app()->make(PermissionApplicator::class)->restrictEntityQuery($query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,6 +204,22 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
return $this->morphMany(Deletion::class, 'deletable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the references pointing from this entity to other items.
|
||||
*/
|
||||
public function referencesFrom(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Reference::class, 'from');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the references pointing to this entity from other items.
|
||||
*/
|
||||
public function referencesTo(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Reference::class, 'to');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this instance or class is a certain type of entity.
|
||||
* Examples of $type are 'page', 'book', 'chapter'.
|
||||
@@ -284,8 +294,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
*/
|
||||
public function rebuildPermissions()
|
||||
{
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
Permissions::buildJointPermissionsForEntity(clone $this);
|
||||
app()->make(JointPermissionBuilder::class)->rebuildForEntity(clone $this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,7 +302,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
*/
|
||||
public function indexForSearch()
|
||||
{
|
||||
app(SearchIndex::class)->indexEntity(clone $this);
|
||||
app()->make(SearchIndex::class)->indexEntity(clone $this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -301,7 +310,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
*/
|
||||
public function refreshSlug(): string
|
||||
{
|
||||
$this->slug = app(SlugGenerator::class)->generate($this);
|
||||
$this->slug = app()->make(SlugGenerator::class)->generate($this);
|
||||
|
||||
return $this->slug;
|
||||
}
|
||||
|
||||
@@ -2,27 +2,31 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Facades\Permissions;
|
||||
use BookStack\Uploads\Attachment;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
/**
|
||||
* Class Page.
|
||||
*
|
||||
* @property int $chapter_id
|
||||
* @property string $html
|
||||
* @property string $markdown
|
||||
* @property string $text
|
||||
* @property bool $template
|
||||
* @property bool $draft
|
||||
* @property int $revision_count
|
||||
* @property Chapter $chapter
|
||||
* @property Collection $attachments
|
||||
* @property int $chapter_id
|
||||
* @property string $html
|
||||
* @property string $markdown
|
||||
* @property string $text
|
||||
* @property bool $template
|
||||
* @property bool $draft
|
||||
* @property int $revision_count
|
||||
* @property string $editor
|
||||
* @property Chapter $chapter
|
||||
* @property Collection $attachments
|
||||
* @property Collection $revisions
|
||||
* @property PageRevision $currentRevision
|
||||
*/
|
||||
class Page extends BookChild
|
||||
{
|
||||
@@ -47,7 +51,7 @@ class Page extends BookChild
|
||||
*/
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
$query = Permissions::enforceDraftVisibilityOnQuery($query);
|
||||
$query = app()->make(PermissionApplicator::class)->restrictDraftsOnPageQuery($query);
|
||||
|
||||
return parent::scopeVisible($query);
|
||||
}
|
||||
@@ -82,6 +86,19 @@ class Page extends BookChild
|
||||
->orderBy('id', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current revision for the page if existing.
|
||||
*
|
||||
* @return PageRevision|null
|
||||
*/
|
||||
public function currentRevision(): HasOne
|
||||
{
|
||||
return $this->hasOne(PageRevision::class)
|
||||
->where('type', '=', 'version')
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all revision instances assigned to this page.
|
||||
* Includes all types of revisions.
|
||||
@@ -117,16 +134,6 @@ class Page extends BookChild
|
||||
return url('/' . implode('/', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current revision for the page if existing.
|
||||
*
|
||||
* @return PageRevision|null
|
||||
*/
|
||||
public function getCurrentRevision()
|
||||
{
|
||||
return $this->revisions()->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this page for JSON display.
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Model;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -10,7 +11,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
/**
|
||||
* Class PageRevision.
|
||||
*
|
||||
* @property mixed $id
|
||||
* @property int $page_id
|
||||
* @property string $name
|
||||
* @property string $slug
|
||||
* @property string $book_slug
|
||||
* @property int $created_by
|
||||
@@ -20,13 +23,15 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* @property string $summary
|
||||
* @property string $markdown
|
||||
* @property string $html
|
||||
* @property string $text
|
||||
* @property int $revision_number
|
||||
* @property Page $page
|
||||
* @property-read ?User $createdBy
|
||||
*/
|
||||
class PageRevision extends Model
|
||||
class PageRevision extends Model implements Loggable
|
||||
{
|
||||
protected $fillable = ['name', 'html', 'text', 'markdown', 'summary'];
|
||||
protected $fillable = ['name', 'text', 'summary'];
|
||||
protected $hidden = ['html', 'markdown', 'restricted', 'text'];
|
||||
|
||||
/**
|
||||
* Get the user that created the page revision.
|
||||
@@ -79,4 +84,9 @@ class PageRevision extends Model
|
||||
{
|
||||
return $type === 'revision';
|
||||
}
|
||||
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "Revision #{$this->revision_number} (ID: {$this->id}) for page ID {$this->page_id}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
|
||||
abstract class EntityQuery
|
||||
{
|
||||
protected function permissionService(): PermissionService
|
||||
protected function permissionService(): PermissionApplicator
|
||||
{
|
||||
return app()->make(PermissionService::class);
|
||||
return app()->make(PermissionApplicator::class);
|
||||
}
|
||||
|
||||
protected function entityProvider(): EntityProvider
|
||||
|
||||
@@ -7,10 +7,10 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Popular extends EntityQuery
|
||||
{
|
||||
public function run(int $count, int $page, array $filterModels = null, string $action = 'view')
|
||||
public function run(int $count, int $page, array $filterModels = null)
|
||||
{
|
||||
$query = $this->permissionService()
|
||||
->filterRestrictedEntityRelations(View::query(), 'views', 'viewable_id', 'viewable_type', $action)
|
||||
->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type')
|
||||
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
|
||||
->groupBy('viewable_id', 'viewable_type')
|
||||
->orderBy('view_count', 'desc');
|
||||
|
||||
@@ -14,12 +14,11 @@ class RecentlyViewed extends EntityQuery
|
||||
return collect();
|
||||
}
|
||||
|
||||
$query = $this->permissionService()->filterRestrictedEntityRelations(
|
||||
$query = $this->permissionService()->restrictEntityRelationQuery(
|
||||
View::query(),
|
||||
'views',
|
||||
'viewable_id',
|
||||
'viewable_type',
|
||||
'view'
|
||||
'viewable_type'
|
||||
)
|
||||
->orderBy('views.updated_at', 'desc')
|
||||
->where('user_id', '=', user()->id);
|
||||
|
||||
@@ -15,7 +15,7 @@ class TopFavourites extends EntityQuery
|
||||
}
|
||||
|
||||
$query = $this->permissionService()
|
||||
->filterRestrictedEntityRelations(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type', 'view')
|
||||
->restrictEntityRelationQuery(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type')
|
||||
->select('favourites.*')
|
||||
->leftJoin('views', function (JoinClause $join) {
|
||||
$join->on('favourites.favouritable_id', '=', 'views.viewable_id');
|
||||
|
||||
@@ -6,18 +6,21 @@ use BookStack\Actions\TagRepo;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\HasCoverImage;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class BaseRepo
|
||||
{
|
||||
protected $tagRepo;
|
||||
protected $imageRepo;
|
||||
protected TagRepo $tagRepo;
|
||||
protected ImageRepo $imageRepo;
|
||||
protected ReferenceUpdater $referenceUpdater;
|
||||
|
||||
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
|
||||
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo, ReferenceUpdater $referenceUpdater)
|
||||
{
|
||||
$this->tagRepo = $tagRepo;
|
||||
$this->imageRepo = $imageRepo;
|
||||
$this->referenceUpdater = $referenceUpdater;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +41,7 @@ class BaseRepo
|
||||
$this->tagRepo->saveTagsToEntity($entity, $input['tags']);
|
||||
}
|
||||
|
||||
$entity->refresh();
|
||||
$entity->rebuildPermissions();
|
||||
$entity->indexForSearch();
|
||||
}
|
||||
@@ -47,10 +51,12 @@ class BaseRepo
|
||||
*/
|
||||
public function update(Entity $entity, array $input)
|
||||
{
|
||||
$oldUrl = $entity->getUrl();
|
||||
|
||||
$entity->fill($input);
|
||||
$entity->updated_by = user()->id;
|
||||
|
||||
if ($entity->isDirty('name')) {
|
||||
if ($entity->isDirty('name') || empty($entity->slug)) {
|
||||
$entity->refreshSlug();
|
||||
}
|
||||
|
||||
@@ -58,10 +64,15 @@ class BaseRepo
|
||||
|
||||
if (isset($input['tags'])) {
|
||||
$this->tagRepo->saveTagsToEntity($entity, $input['tags']);
|
||||
$entity->touch();
|
||||
}
|
||||
|
||||
$entity->rebuildPermissions();
|
||||
$entity->indexForSearch();
|
||||
|
||||
if ($oldUrl !== $entity->getUrl()) {
|
||||
$this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,8 +86,9 @@ class BaseRepo
|
||||
public function updateCoverImage($entity, ?UploadedFile $coverImage, bool $removeImage = false)
|
||||
{
|
||||
if ($coverImage) {
|
||||
$imageType = $entity->coverImageTypeKey();
|
||||
$this->imageRepo->destroyImage($entity->cover);
|
||||
$image = $this->imageRepo->saveNew($coverImage, 'cover_book', $entity->id, 512, 512, true);
|
||||
$image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
|
||||
$entity->cover()->associate($image);
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ class BookRepo
|
||||
{
|
||||
$book = new Book();
|
||||
$this->baseRepo->create($book, $input);
|
||||
$this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
|
||||
Activity::add(ActivityType::BOOK_CREATE, $book);
|
||||
|
||||
return $book;
|
||||
@@ -102,6 +103,11 @@ class BookRepo
|
||||
public function update(Book $book, array $input): Book
|
||||
{
|
||||
$this->baseRepo->update($book, $input);
|
||||
|
||||
if (array_key_exists('image', $input)) {
|
||||
$this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null);
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::BOOK_UPDATE, $book);
|
||||
|
||||
return $book;
|
||||
|
||||
@@ -6,12 +6,10 @@ use BookStack\Actions\ActivityType;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Facades\Activity;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BookshelfRepo
|
||||
@@ -89,6 +87,7 @@ class BookshelfRepo
|
||||
{
|
||||
$shelf = new Bookshelf();
|
||||
$this->baseRepo->create($shelf, $input);
|
||||
$this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
|
||||
$this->updateBooks($shelf, $bookIds);
|
||||
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
|
||||
|
||||
@@ -106,14 +105,17 @@ class BookshelfRepo
|
||||
$this->updateBooks($shelf, $bookIds);
|
||||
}
|
||||
|
||||
if (array_key_exists('image', $input)) {
|
||||
$this->baseRepo->updateCoverImage($shelf, $input['image'], $input['image'] === null);
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::BOOKSHELF_UPDATE, $shelf);
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update which books are assigned to this shelf by
|
||||
* syncing the given book ids.
|
||||
* Update which books are assigned to this shelf by syncing the given book ids.
|
||||
* Function ensures the books are visible to the current user and existing.
|
||||
*/
|
||||
protected function updateBooks(Bookshelf $shelf, array $bookIds)
|
||||
@@ -132,24 +134,13 @@ class BookshelfRepo
|
||||
$shelf->books()->sync($syncData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given shelf cover image, or clear it.
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function updateCoverImage(Bookshelf $shelf, ?UploadedFile $coverImage, bool $removeImage = false)
|
||||
{
|
||||
$this->baseRepo->updateCoverImage($shelf, $coverImage, $removeImage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy down the permissions of the given shelf to all child books.
|
||||
*/
|
||||
public function copyDownPermissions(Bookshelf $shelf, $checkUserPermissions = true): int
|
||||
{
|
||||
$shelfPermissions = $shelf->permissions()->get(['role_id', 'action'])->toArray();
|
||||
$shelfBooks = $shelf->books()->get(['id', 'restricted']);
|
||||
$shelfBooks = $shelf->books()->get(['id', 'restricted', 'owned_by']);
|
||||
$updatedBookCount = 0;
|
||||
|
||||
/** @var Book $book */
|
||||
|
||||
36
app/Entities/Repos/DeletionRepo.php
Normal file
36
app/Entities/Repos/DeletionRepo.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Repos;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Entities\Models\Deletion;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Facades\Activity;
|
||||
|
||||
class DeletionRepo
|
||||
{
|
||||
private TrashCan $trashCan;
|
||||
|
||||
public function __construct(TrashCan $trashCan)
|
||||
{
|
||||
$this->trashCan = $trashCan;
|
||||
}
|
||||
|
||||
public function restore(int $id): int
|
||||
{
|
||||
/** @var Deletion $deletion */
|
||||
$deletion = Deletion::query()->findOrFail($id);
|
||||
Activity::add(ActivityType::RECYCLE_BIN_RESTORE, $deletion);
|
||||
|
||||
return $this->trashCan->restoreFromDeletion($deletion);
|
||||
}
|
||||
|
||||
public function destroy(int $id): int
|
||||
{
|
||||
/** @var Deletion $deletion */
|
||||
$deletion = Deletion::query()->findOrFail($id);
|
||||
Activity::add(ActivityType::RECYCLE_BIN_DESTROY, $deletion);
|
||||
|
||||
return $this->trashCan->destroyFromDeletion($deletion);
|
||||
}
|
||||
}
|
||||
@@ -10,25 +10,37 @@ use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Models\PageRevision;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditorData;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\References\ReferenceStore;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class PageRepo
|
||||
{
|
||||
protected $baseRepo;
|
||||
protected BaseRepo $baseRepo;
|
||||
protected RevisionRepo $revisionRepo;
|
||||
protected ReferenceStore $referenceStore;
|
||||
protected ReferenceUpdater $referenceUpdater;
|
||||
|
||||
/**
|
||||
* PageRepo constructor.
|
||||
*/
|
||||
public function __construct(BaseRepo $baseRepo)
|
||||
{
|
||||
public function __construct(
|
||||
BaseRepo $baseRepo,
|
||||
RevisionRepo $revisionRepo,
|
||||
ReferenceStore $referenceStore,
|
||||
ReferenceUpdater $referenceUpdater
|
||||
) {
|
||||
$this->baseRepo = $baseRepo;
|
||||
$this->revisionRepo = $revisionRepo;
|
||||
$this->referenceStore = $referenceStore;
|
||||
$this->referenceUpdater = $referenceUpdater;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +50,7 @@ class PageRepo
|
||||
*/
|
||||
public function getById(int $id, array $relations = ['book']): Page
|
||||
{
|
||||
/** @var Page $page */
|
||||
$page = Page::visible()->with($relations)->find($id);
|
||||
|
||||
if (!$page) {
|
||||
@@ -69,17 +82,7 @@ class PageRepo
|
||||
*/
|
||||
public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
|
||||
{
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = PageRevision::query()
|
||||
->whereHas('page', function (Builder $query) {
|
||||
$query->scopes('visible');
|
||||
})
|
||||
->where('slug', '=', $pageSlug)
|
||||
->where('type', '=', 'version')
|
||||
->where('book_slug', '=', $bookSlug)
|
||||
->orderBy('created_at', 'desc')
|
||||
->with('page')
|
||||
->first();
|
||||
$revision = $this->revisionRepo->getBySlugs($bookSlug, $pageSlug);
|
||||
|
||||
return $revision->page ?? null;
|
||||
}
|
||||
@@ -111,7 +114,7 @@ class PageRepo
|
||||
public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null): Entity
|
||||
{
|
||||
if ($chapterSlug !== null) {
|
||||
return $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
|
||||
return Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
|
||||
}
|
||||
|
||||
return Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
|
||||
@@ -122,9 +125,7 @@ class PageRepo
|
||||
*/
|
||||
public function getUserDraft(Page $page): ?PageRevision
|
||||
{
|
||||
$revision = $this->getUserDraftQuery($page)->first();
|
||||
|
||||
return $revision;
|
||||
return $this->revisionRepo->getLatestDraftForCurrentUser($page);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,11 +165,10 @@ class PageRepo
|
||||
$draft->draft = false;
|
||||
$draft->revision_count = 1;
|
||||
$draft->priority = $this->getNewPriority($draft);
|
||||
$draft->refreshSlug();
|
||||
$draft->save();
|
||||
|
||||
$this->savePageRevision($draft, trans('entities.pages_initial_revision'));
|
||||
$draft->indexForSearch();
|
||||
$this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision'));
|
||||
$this->referenceStore->updateForPage($draft);
|
||||
$draft->refresh();
|
||||
|
||||
Activity::add(ActivityType::PAGE_CREATE, $draft);
|
||||
@@ -188,13 +188,14 @@ class PageRepo
|
||||
|
||||
$this->updateTemplateStatusAndContentFromInput($page, $input);
|
||||
$this->baseRepo->update($page, $input);
|
||||
$this->referenceStore->updateForPage($page);
|
||||
|
||||
// Update with new details
|
||||
$page->revision_count++;
|
||||
$page->save();
|
||||
|
||||
// Remove all update drafts for this user & page.
|
||||
$this->getUserDraftQuery($page)->delete();
|
||||
$this->revisionRepo->deleteDraftsForCurrentUser($page);
|
||||
|
||||
// Save a revision after updating
|
||||
$summary = trim($input['summary'] ?? '');
|
||||
@@ -202,7 +203,7 @@ class PageRepo
|
||||
$nameChanged = isset($input['name']) && $input['name'] !== $oldName;
|
||||
$markdownChanged = isset($input['markdown']) && $input['markdown'] !== $oldMarkdown;
|
||||
if ($htmlChanged || $nameChanged || $markdownChanged || $summary) {
|
||||
$this->savePageRevision($page, $summary);
|
||||
$this->revisionRepo->storeNewForPage($page, $summary);
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::PAGE_UPDATE, $page);
|
||||
@@ -217,33 +218,25 @@ class PageRepo
|
||||
}
|
||||
|
||||
$pageContent = new PageContent($page);
|
||||
if (!empty($input['markdown'] ?? '')) {
|
||||
$currentEditor = $page->editor ?: PageEditorData::getSystemDefaultEditor();
|
||||
$newEditor = $currentEditor;
|
||||
|
||||
$haveInput = isset($input['markdown']) || isset($input['html']);
|
||||
$inputEmpty = empty($input['markdown']) && empty($input['html']);
|
||||
|
||||
if ($haveInput && $inputEmpty) {
|
||||
$pageContent->setNewHTML('');
|
||||
} elseif (!empty($input['markdown']) && is_string($input['markdown'])) {
|
||||
$newEditor = 'markdown';
|
||||
$pageContent->setNewMarkdown($input['markdown']);
|
||||
} elseif (isset($input['html'])) {
|
||||
$newEditor = 'wysiwyg';
|
||||
$pageContent->setNewHTML($input['html']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a page revision into the system.
|
||||
*/
|
||||
protected function savePageRevision(Page $page, string $summary = null): PageRevision
|
||||
{
|
||||
$revision = new PageRevision($page->getAttributes());
|
||||
|
||||
$revision->page_id = $page->id;
|
||||
$revision->slug = $page->slug;
|
||||
$revision->book_slug = $page->book->slug;
|
||||
$revision->created_by = user()->id;
|
||||
$revision->created_at = $page->updated_at;
|
||||
$revision->type = 'version';
|
||||
$revision->summary = $summary;
|
||||
$revision->revision_number = $page->revision_count;
|
||||
$revision->save();
|
||||
|
||||
$this->deleteOldRevisions($page);
|
||||
|
||||
return $revision;
|
||||
if ($newEditor !== $currentEditor && userCan('editor-change')) {
|
||||
$page->editor = $newEditor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,10 +253,15 @@ class PageRepo
|
||||
return $page;
|
||||
}
|
||||
|
||||
// Otherwise save the data to a revision
|
||||
$draft = $this->getPageRevisionToUpdate($page);
|
||||
// Otherwise, save the data to a revision
|
||||
$draft = $this->revisionRepo->getNewDraftForCurrentUser($page);
|
||||
$draft->fill($input);
|
||||
if (setting('app-editor') !== 'markdown') {
|
||||
|
||||
if (!empty($input['markdown'])) {
|
||||
$draft->markdown = $input['markdown'];
|
||||
$draft->html = '';
|
||||
} else {
|
||||
$draft->html = $input['html'];
|
||||
$draft->markdown = '';
|
||||
}
|
||||
|
||||
@@ -290,6 +288,7 @@ class PageRepo
|
||||
*/
|
||||
public function restoreRevision(Page $page, int $revisionId): Page
|
||||
{
|
||||
$oldUrl = $page->getUrl();
|
||||
$page->revision_count++;
|
||||
|
||||
/** @var PageRevision $revision */
|
||||
@@ -308,11 +307,17 @@ class PageRepo
|
||||
$page->refreshSlug();
|
||||
$page->save();
|
||||
$page->indexForSearch();
|
||||
$this->referenceStore->updateForPage($page);
|
||||
|
||||
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
|
||||
$this->savePageRevision($page, $summary);
|
||||
$this->revisionRepo->storeNewForPage($page, $summary);
|
||||
|
||||
if ($oldUrl !== $page->getUrl()) {
|
||||
$this->referenceUpdater->updateEntityPageReferences($page, $oldUrl);
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::PAGE_RESTORE, $page);
|
||||
Activity::add(ActivityType::REVISION_RESTORE, $revision);
|
||||
|
||||
return $page;
|
||||
}
|
||||
@@ -368,65 +373,6 @@ class PageRepo
|
||||
return $parentClass::visible()->where('id', '=', $entityId)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the page's parent to the given entity.
|
||||
*/
|
||||
protected function changeParent(Page $page, Entity $parent)
|
||||
{
|
||||
$book = ($parent instanceof Chapter) ? $parent->book : $parent;
|
||||
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0;
|
||||
$page->save();
|
||||
|
||||
if ($page->book->id !== $book->id) {
|
||||
$page->changeBook($book->id);
|
||||
}
|
||||
|
||||
$page->load('book');
|
||||
$book->rebuildPermissions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a page revision to update for the given page.
|
||||
* Checks for an existing revisions before providing a fresh one.
|
||||
*/
|
||||
protected function getPageRevisionToUpdate(Page $page): PageRevision
|
||||
{
|
||||
$drafts = $this->getUserDraftQuery($page)->get();
|
||||
if ($drafts->count() > 0) {
|
||||
return $drafts->first();
|
||||
}
|
||||
|
||||
$draft = new PageRevision();
|
||||
$draft->page_id = $page->id;
|
||||
$draft->slug = $page->slug;
|
||||
$draft->book_slug = $page->book->slug;
|
||||
$draft->created_by = user()->id;
|
||||
$draft->type = 'update_draft';
|
||||
|
||||
return $draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old revisions, for the given page, from the system.
|
||||
*/
|
||||
protected function deleteOldRevisions(Page $page)
|
||||
{
|
||||
$revisionLimit = config('app.revision_limit');
|
||||
if ($revisionLimit === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$revisionsToDelete = PageRevision::query()
|
||||
->where('page_id', '=', $page->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->skip(intval($revisionLimit))
|
||||
->take(10)
|
||||
->get(['id']);
|
||||
if ($revisionsToDelete->count() > 0) {
|
||||
PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new priority for a page.
|
||||
*/
|
||||
@@ -442,15 +388,4 @@ class PageRepo
|
||||
|
||||
return (new BookContents($page->book))->getLastPriority() + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the query to find the user's draft copies of the given page.
|
||||
*/
|
||||
protected function getUserDraftQuery(Page $page)
|
||||
{
|
||||
return PageRevision::query()->where('created_by', '=', user()->id)
|
||||
->where('type', 'update_draft')
|
||||
->where('page_id', '=', $page->id)
|
||||
->orderBy('created_at', 'desc');
|
||||
}
|
||||
}
|
||||
|
||||
131
app/Entities/Repos/RevisionRepo.php
Normal file
131
app/Entities/Repos/RevisionRepo.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Repos;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Models\PageRevision;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RevisionRepo
|
||||
{
|
||||
/**
|
||||
* Get a revision by its stored book and page slug values.
|
||||
*/
|
||||
public function getBySlugs(string $bookSlug, string $pageSlug): ?PageRevision
|
||||
{
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = PageRevision::query()
|
||||
->whereHas('page', function (Builder $query) {
|
||||
$query->scopes('visible');
|
||||
})
|
||||
->where('slug', '=', $pageSlug)
|
||||
->where('type', '=', 'version')
|
||||
->where('book_slug', '=', $bookSlug)
|
||||
->orderBy('created_at', 'desc')
|
||||
->with('page')
|
||||
->first();
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest draft revision, for the given page, belonging to the current user.
|
||||
*/
|
||||
public function getLatestDraftForCurrentUser(Page $page): ?PageRevision
|
||||
{
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $this->queryForCurrentUserDraft($page->id)->first();
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all drafts revisions, for the given page, belonging to the current user.
|
||||
*/
|
||||
public function deleteDraftsForCurrentUser(Page $page): void
|
||||
{
|
||||
$this->queryForCurrentUserDraft($page->id)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user update_draft page revision to update for the given page.
|
||||
* Checks for an existing revisions before providing a fresh one.
|
||||
*/
|
||||
public function getNewDraftForCurrentUser(Page $page): PageRevision
|
||||
{
|
||||
$draft = $this->getLatestDraftForCurrentUser($page);
|
||||
|
||||
if ($draft) {
|
||||
return $draft;
|
||||
}
|
||||
|
||||
$draft = new PageRevision();
|
||||
$draft->page_id = $page->id;
|
||||
$draft->slug = $page->slug;
|
||||
$draft->book_slug = $page->book->slug;
|
||||
$draft->created_by = user()->id;
|
||||
$draft->type = 'update_draft';
|
||||
|
||||
return $draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new revision in the system for the given page.
|
||||
*/
|
||||
public function storeNewForPage(Page $page, string $summary = null): PageRevision
|
||||
{
|
||||
$revision = new PageRevision();
|
||||
|
||||
$revision->name = $page->name;
|
||||
$revision->html = $page->html;
|
||||
$revision->markdown = $page->markdown;
|
||||
$revision->text = $page->text;
|
||||
$revision->page_id = $page->id;
|
||||
$revision->slug = $page->slug;
|
||||
$revision->book_slug = $page->book->slug;
|
||||
$revision->created_by = user()->id;
|
||||
$revision->created_at = $page->updated_at;
|
||||
$revision->type = 'version';
|
||||
$revision->summary = $summary;
|
||||
$revision->revision_number = $page->revision_count;
|
||||
$revision->save();
|
||||
|
||||
$this->deleteOldRevisions($page);
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old revisions, for the given page, from the system.
|
||||
*/
|
||||
protected function deleteOldRevisions(Page $page)
|
||||
{
|
||||
$revisionLimit = config('app.revision_limit');
|
||||
if ($revisionLimit === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$revisionsToDelete = PageRevision::query()
|
||||
->where('page_id', '=', $page->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->skip(intval($revisionLimit))
|
||||
->take(10)
|
||||
->get(['id']);
|
||||
|
||||
if ($revisionsToDelete->count() > 0) {
|
||||
PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query update draft revisions for the current user.
|
||||
*/
|
||||
protected function queryForCurrentUserDraft(int $pageId): Builder
|
||||
{
|
||||
return PageRevision::query()
|
||||
->where('created_by', '=', user()->id)
|
||||
->where('type', 'update_draft')
|
||||
->where('page_id', '=', $pageId)
|
||||
->orderBy('created_at', 'desc');
|
||||
}
|
||||
}
|
||||
@@ -16,25 +16,10 @@ use Illuminate\Http\UploadedFile;
|
||||
|
||||
class Cloner
|
||||
{
|
||||
/**
|
||||
* @var PageRepo
|
||||
*/
|
||||
protected $pageRepo;
|
||||
|
||||
/**
|
||||
* @var ChapterRepo
|
||||
*/
|
||||
protected $chapterRepo;
|
||||
|
||||
/**
|
||||
* @var BookRepo
|
||||
*/
|
||||
protected $bookRepo;
|
||||
|
||||
/**
|
||||
* @var ImageService
|
||||
*/
|
||||
protected $imageService;
|
||||
protected PageRepo $pageRepo;
|
||||
protected ChapterRepo $chapterRepo;
|
||||
protected BookRepo $bookRepo;
|
||||
protected ImageService $imageService;
|
||||
|
||||
public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService)
|
||||
{
|
||||
@@ -50,11 +35,8 @@ class Cloner
|
||||
public function clonePage(Page $original, Entity $parent, string $newName): Page
|
||||
{
|
||||
$copyPage = $this->pageRepo->getNewDraftPage($parent);
|
||||
$pageData = $original->getAttributes();
|
||||
|
||||
// Update name & tags
|
||||
$pageData = $this->entityToInputData($original);
|
||||
$pageData['name'] = $newName;
|
||||
$pageData['tags'] = $this->entityTagsToInputArray($original);
|
||||
|
||||
return $this->pageRepo->publishDraft($copyPage, $pageData);
|
||||
}
|
||||
@@ -65,9 +47,8 @@ class Cloner
|
||||
*/
|
||||
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
|
||||
{
|
||||
$chapterDetails = $original->getAttributes();
|
||||
$chapterDetails = $this->entityToInputData($original);
|
||||
$chapterDetails['name'] = $newName;
|
||||
$chapterDetails['tags'] = $this->entityTagsToInputArray($original);
|
||||
|
||||
$copyChapter = $this->chapterRepo->create($chapterDetails, $parent);
|
||||
|
||||
@@ -87,9 +68,8 @@ class Cloner
|
||||
*/
|
||||
public function cloneBook(Book $original, string $newName): Book
|
||||
{
|
||||
$bookDetails = $original->getAttributes();
|
||||
$bookDetails = $this->entityToInputData($original);
|
||||
$bookDetails['name'] = $newName;
|
||||
$bookDetails['tags'] = $this->entityTagsToInputArray($original);
|
||||
|
||||
$copyBook = $this->bookRepo->create($bookDetails);
|
||||
|
||||
@@ -104,26 +84,48 @@ class Cloner
|
||||
}
|
||||
}
|
||||
|
||||
if ($original->cover) {
|
||||
try {
|
||||
$tmpImgFile = tmpfile();
|
||||
$uploadedFile = $this->imageToUploadedFile($original->cover, $tmpImgFile);
|
||||
$this->bookRepo->updateCoverImage($copyBook, $uploadedFile, false);
|
||||
} catch (\Exception $exception) {
|
||||
}
|
||||
return $copyBook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an entity to a raw data array of input data.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function entityToInputData(Entity $entity): array
|
||||
{
|
||||
$inputData = $entity->getAttributes();
|
||||
$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;
|
||||
}
|
||||
|
||||
return $copyBook;
|
||||
return $inputData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the permission settings from the source entity to the target entity.
|
||||
*/
|
||||
public function copyEntityPermissions(Entity $sourceEntity, Entity $targetEntity): void
|
||||
{
|
||||
$targetEntity->restricted = $sourceEntity->restricted;
|
||||
$permissions = $sourceEntity->permissions()->get(['role_id', 'action'])->toArray();
|
||||
$targetEntity->permissions()->delete();
|
||||
$targetEntity->permissions()->createMany($permissions);
|
||||
$targetEntity->rebuildPermissions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an image instance to an UploadedFile instance to mimic
|
||||
* a file being uploaded.
|
||||
*/
|
||||
protected function imageToUploadedFile(Image $image, &$tmpFile): ?UploadedFile
|
||||
protected function imageToUploadedFile(Image $image): ?UploadedFile
|
||||
{
|
||||
$imgData = $this->imageService->getImageData($image);
|
||||
$tmpImgFilePath = stream_get_meta_data($tmpFile)['uri'];
|
||||
$tmpImgFilePath = tempnam(sys_get_temp_dir(), 'bs_cover_clone_');
|
||||
file_put_contents($tmpImgFilePath, $imgData);
|
||||
|
||||
return new UploadedFile($tmpImgFilePath, basename($image->path));
|
||||
|
||||
@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Util\CspService;
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMXPath;
|
||||
@@ -15,16 +16,18 @@ use Throwable;
|
||||
|
||||
class ExportFormatter
|
||||
{
|
||||
protected $imageService;
|
||||
protected $pdfGenerator;
|
||||
protected ImageService $imageService;
|
||||
protected PdfGenerator $pdfGenerator;
|
||||
protected CspService $cspService;
|
||||
|
||||
/**
|
||||
* ExportService constructor.
|
||||
*/
|
||||
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator)
|
||||
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator, CspService $cspService)
|
||||
{
|
||||
$this->imageService = $imageService;
|
||||
$this->pdfGenerator = $pdfGenerator;
|
||||
$this->cspService = $cspService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,9 +39,10 @@ class ExportFormatter
|
||||
public function pageToContainedHtml(Page $page)
|
||||
{
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$pageHtml = view('pages.export', [
|
||||
'page' => $page,
|
||||
'format' => 'html',
|
||||
$pageHtml = view('exports.page', [
|
||||
'page' => $page,
|
||||
'format' => 'html',
|
||||
'cspContent' => $this->cspService->getCspMetaTagValue(),
|
||||
])->render();
|
||||
|
||||
return $this->containHtml($pageHtml);
|
||||
@@ -55,10 +59,11 @@ class ExportFormatter
|
||||
$pages->each(function ($page) {
|
||||
$page->html = (new PageContent($page))->render();
|
||||
});
|
||||
$html = view('chapters.export', [
|
||||
'chapter' => $chapter,
|
||||
'pages' => $pages,
|
||||
'format' => 'html',
|
||||
$html = view('exports.chapter', [
|
||||
'chapter' => $chapter,
|
||||
'pages' => $pages,
|
||||
'format' => 'html',
|
||||
'cspContent' => $this->cspService->getCspMetaTagValue(),
|
||||
])->render();
|
||||
|
||||
return $this->containHtml($html);
|
||||
@@ -72,10 +77,11 @@ class ExportFormatter
|
||||
public function bookToContainedHtml(Book $book)
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$html = view('books.export', [
|
||||
$html = view('exports.book', [
|
||||
'book' => $book,
|
||||
'bookChildren' => $bookTree,
|
||||
'format' => 'html',
|
||||
'cspContent' => $this->cspService->getCspMetaTagValue(),
|
||||
])->render();
|
||||
|
||||
return $this->containHtml($html);
|
||||
@@ -89,7 +95,7 @@ class ExportFormatter
|
||||
public function pageToPdf(Page $page)
|
||||
{
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$html = view('pages.export', [
|
||||
$html = view('exports.page', [
|
||||
'page' => $page,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
@@ -110,7 +116,7 @@ class ExportFormatter
|
||||
$page->html = (new PageContent($page))->render();
|
||||
});
|
||||
|
||||
$html = view('chapters.export', [
|
||||
$html = view('exports.chapter', [
|
||||
'chapter' => $chapter,
|
||||
'pages' => $pages,
|
||||
'format' => 'pdf',
|
||||
@@ -128,7 +134,7 @@ class ExportFormatter
|
||||
public function bookToPdf(Book $book)
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$html = view('books.export', [
|
||||
$html = view('exports.book', [
|
||||
'book' => $book,
|
||||
'bookChildren' => $bookTree,
|
||||
'format' => 'pdf',
|
||||
@@ -147,10 +153,31 @@ class ExportFormatter
|
||||
{
|
||||
$html = $this->containHtml($html);
|
||||
$html = $this->replaceIframesWithLinks($html);
|
||||
$html = $this->openDetailElements($html);
|
||||
|
||||
return $this->pdfGenerator->fromHtml($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Within the given HTML content, Open any detail blocks.
|
||||
*/
|
||||
protected function openDetailElements(string $html): string
|
||||
{
|
||||
libxml_use_internal_errors(true);
|
||||
|
||||
$doc = new DOMDocument();
|
||||
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
|
||||
$xPath = new DOMXPath($doc);
|
||||
|
||||
$details = $xPath->query('//details');
|
||||
/** @var DOMElement $detail */
|
||||
foreach ($details as $detail) {
|
||||
$detail->setAttribute('open', 'open');
|
||||
}
|
||||
|
||||
return $doc->saveHTML();
|
||||
}
|
||||
|
||||
/**
|
||||
* Within the given HTML content, replace any iframe elements
|
||||
* with anchor links within paragraph blocks.
|
||||
@@ -208,7 +235,7 @@ class ExportFormatter
|
||||
$linksOutput = [];
|
||||
preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $linksOutput);
|
||||
|
||||
// Replace image src with base64 encoded image strings
|
||||
// Update relative links to be absolute, with instance url
|
||||
if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
|
||||
foreach ($linksOutput[0] as $index => $linkMatch) {
|
||||
$oldLinkString = $linkMatch;
|
||||
@@ -221,7 +248,6 @@ class ExportFormatter
|
||||
}
|
||||
}
|
||||
|
||||
// Replace any relative links with system domain
|
||||
return $htmlContent;
|
||||
}
|
||||
|
||||
@@ -299,7 +325,7 @@ class ExportFormatter
|
||||
$text .= $this->pageToMarkdown($page) . "\n\n";
|
||||
}
|
||||
|
||||
return $text;
|
||||
return trim($text);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,12 +337,12 @@ class ExportFormatter
|
||||
$text = '# ' . $book->name . "\n\n";
|
||||
foreach ($bookTree as $bookChild) {
|
||||
if ($bookChild instanceof Chapter) {
|
||||
$text .= $this->chapterToMarkdown($bookChild);
|
||||
$text .= $this->chapterToMarkdown($bookChild) . "\n\n";
|
||||
} else {
|
||||
$text .= $this->pageToMarkdown($bookChild);
|
||||
$text .= $this->pageToMarkdown($bookChild) . "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
return $text;
|
||||
return trim($text);
|
||||
}
|
||||
}
|
||||
|
||||
87
app/Entities/Tools/HierarchyTransformer.php
Normal file
87
app/Entities/Tools/HierarchyTransformer.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Facades\Activity;
|
||||
|
||||
class HierarchyTransformer
|
||||
{
|
||||
protected BookRepo $bookRepo;
|
||||
protected BookshelfRepo $shelfRepo;
|
||||
protected Cloner $cloner;
|
||||
protected TrashCan $trashCan;
|
||||
|
||||
public function __construct(BookRepo $bookRepo, BookshelfRepo $shelfRepo, Cloner $cloner, TrashCan $trashCan)
|
||||
{
|
||||
$this->bookRepo = $bookRepo;
|
||||
$this->shelfRepo = $shelfRepo;
|
||||
$this->cloner = $cloner;
|
||||
$this->trashCan = $trashCan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a chapter into a book.
|
||||
* Does not check permissions, check before calling.
|
||||
*/
|
||||
public function transformChapterToBook(Chapter $chapter): Book
|
||||
{
|
||||
$inputData = $this->cloner->entityToInputData($chapter);
|
||||
$book = $this->bookRepo->create($inputData);
|
||||
$this->cloner->copyEntityPermissions($chapter, $book);
|
||||
|
||||
/** @var Page $page */
|
||||
foreach ($chapter->pages as $page) {
|
||||
$page->chapter_id = 0;
|
||||
$page->changeBook($book->id);
|
||||
}
|
||||
|
||||
$this->trashCan->destroyEntity($chapter);
|
||||
|
||||
Activity::add(ActivityType::BOOK_CREATE_FROM_CHAPTER, $book);
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a book into a shelf.
|
||||
* Does not check permissions, check before calling.
|
||||
*/
|
||||
public function transformBookToShelf(Book $book): Bookshelf
|
||||
{
|
||||
$inputData = $this->cloner->entityToInputData($book);
|
||||
$shelf = $this->shelfRepo->create($inputData, []);
|
||||
$this->cloner->copyEntityPermissions($book, $shelf);
|
||||
|
||||
$shelfBookSyncData = [];
|
||||
|
||||
/** @var Chapter $chapter */
|
||||
foreach ($book->chapters as $index => $chapter) {
|
||||
$newBook = $this->transformChapterToBook($chapter);
|
||||
$shelfBookSyncData[$newBook->id] = ['order' => $index];
|
||||
if (!$newBook->restricted) {
|
||||
$this->cloner->copyEntityPermissions($shelf, $newBook);
|
||||
}
|
||||
}
|
||||
|
||||
if ($book->directPages->count() > 0) {
|
||||
$book->name .= ' ' . trans('entities.pages');
|
||||
$shelfBookSyncData[$book->id] = ['order' => count($shelfBookSyncData) + 1];
|
||||
$book->save();
|
||||
} else {
|
||||
$this->trashCan->destroyEntity($book);
|
||||
}
|
||||
|
||||
$shelf->books()->sync($shelfBookSyncData);
|
||||
|
||||
Activity::add(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $shelf);
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
}
|
||||
28
app/Entities/Tools/Markdown/CheckboxConverter.php
Normal file
28
app/Entities/Tools/Markdown/CheckboxConverter.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools\Markdown;
|
||||
|
||||
use League\HTMLToMarkdown\Converter\ConverterInterface;
|
||||
use League\HTMLToMarkdown\ElementInterface;
|
||||
|
||||
class CheckboxConverter implements ConverterInterface
|
||||
{
|
||||
public function convert(ElementInterface $element): string
|
||||
{
|
||||
if (strtolower($element->getAttribute('type')) === 'checkbox') {
|
||||
$isChecked = $element->getAttribute('checked') === 'checked';
|
||||
|
||||
return $isChecked ? ' [x] ' : ' [ ] ';
|
||||
}
|
||||
|
||||
return $element->getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getSupportedTags(): array
|
||||
{
|
||||
return ['input'];
|
||||
}
|
||||
}
|
||||
20
app/Entities/Tools/Markdown/CustomDivConverter.php
Normal file
20
app/Entities/Tools/Markdown/CustomDivConverter.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools\Markdown;
|
||||
|
||||
use League\HTMLToMarkdown\Converter\DivConverter;
|
||||
use League\HTMLToMarkdown\ElementInterface;
|
||||
|
||||
class CustomDivConverter extends DivConverter
|
||||
{
|
||||
public function convert(ElementInterface $element): string
|
||||
{
|
||||
// Clean up draw.io diagrams
|
||||
$drawIoDiagram = $element->getAttribute('drawio-diagram');
|
||||
if ($drawIoDiagram) {
|
||||
return "<div drawio-diagram=\"{$drawIoDiagram}\">{$element->getValue()}</div>\n\n";
|
||||
}
|
||||
|
||||
return parent::convert($element);
|
||||
}
|
||||
}
|
||||
25
app/Entities/Tools/Markdown/CustomImageConverter.php
Normal file
25
app/Entities/Tools/Markdown/CustomImageConverter.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools\Markdown;
|
||||
|
||||
use League\HTMLToMarkdown\Converter\ImageConverter;
|
||||
use League\HTMLToMarkdown\ElementInterface;
|
||||
|
||||
class CustomImageConverter extends ImageConverter
|
||||
{
|
||||
public function convert(ElementInterface $element): string
|
||||
{
|
||||
$parent = $element->getParent();
|
||||
|
||||
// Remain as HTML if within diagram block.
|
||||
$withinDrawing = $parent && !empty($parent->getAttribute('drawio-diagram'));
|
||||
if ($withinDrawing) {
|
||||
$src = e($element->getAttribute('src'));
|
||||
$alt = e($element->getAttribute('alt'));
|
||||
|
||||
return "<img src=\"{$src}\" alt=\"{$alt}\"/>";
|
||||
}
|
||||
|
||||
return parent::convert($element);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ class CustomParagraphConverter extends ParagraphConverter
|
||||
{
|
||||
public function convert(ElementInterface $element): string
|
||||
{
|
||||
$class = $element->getAttribute('class');
|
||||
$class = e($element->getAttribute('class'));
|
||||
if (strpos($class, 'callout') !== false) {
|
||||
return "<{$element->getTagName()} class=\"{$class}\">{$element->getValue()}</{$element->getTagName()}>\n\n";
|
||||
}
|
||||
|
||||
@@ -5,12 +5,10 @@ namespace BookStack\Entities\Tools\Markdown;
|
||||
use League\HTMLToMarkdown\Converter\BlockquoteConverter;
|
||||
use League\HTMLToMarkdown\Converter\CodeConverter;
|
||||
use League\HTMLToMarkdown\Converter\CommentConverter;
|
||||
use League\HTMLToMarkdown\Converter\DivConverter;
|
||||
use League\HTMLToMarkdown\Converter\EmphasisConverter;
|
||||
use League\HTMLToMarkdown\Converter\HardBreakConverter;
|
||||
use League\HTMLToMarkdown\Converter\HeaderConverter;
|
||||
use League\HTMLToMarkdown\Converter\HorizontalRuleConverter;
|
||||
use League\HTMLToMarkdown\Converter\ImageConverter;
|
||||
use League\HTMLToMarkdown\Converter\LinkConverter;
|
||||
use League\HTMLToMarkdown\Converter\ListBlockConverter;
|
||||
use League\HTMLToMarkdown\Converter\ListItemConverter;
|
||||
@@ -21,7 +19,7 @@ use League\HTMLToMarkdown\HtmlConverter;
|
||||
|
||||
class HtmlToMarkdown
|
||||
{
|
||||
protected $html;
|
||||
protected string $html;
|
||||
|
||||
public function __construct(string $html)
|
||||
{
|
||||
@@ -75,18 +73,20 @@ class HtmlToMarkdown
|
||||
$environment->addConverter(new BlockquoteConverter());
|
||||
$environment->addConverter(new CodeConverter());
|
||||
$environment->addConverter(new CommentConverter());
|
||||
$environment->addConverter(new DivConverter());
|
||||
$environment->addConverter(new CustomDivConverter());
|
||||
$environment->addConverter(new EmphasisConverter());
|
||||
$environment->addConverter(new HardBreakConverter());
|
||||
$environment->addConverter(new HeaderConverter());
|
||||
$environment->addConverter(new HorizontalRuleConverter());
|
||||
$environment->addConverter(new ImageConverter());
|
||||
$environment->addConverter(new CustomImageConverter());
|
||||
$environment->addConverter(new LinkConverter());
|
||||
$environment->addConverter(new ListBlockConverter());
|
||||
$environment->addConverter(new ListItemConverter());
|
||||
$environment->addConverter(new CustomParagraphConverter());
|
||||
$environment->addConverter(new PreformattedConverter());
|
||||
$environment->addConverter(new TextConverter());
|
||||
$environment->addConverter(new CheckboxConverter());
|
||||
$environment->addConverter(new SpacedTagFallbackConverter());
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
35
app/Entities/Tools/Markdown/MarkdownToHtml.php
Normal file
35
app/Entities/Tools/Markdown/MarkdownToHtml.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
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\Extension\Table\TableExtension;
|
||||
use League\CommonMark\Extension\TaskList\TaskListExtension;
|
||||
|
||||
class MarkdownToHtml
|
||||
{
|
||||
protected string $markdown;
|
||||
|
||||
public function __construct(string $markdown)
|
||||
{
|
||||
$this->markdown = $markdown;
|
||||
}
|
||||
|
||||
public function convert(): string
|
||||
{
|
||||
$environment = Environment::createCommonMarkEnvironment();
|
||||
$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);
|
||||
|
||||
$environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);
|
||||
|
||||
return $converter->convertToHtml($this->markdown);
|
||||
}
|
||||
}
|
||||
23
app/Entities/Tools/Markdown/SpacedTagFallbackConverter.php
Normal file
23
app/Entities/Tools/Markdown/SpacedTagFallbackConverter.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools\Markdown;
|
||||
|
||||
use League\HTMLToMarkdown\Converter\ConverterInterface;
|
||||
use League\HTMLToMarkdown\ElementInterface;
|
||||
|
||||
/**
|
||||
* For certain defined tags, add additional spacing upon the retained HTML content
|
||||
* to separate it out from anything that may be markdown soon afterwards or within.
|
||||
*/
|
||||
class SpacedTagFallbackConverter implements ConverterInterface
|
||||
{
|
||||
public function convert(ElementInterface $element): string
|
||||
{
|
||||
return \html_entity_decode($element->getChildrenAsString()) . "\n\n";
|
||||
}
|
||||
|
||||
public function getSupportedTags(): array
|
||||
{
|
||||
return ['summary', 'iframe'];
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,7 @@
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\Markdown\CustomListItemRenderer;
|
||||
use BookStack\Entities\Tools\Markdown\CustomStrikeThroughExtension;
|
||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
@@ -17,15 +16,10 @@ use DOMNode;
|
||||
use DOMNodeList;
|
||||
use DOMXPath;
|
||||
use Illuminate\Support\Str;
|
||||
use League\CommonMark\Block\Element\ListItem;
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
use League\CommonMark\Environment;
|
||||
use League\CommonMark\Extension\Table\TableExtension;
|
||||
use League\CommonMark\Extension\TaskList\TaskListExtension;
|
||||
|
||||
class PageContent
|
||||
{
|
||||
protected $page;
|
||||
protected Page $page;
|
||||
|
||||
/**
|
||||
* PageContent constructor.
|
||||
@@ -53,28 +47,11 @@ class PageContent
|
||||
{
|
||||
$markdown = $this->extractBase64ImagesFromMarkdown($markdown);
|
||||
$this->page->markdown = $markdown;
|
||||
$html = $this->markdownToHtml($markdown);
|
||||
$html = (new MarkdownToHtml($markdown))->convert();
|
||||
$this->page->html = $this->formatHtml($html);
|
||||
$this->page->text = $this->toPlainText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given Markdown content to a HTML string.
|
||||
*/
|
||||
protected function markdownToHtml(string $markdown): string
|
||||
{
|
||||
$environment = Environment::createCommonMarkEnvironment();
|
||||
$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);
|
||||
|
||||
$environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);
|
||||
|
||||
return $converter->convertToHtml($markdown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert all base64 image data to saved images.
|
||||
*/
|
||||
@@ -239,6 +216,9 @@ class PageContent
|
||||
$html .= $doc->saveHTML($childNode);
|
||||
}
|
||||
|
||||
// Perform required string-level tweaks
|
||||
$html = str_replace(' ', ' ', $html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
@@ -394,23 +374,30 @@ class PageContent
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find page and skip this if page not found
|
||||
// Find page to use, and default replacement to empty string for non-matches.
|
||||
/** @var ?Page $matchedPage */
|
||||
$matchedPage = Page::visible()->find($pageId);
|
||||
if ($matchedPage === null) {
|
||||
$html = str_replace($fullMatch, '', $html);
|
||||
continue;
|
||||
$replacement = '';
|
||||
|
||||
if ($matchedPage && count($splitInclude) === 1) {
|
||||
// If we only have page id, just insert all page html and continue.
|
||||
$replacement = $matchedPage->html;
|
||||
} elseif ($matchedPage && count($splitInclude) > 1) {
|
||||
// Otherwise, if our include tag defines a section, load that specific content
|
||||
$innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
|
||||
$replacement = trim($innerContent);
|
||||
}
|
||||
|
||||
// If we only have page id, just insert all page html and continue.
|
||||
if (count($splitInclude) === 1) {
|
||||
$html = str_replace($fullMatch, $matchedPage->html, $html);
|
||||
continue;
|
||||
}
|
||||
$themeReplacement = Theme::dispatch(
|
||||
ThemeEvents::PAGE_INCLUDE_PARSE,
|
||||
$includeId,
|
||||
$replacement,
|
||||
clone $this->page,
|
||||
$matchedPage ? (clone $matchedPage) : null,
|
||||
);
|
||||
|
||||
// Create and load HTML into a document
|
||||
$innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
|
||||
$html = str_replace($fullMatch, trim($innerContent), $html);
|
||||
// Perform the content replacement
|
||||
$html = str_replace($fullMatch, $themeReplacement ?? $replacement, $html);
|
||||
}
|
||||
|
||||
return $html;
|
||||
|
||||
@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class PageEditActivity
|
||||
{
|
||||
protected $page;
|
||||
protected Page $page;
|
||||
|
||||
/**
|
||||
* PageEditActivity constructor.
|
||||
@@ -42,7 +42,7 @@ class PageEditActivity
|
||||
$userMessage = trans('entities.pages_draft_edit_active.start_b', ['userName' => $firstDraft->createdBy->name ?? '']);
|
||||
}
|
||||
|
||||
$timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount'=> 60]);
|
||||
$timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount' => 60]);
|
||||
|
||||
return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
|
||||
}
|
||||
|
||||
115
app/Entities/Tools/PageEditorData.php
Normal file
115
app/Entities/Tools/PageEditorData.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||
|
||||
class PageEditorData
|
||||
{
|
||||
protected Page $page;
|
||||
protected PageRepo $pageRepo;
|
||||
protected string $requestedEditor;
|
||||
|
||||
protected array $viewData;
|
||||
protected array $warnings;
|
||||
|
||||
public function __construct(Page $page, PageRepo $pageRepo, string $requestedEditor)
|
||||
{
|
||||
$this->page = $page;
|
||||
$this->pageRepo = $pageRepo;
|
||||
$this->requestedEditor = $requestedEditor;
|
||||
|
||||
$this->viewData = $this->build();
|
||||
}
|
||||
|
||||
public function getViewData(): array
|
||||
{
|
||||
return $this->viewData;
|
||||
}
|
||||
|
||||
public function getWarnings(): array
|
||||
{
|
||||
return $this->warnings;
|
||||
}
|
||||
|
||||
protected function build(): array
|
||||
{
|
||||
$page = clone $this->page;
|
||||
$isDraft = boolval($this->page->draft);
|
||||
$templates = $this->pageRepo->getTemplates(10);
|
||||
$draftsEnabled = auth()->check();
|
||||
|
||||
$isDraftRevision = false;
|
||||
$this->warnings = [];
|
||||
$editActivity = new PageEditActivity($page);
|
||||
|
||||
if ($editActivity->hasActiveEditing()) {
|
||||
$this->warnings[] = $editActivity->activeEditingMessage();
|
||||
}
|
||||
|
||||
// Check for a current draft version for this user
|
||||
$userDraft = $this->pageRepo->getUserDraft($page);
|
||||
if ($userDraft !== null) {
|
||||
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
|
||||
$isDraftRevision = true;
|
||||
$this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
|
||||
}
|
||||
|
||||
$editorType = $this->getEditorType($page);
|
||||
$this->updateContentForEditor($page, $editorType);
|
||||
|
||||
return [
|
||||
'page' => $page,
|
||||
'book' => $page->book,
|
||||
'isDraft' => $isDraft,
|
||||
'isDraftRevision' => $isDraftRevision,
|
||||
'draftsEnabled' => $draftsEnabled,
|
||||
'templates' => $templates,
|
||||
'editor' => $editorType,
|
||||
];
|
||||
}
|
||||
|
||||
protected function updateContentForEditor(Page $page, string $editorType): void
|
||||
{
|
||||
$isHtml = !empty($page->html) && empty($page->markdown);
|
||||
|
||||
// HTML to markdown-clean conversion
|
||||
if ($editorType === 'markdown' && $isHtml && $this->requestedEditor === 'markdown-clean') {
|
||||
$page->markdown = (new HtmlToMarkdown($page->html))->convert();
|
||||
}
|
||||
|
||||
// Markdown to HTML conversion if we don't have HTML
|
||||
if ($editorType === 'wysiwyg' && !$isHtml) {
|
||||
$page->html = (new MarkdownToHtml($page->markdown))->convert();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of editor to show for editing the given page.
|
||||
* Defaults based upon the current content of the page otherwise will fall back
|
||||
* to system default but will take a requested type (if provided) if permissions allow.
|
||||
*/
|
||||
protected function getEditorType(Page $page): string
|
||||
{
|
||||
$editorType = $page->editor ?: self::getSystemDefaultEditor();
|
||||
|
||||
// Use requested editor if valid and if we have permission
|
||||
$requestedType = explode('-', $this->requestedEditor)[0];
|
||||
if (($requestedType === 'markdown' || $requestedType === 'wysiwyg') && userCan('editor-change')) {
|
||||
$editorType = $requestedType;
|
||||
}
|
||||
|
||||
return $editorType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured system default editor.
|
||||
*/
|
||||
public static function getSystemDefaultEditor(): string
|
||||
{
|
||||
return setting('app-editor') === 'markdown' ? 'markdown' : 'wysiwyg';
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use Barryvdh\DomPDF\Facade as DomPDF;
|
||||
use Barryvdh\DomPDF\Facade\Pdf as DomPDF;
|
||||
use Barryvdh\Snappy\Facades\SnappyPdf;
|
||||
|
||||
class PdfGenerator
|
||||
|
||||
@@ -20,6 +20,7 @@ class ShelfContext
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var Bookshelf $shelf */
|
||||
$shelf = Bookshelf::visible()->find($contextBookshelfId);
|
||||
$shelfContainsBook = $shelf && $shelf->contains($book);
|
||||
|
||||
|
||||
@@ -344,7 +344,7 @@ class TrashCan
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function destroyEntity(Entity $entity): int
|
||||
public function destroyEntity(Entity $entity): int
|
||||
{
|
||||
if ($entity instanceof Page) {
|
||||
return $this->destroyPage($entity);
|
||||
@@ -376,6 +376,8 @@ class TrashCan
|
||||
$entity->searchTerms()->delete();
|
||||
$entity->deletions()->delete();
|
||||
$entity->favourites()->delete();
|
||||
$entity->referencesTo()->delete();
|
||||
$entity->referencesFrom()->delete();
|
||||
|
||||
if ($entity instanceof HasCoverImage && $entity->cover()->exists()) {
|
||||
$imageService = app()->make(ImageService::class);
|
||||
|
||||
@@ -21,6 +21,7 @@ class Handler extends ExceptionHandler
|
||||
*/
|
||||
protected $dontReport = [
|
||||
NotFoundException::class,
|
||||
StoppedAuthenticationException::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -101,6 +102,10 @@ class Handler extends ExceptionHandler
|
||||
$code = $e->status;
|
||||
}
|
||||
|
||||
if (method_exists($e, 'getStatus')) {
|
||||
$code = $e->getStatus();
|
||||
}
|
||||
|
||||
$responseData['error']['code'] = $code;
|
||||
|
||||
return new JsonResponse($responseData, $code, $headers);
|
||||
|
||||
@@ -3,24 +3,29 @@
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class JsonDebugException extends Exception
|
||||
{
|
||||
protected $data;
|
||||
protected array $data;
|
||||
|
||||
/**
|
||||
* JsonDebugException constructor.
|
||||
*/
|
||||
public function __construct($data)
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Covert this exception into a response.
|
||||
* Convert this exception into a response.
|
||||
* We add a manual data conversion to UTF8 to ensure any binary data is presentable as a JSON string.
|
||||
*/
|
||||
public function render()
|
||||
public function render(): JsonResponse
|
||||
{
|
||||
return response()->json($this->data);
|
||||
$cleaned = mb_convert_encoding($this->data, 'UTF-8');
|
||||
|
||||
return response()->json($cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,24 @@ class NotifyException extends Exception implements Responsable
|
||||
{
|
||||
public $message;
|
||||
public $redirectLocation;
|
||||
protected $status;
|
||||
|
||||
/**
|
||||
* NotifyException constructor.
|
||||
*/
|
||||
public function __construct(string $message, string $redirectLocation = '/')
|
||||
public function __construct(string $message, string $redirectLocation = '/', int $status = 500)
|
||||
{
|
||||
$this->message = $message;
|
||||
$this->redirectLocation = $redirectLocation;
|
||||
$this->status = $status;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the desired status code for this exception.
|
||||
*/
|
||||
public function getStatus(): int
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the response for this type of exception.
|
||||
*
|
||||
@@ -29,6 +36,11 @@ class NotifyException extends Exception implements Responsable
|
||||
{
|
||||
$message = $this->getMessage();
|
||||
|
||||
// Front-end JSON handling. API-side handling managed via handler.
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['error' => $message], 403);
|
||||
}
|
||||
|
||||
if (!empty($message)) {
|
||||
session()->flash('error', $message);
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
class OpenIdConnectException extends NotifyException
|
||||
{
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace BookStack\Facades;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
/**
|
||||
* @see \BookStack\Actions\ActivityLogger
|
||||
* @mixin \BookStack\Actions\ActivityLogger
|
||||
*/
|
||||
class Activity extends Facade
|
||||
{
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Facades;
|
||||
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
class Permissions extends Facade
|
||||
{
|
||||
/**
|
||||
* Get the registered name of the component.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected static function getFacadeAccessor()
|
||||
{
|
||||
return 'permissions';
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,14 @@ abstract class ApiController extends Controller
|
||||
* Provide a paginated listing JSON response in a standard format
|
||||
* taking into account any pagination parameters passed by the user.
|
||||
*/
|
||||
protected function apiListingResponse(Builder $query, array $fields): JsonResponse
|
||||
protected function apiListingResponse(Builder $query, array $fields, array $modifiers = []): JsonResponse
|
||||
{
|
||||
$listing = new ListingResponseBuilder($query, request(), $fields);
|
||||
|
||||
foreach ($modifiers as $modifier) {
|
||||
$listing->modifyResults($modifier);
|
||||
}
|
||||
|
||||
return $listing->toResponse();
|
||||
}
|
||||
|
||||
@@ -26,7 +30,7 @@ abstract class ApiController extends Controller
|
||||
* Get the validation rules for this controller.
|
||||
* Defaults to a $rules property but can be a rules() method.
|
||||
*/
|
||||
public function getValdationRules(): array
|
||||
public function getValidationRules(): array
|
||||
{
|
||||
if (method_exists($this, 'rules')) {
|
||||
return $this->rules();
|
||||
|
||||
@@ -87,14 +87,33 @@ class AttachmentApiController extends ApiController
|
||||
'markdown' => $attachment->markdownLink(),
|
||||
]);
|
||||
|
||||
if (!$attachment->external) {
|
||||
$attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
|
||||
$attachment->setAttribute('content', base64_encode($attachmentContents));
|
||||
} else {
|
||||
// Simply return a JSON response of the attachment for link-based attachments
|
||||
if ($attachment->external) {
|
||||
$attachment->setAttribute('content', $attachment->path);
|
||||
|
||||
return response()->json($attachment);
|
||||
}
|
||||
|
||||
return response()->json($attachment);
|
||||
// Build and split our core JSON, at point of content.
|
||||
$splitter = 'CONTENT_SPLIT_LOCATION_' . time() . '_' . rand(1, 40000);
|
||||
$attachment->setAttribute('content', $splitter);
|
||||
$json = $attachment->toJson();
|
||||
$jsonParts = explode($splitter, $json);
|
||||
// Get a stream for the file data from storage
|
||||
$stream = $this->attachmentService->streamAttachmentFromStorage($attachment);
|
||||
|
||||
return response()->stream(function () use ($jsonParts, $stream) {
|
||||
// Output the pre-content JSON data
|
||||
echo $jsonParts[0];
|
||||
|
||||
// Stream out our attachment data as base64 content
|
||||
stream_filter_append($stream, 'convert.base64-encode', STREAM_FILTER_READ);
|
||||
fpassthru($stream);
|
||||
fclose($stream);
|
||||
|
||||
// Output our post-content JSON data
|
||||
echo $jsonParts[1];
|
||||
}, 200, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,19 +11,6 @@ class BookApiController extends ApiController
|
||||
{
|
||||
protected $bookRepo;
|
||||
|
||||
protected $rules = [
|
||||
'create' => [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'tags' => ['array'],
|
||||
],
|
||||
'update' => [
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'tags' => ['array'],
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(BookRepo $bookRepo)
|
||||
{
|
||||
$this->bookRepo = $bookRepo;
|
||||
@@ -37,19 +24,21 @@ class BookApiController extends ApiController
|
||||
$books = Book::visible();
|
||||
|
||||
return $this->apiListingResponse($books, [
|
||||
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by', 'image_id',
|
||||
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new book in the system.
|
||||
* The cover image of a book can be set by sending a file via an 'image' property within a 'multipart/form-data' request.
|
||||
* If the 'image' property is null then the book cover image will be removed.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function create(Request $request)
|
||||
{
|
||||
$this->checkPermission('book-create-all');
|
||||
$requestData = $this->validate($request, $this->rules['create']);
|
||||
$requestData = $this->validate($request, $this->rules()['create']);
|
||||
|
||||
$book = $this->bookRepo->create($requestData);
|
||||
|
||||
@@ -68,6 +57,8 @@ class BookApiController extends ApiController
|
||||
|
||||
/**
|
||||
* Update the details of a single book.
|
||||
* The cover image of a book can be set by sending a file via an 'image' property within a 'multipart/form-data' request.
|
||||
* If the 'image' property is null then the book cover image will be removed.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
@@ -76,7 +67,7 @@ class BookApiController extends ApiController
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
|
||||
$requestData = $this->validate($request, $this->rules['update']);
|
||||
$requestData = $this->validate($request, $this->rules()['update']);
|
||||
$book = $this->bookRepo->update($book, $requestData);
|
||||
|
||||
return response()->json($book);
|
||||
@@ -97,4 +88,22 @@ class BookApiController extends ApiController
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'create' => [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
],
|
||||
'update' => [
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ class BookExportApiController extends ApiController
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$pdfContent = $this->exportFormatter->bookToPdf($book);
|
||||
|
||||
return $this->downloadResponse($pdfContent, $book->slug . '.pdf');
|
||||
return $this->download()->directly($pdfContent, $book->slug . '.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,7 +39,7 @@ class BookExportApiController extends ApiController
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$htmlContent = $this->exportFormatter->bookToContainedHtml($book);
|
||||
|
||||
return $this->downloadResponse($htmlContent, $book->slug . '.html');
|
||||
return $this->download()->directly($htmlContent, $book->slug . '.html');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +50,7 @@ class BookExportApiController extends ApiController
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$textContent = $this->exportFormatter->bookToPlainText($book);
|
||||
|
||||
return $this->downloadResponse($textContent, $book->slug . '.txt');
|
||||
return $this->download()->directly($textContent, $book->slug . '.txt');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +61,6 @@ class BookExportApiController extends ApiController
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$markdown = $this->exportFormatter->bookToMarkdown($book);
|
||||
|
||||
return $this->downloadResponse($markdown, $book->slug . '.md');
|
||||
return $this->download()->directly($markdown, $book->slug . '.md');
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user