mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-24 19:07:20 +03:00
Compare commits
941 Commits
v23.12
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a12e3a8b7 | ||
|
|
6808292c90 | ||
|
|
c10b0fd5b9 | ||
|
|
1077a4efd0 | ||
|
|
23f3f35f6b | ||
|
|
229a99ba24 | ||
|
|
8e99fc6783 | ||
|
|
80204518a2 | ||
|
|
a8d96fd389 | ||
|
|
9d15c79fee | ||
|
|
e1de1f0583 | ||
|
|
a2017ffa55 | ||
|
|
9646339933 | ||
|
|
e4383765e1 | ||
|
|
5d547fcf4c | ||
|
|
826b36c985 | ||
|
|
3fa1174e7a | ||
|
|
50e8501027 | ||
|
|
8a221f64e4 | ||
|
|
035be66ebc | ||
|
|
227027fc45 | ||
|
|
0f040fe8b1 | ||
|
|
10ebe53bd9 | ||
|
|
5e12b678c7 | ||
|
|
057d7be0bc | ||
|
|
984a73159f | ||
|
|
a20438b901 | ||
|
|
9d3d0a4a07 | ||
|
|
5038d124e1 | ||
|
|
f7890c2dd9 | ||
|
|
45ae03ceac | ||
|
|
aa0a8dda11 | ||
|
|
120ee38383 | ||
|
|
cd84074cdf | ||
|
|
4949520194 | ||
|
|
46dcc30bf7 | ||
|
|
9f7d3b55dd | ||
|
|
3e5e88dc87 | ||
|
|
c77a0fdff3 | ||
|
|
6a63b38bb3 | ||
|
|
1b17bb3929 | ||
|
|
9fcfc762ec | ||
|
|
c32b1686a9 | ||
|
|
36649a6188 | ||
|
|
ff59bbdc07 | ||
|
|
4dc443b7df | ||
|
|
19f02d927e | ||
|
|
da7bedd2e4 | ||
|
|
20db372596 | ||
|
|
43eed1660c | ||
|
|
e6b754fad0 | ||
|
|
018de5def3 | ||
|
|
5c4fc3dc2c | ||
|
|
07ec880e33 | ||
|
|
ab436ed5c3 | ||
|
|
082befb2fc | ||
|
|
b0a8cb0c5d | ||
|
|
b08d1b36de | ||
|
|
88d86df66f | ||
|
|
38d3697246 | ||
|
|
d93354ff0e | ||
|
|
3336e0c6ae | ||
|
|
8fc9a2af4e | ||
|
|
8aec571123 | ||
|
|
382f4db276 | ||
|
|
d504b19143 | ||
|
|
d87e8d05c7 | ||
|
|
0b48361780 | ||
|
|
2de3247ae4 | ||
|
|
48df2be0d8 | ||
|
|
a4c0556551 | ||
|
|
a941d1b403 | ||
|
|
51f9b63db0 | ||
|
|
90fc02c57f | ||
|
|
4aeb571126 | ||
|
|
3d9aba7b1f | ||
|
|
48cdaab690 | ||
|
|
4f760479c3 | ||
|
|
9211062e8e | ||
|
|
221c6c7e9f | ||
|
|
e2f91c2bbb | ||
|
|
147ff00c7a | ||
|
|
1e768ce33f | ||
|
|
313326b32a | ||
|
|
1d87b513be | ||
|
|
9bf9ae9c37 | ||
|
|
50540e23a1 | ||
|
|
3e1b0587ec | ||
|
|
6661ae8178 | ||
|
|
1ee5711435 | ||
|
|
08e7ba7064 | ||
|
|
34e747162f | ||
|
|
10f5ceee35 | ||
|
|
9886bbd3a0 | ||
|
|
92a3c22b4c | ||
|
|
b5246a28f0 | ||
|
|
ab4b1c8efa | ||
|
|
8890746278 | ||
|
|
dfdcfcfdb8 | ||
|
|
ebceba0afe | ||
|
|
65f7b61c1f | ||
|
|
2fde803c76 | ||
|
|
adfac3e30e | ||
|
|
21730aeb39 | ||
|
|
75231d2d4a | ||
|
|
9d732d8dd8 | ||
|
|
9e8088f186 | ||
|
|
cf847974d2 | ||
|
|
3cd3e73f60 | ||
|
|
bb350639c6 | ||
|
|
9de294343d | ||
|
|
98a09bcc37 | ||
|
|
959981a676 | ||
|
|
674bb84fac | ||
|
|
ba675b6349 | ||
|
|
f073994bc3 | ||
|
|
0f40aeb0d3 | ||
|
|
cdd164e3e3 | ||
|
|
c90816987c | ||
|
|
dd393691b1 | ||
|
|
dd5375f480 | ||
|
|
291a807d98 | ||
|
|
e64fc60bdf | ||
|
|
ad582ab9f8 | ||
|
|
870f3c58c0 | ||
|
|
22a7772c3d | ||
|
|
9934f85ba9 | ||
|
|
73c6bf4f8d | ||
|
|
47f12cc8f6 | ||
|
|
b2f81f5c62 | ||
|
|
1be2969055 | ||
|
|
99a1d82f0a | ||
|
|
f06a6de2e7 | ||
|
|
aaa28186bc | ||
|
|
8ab9252f9b | ||
|
|
befc645705 | ||
|
|
4eb4407ef7 | ||
|
|
5bf2d801cf | ||
|
|
1421ba871d | ||
|
|
563828ba52 | ||
|
|
d40a68b411 | ||
|
|
4a57933cd1 | ||
|
|
1df850ea3e | ||
|
|
7881bddce0 | ||
|
|
570ded10fa | ||
|
|
02d024aa32 | ||
|
|
652124abaf | ||
|
|
751934c84a | ||
|
|
3fd25bd03e | ||
|
|
f0303de2e5 | ||
|
|
0b26573314 | ||
|
|
c21c36e2a6 | ||
|
|
a949900570 | ||
|
|
9c4a9225af | ||
|
|
4627dfd4f7 | ||
|
|
fcacf7cacb | ||
|
|
cbf27d70c8 | ||
|
|
3ad1e31fcc | ||
|
|
082dbc9944 | ||
|
|
abe9c1e5a3 | ||
|
|
ebf82617b8 | ||
|
|
2c81447c9e | ||
|
|
8898647f78 | ||
|
|
ea6344898f | ||
|
|
0bfd79925e | ||
|
|
efff8700d4 | ||
|
|
5754acf2fb | ||
|
|
4c7d6420ee | ||
|
|
146a6c01cc | ||
|
|
f8e4ea82c6 | ||
|
|
047195c033 | ||
|
|
a7b30c284c | ||
|
|
c3412d8c1c | ||
|
|
4db7135231 | ||
|
|
009d146185 | ||
|
|
fcef1a7948 | ||
|
|
08dfff05f4 | ||
|
|
fc10520e10 | ||
|
|
a70c733f27 | ||
|
|
573d692a59 | ||
|
|
419dbadcfd | ||
|
|
33a0237f87 | ||
|
|
5fc11d46d5 | ||
|
|
c8716df284 | ||
|
|
1ac74099ca | ||
|
|
36cb243d5e | ||
|
|
579c1bf424 | ||
|
|
242b7dfb1b | ||
|
|
7d1c316202 | ||
|
|
318b486e0b | ||
|
|
e05ec7da36 | ||
|
|
cee23de6c5 | ||
|
|
1e34954554 | ||
|
|
5ea4e1e935 | ||
|
|
a27ce6e915 | ||
|
|
64b06bcf61 | ||
|
|
cdbac63b40 | ||
|
|
d6296ac7a5 | ||
|
|
481f356068 | ||
|
|
955837c9aa | ||
|
|
c6e35c2e7c | ||
|
|
0436ccfebf | ||
|
|
f5da31037d | ||
|
|
46613f76f6 | ||
|
|
519acaf324 | ||
|
|
849bc4d6c3 | ||
|
|
ee994fa2b7 | ||
|
|
13a79b3f96 | ||
|
|
7c79b10fb6 | ||
|
|
5c481b4282 | ||
|
|
9443682ae4 | ||
|
|
0311e3d2d7 | ||
|
|
a50a256939 | ||
|
|
4830248a1e | ||
|
|
1256b30ad4 | ||
|
|
777cca76da | ||
|
|
a2d13124af | ||
|
|
bd966ef99e | ||
|
|
a6b5733ec2 | ||
|
|
e899066e96 | ||
|
|
f4f2435856 | ||
|
|
fca4a0563e | ||
|
|
0bc9ddd780 | ||
|
|
c66f3b2a37 | ||
|
|
f36e6fb929 | ||
|
|
7bc0d54af1 | ||
|
|
2eefbd21c1 | ||
|
|
a961552c23 | ||
|
|
776ec7b9e7 | ||
|
|
8aa6bdc8ab | ||
|
|
4ab17157b1 | ||
|
|
6d7ffab115 | ||
|
|
c8cfec96dc | ||
|
|
d145efb6f6 | ||
|
|
c54101c603 | ||
|
|
865e5aecc9 | ||
|
|
ae4d1d804a | ||
|
|
5fc19b0edf | ||
|
|
0a73b70b64 | ||
|
|
2668aae09b | ||
|
|
3b9c0b34ae | ||
|
|
53f32849a9 | ||
|
|
7ca8bdc231 | ||
|
|
6621d55f3d | ||
|
|
d55db06c01 | ||
|
|
6b4b500a33 | ||
|
|
5ffec2c52d | ||
|
|
ec07793cda | ||
|
|
61adc735c8 | ||
|
|
7bbf591a7f | ||
|
|
61f8d18af5 | ||
|
|
f786d25f2e | ||
|
|
e62f4426ea | ||
|
|
32ba3a591f | ||
|
|
73025719a4 | ||
|
|
d55684531f | ||
|
|
d15eb129b0 | ||
|
|
3626a2265b | ||
|
|
d13abc7e1d | ||
|
|
2442829ef2 | ||
|
|
795b28162a | ||
|
|
31706ea06b | ||
|
|
4b9e6042d5 | ||
|
|
d279b0830b | ||
|
|
181ab91b1d | ||
|
|
306f41b6f0 | ||
|
|
c1d76d2571 | ||
|
|
f83074d50e | ||
|
|
2be892be70 | ||
|
|
c934b9319f | ||
|
|
35a51197ce | ||
|
|
47fd578edb | ||
|
|
add091305c | ||
|
|
3d017594a8 | ||
|
|
0dcb2ec78c | ||
|
|
9186e77d27 | ||
|
|
6045aff33a | ||
|
|
dca9765d5d | ||
|
|
a37d0c57dc | ||
|
|
054475135a | ||
|
|
02a35b6db4 | ||
|
|
b80992ca59 | ||
|
|
c606970e38 | ||
|
|
dfeca246a0 | ||
|
|
3476d83ecc | ||
|
|
3617ab1540 | ||
|
|
c4839c783a | ||
|
|
a5751a584c | ||
|
|
f518a3be37 | ||
|
|
0208f066c5 | ||
|
|
2d0461b63a | ||
|
|
b913ae703d | ||
|
|
1611b0399f | ||
|
|
8d4b8ff4f3 | ||
|
|
77a88618c2 | ||
|
|
8b062d4795 | ||
|
|
717b516341 | ||
|
|
fda242d3da | ||
|
|
aac547934c | ||
|
|
5c9b90ea0d | ||
|
|
074f193e2f | ||
|
|
7f2604c8e8 | ||
|
|
b71b2a4376 | ||
|
|
68df43e5a8 | ||
|
|
c5ca865723 | ||
|
|
b862f12a50 | ||
|
|
b0f8b11054 | ||
|
|
7650ebf2f9 | ||
|
|
d9ea52522e | ||
|
|
2e718c12e1 | ||
|
|
a43a1832f5 | ||
|
|
c4f7368c1c | ||
|
|
2a32475541 | ||
|
|
1243108e0f | ||
|
|
3280919370 | ||
|
|
d149b809b1 | ||
|
|
eb47e11916 | ||
|
|
9d6bc1ad4d | ||
|
|
30bf0ce632 | ||
|
|
b64c9b31d5 | ||
|
|
f9dbbe5d70 | ||
|
|
05f7f4cb17 | ||
|
|
454b152b95 | ||
|
|
b29fe5c46d | ||
|
|
131ac29df4 | ||
|
|
3a9d18a6cd | ||
|
|
59e2c5e52a | ||
|
|
d29b14ebfd | ||
|
|
cdd446ac73 | ||
|
|
1dd1024eba | ||
|
|
752cfe2f67 | ||
|
|
25baaa8189 | ||
|
|
d2d0331782 | ||
|
|
8121418e18 | ||
|
|
5ab31a8191 | ||
|
|
0e69ab1938 | ||
|
|
058007109e | ||
|
|
b6110ed3cd | ||
|
|
32b29fcdfc | ||
|
|
8f92b6f21b | ||
|
|
62f78f1c6d | ||
|
|
f8c0aaff03 | ||
|
|
a27df485bb | ||
|
|
3e99ce4098 | ||
|
|
ce1e20501c | ||
|
|
295532fa7a | ||
|
|
642ba668b1 | ||
|
|
4f36cdd757 | ||
|
|
8821844c4a | ||
|
|
1262083fcf | ||
|
|
c82fa33210 | ||
|
|
15c79c38db | ||
|
|
e7dcc2dcdf | ||
|
|
099f6104d0 | ||
|
|
8bdf948743 | ||
|
|
e8f44186a8 | ||
|
|
ecda4e1d6f | ||
|
|
64da80cbf4 | ||
|
|
5fa728f28a | ||
|
|
c61ce8dee4 | ||
|
|
f656a82fe7 | ||
|
|
5bfba281fc | ||
|
|
18ede9bbd3 | ||
|
|
2e7544a865 | ||
|
|
5e3c3ad634 | ||
|
|
add238fe9f | ||
|
|
8d159f77e4 | ||
|
|
fa566f156a | ||
|
|
78a0a2f519 | ||
|
|
42cbd6adef | ||
|
|
6117349893 | ||
|
|
1256320c72 | ||
|
|
1ba0d26fdd | ||
|
|
802f69cf35 | ||
|
|
bb44334224 | ||
|
|
9bfcadd95f | ||
|
|
62c8eb3357 | ||
|
|
c03e44124a | ||
|
|
5c6671b3bf | ||
|
|
abe7467ae5 | ||
|
|
0ec0913846 | ||
|
|
e980564fd6 | ||
|
|
8a9215ecad | ||
|
|
304a1d8f91 | ||
|
|
dfbc78947f | ||
|
|
4f5ad171ac | ||
|
|
94b1cffa2d | ||
|
|
13dae24cbe | ||
|
|
6211d6bcfc | ||
|
|
a384599cfa | ||
|
|
dca14feaaa | ||
|
|
d7ccb3ce6a | ||
|
|
6548ea4a12 | ||
|
|
c3a1fabbf0 | ||
|
|
d2542d6265 | ||
|
|
0e343c408f | ||
|
|
5c78f8352e | ||
|
|
35b45a2b8d | ||
|
|
5050719ea3 | ||
|
|
5508c171db | ||
|
|
3b4d3430a5 | ||
|
|
213a86e3c0 | ||
|
|
2b746425c9 | ||
|
|
5c15f4add2 | ||
|
|
92ad81429f | ||
|
|
f1b8e857bf | ||
|
|
c291d27c19 | ||
|
|
f4449928f8 | ||
|
|
45a15b4792 | ||
|
|
2291d78382 | ||
|
|
7901ca9e6b | ||
|
|
a7de251876 | ||
|
|
7bd89316bc | ||
|
|
b9306a9029 | ||
|
|
a208c46b62 | ||
|
|
a65701294e | ||
|
|
69683d50ec | ||
|
|
37d020c083 | ||
|
|
ec79517493 | ||
|
|
d938565839 | ||
|
|
ccd94684eb | ||
|
|
103a8a8e8e | ||
|
|
c13ce18837 | ||
|
|
7093daa49d | ||
|
|
b897af2ed0 | ||
|
|
d28278bba6 | ||
|
|
12cc2f0689 | ||
|
|
bf8a84a8b1 | ||
|
|
4f5f7c10b1 | ||
|
|
a34023f715 | ||
|
|
b2ac3e0834 | ||
|
|
5b0cb3dd50 | ||
|
|
ac0cd9995d | ||
|
|
7e03a973d8 | ||
|
|
d89a2fdb15 | ||
|
|
958b537a49 | ||
|
|
8a66365d48 | ||
|
|
da82e70ca3 | ||
|
|
04cca77ae6 | ||
|
|
c091f67db3 | ||
|
|
7f5fd16dc6 | ||
|
|
0d1a237f81 | ||
|
|
786a434c03 | ||
|
|
25c4f4b02b | ||
|
|
481580be17 | ||
|
|
593645acfe | ||
|
|
b9751807e7 | ||
|
|
ee88832f1a | ||
|
|
dbda82ef92 | ||
|
|
ad8bc5fe21 | ||
|
|
5bf75786c6 | ||
|
|
cf9ccfcd5b | ||
|
|
5116d83d38 | ||
|
|
33b46882f3 | ||
|
|
9a5c287470 | ||
|
|
6effc6d262 | ||
|
|
ff6c5aaecb | ||
|
|
1ff2826678 | ||
|
|
7e31725d48 | ||
|
|
6d7ff59a89 | ||
|
|
980a684b14 | ||
|
|
d56eea9279 | ||
|
|
2be504e0d2 | ||
|
|
c84d999456 | ||
|
|
01825ddb93 | ||
|
|
1f88bc2a59 | ||
|
|
ebe2ca7faf | ||
|
|
f4005a139b | ||
|
|
fca8f928a3 | ||
|
|
ace8af077d | ||
|
|
e50cd33277 | ||
|
|
8486775edf | ||
|
|
5887322178 | ||
|
|
3f86937f74 | ||
|
|
2f119d3033 | ||
|
|
5f07f31c9f | ||
|
|
a71aa241ad | ||
|
|
97b201f61f | ||
|
|
a8ef820443 | ||
|
|
7e1a8e5ec6 | ||
|
|
19ee1c9be7 | ||
|
|
fcf0bf79a9 | ||
|
|
0ece664475 | ||
|
|
509af2463d | ||
|
|
5632fef621 | ||
|
|
8ec26e8083 | ||
|
|
617b2edea0 | ||
|
|
55d074f1a5 | ||
|
|
7e6f6af463 | ||
|
|
d00cf6e1ba | ||
|
|
9fdd100f2d | ||
|
|
57d8449660 | ||
|
|
ebd4604f21 | ||
|
|
36a4d79120 | ||
|
|
f3fa63a5ae | ||
|
|
5164375b18 | ||
|
|
fec44452cb | ||
|
|
18ab38a87b | ||
|
|
0f9957bc03 | ||
|
|
80f258c3c5 | ||
|
|
90341e0e00 | ||
|
|
3298374113 | ||
|
|
227c5e155b | ||
|
|
fdbbcf2b8a | ||
|
|
0a07b0d162 | ||
|
|
94165cc18f | ||
|
|
f5ecd51461 | ||
|
|
e9f906ce56 | ||
|
|
4630f07282 | ||
|
|
978acecdcf | ||
|
|
bc1f1d92e5 | ||
|
|
415cd6a360 | ||
|
|
68ce340741 | ||
|
|
bdca9fc1ce | ||
|
|
edb684c72c | ||
|
|
17f7afe12d | ||
|
|
0a182a45ba | ||
|
|
95d62e7f57 | ||
|
|
9ecc91929a | ||
|
|
f79c6aef8d | ||
|
|
c0dff6d4a6 | ||
|
|
59cfc087e1 | ||
|
|
e2f6e50df4 | ||
|
|
c2c64e207f | ||
|
|
8645aeaa4a | ||
|
|
7681e32dca | ||
|
|
b7476a9e7f | ||
|
|
306b8774c2 | ||
|
|
c40ab4147e | ||
|
|
48c101aa7a | ||
|
|
378f0d595f | ||
|
|
f12946d581 | ||
|
|
d13e4d2eef | ||
|
|
ac27e18933 | ||
|
|
e5a6ccc4d4 | ||
|
|
e42cdbe8e0 | ||
|
|
a6ba8dd68f | ||
|
|
7017a1cae5 | ||
|
|
8120278b8c | ||
|
|
73babcbfe3 | ||
|
|
45189d9517 | ||
|
|
7b84558ca1 | ||
|
|
92cfde495e | ||
|
|
14578c2257 | ||
|
|
8f6f81948e | ||
|
|
c6109c7087 | ||
|
|
8ea3855e02 | ||
|
|
74fce9640e | ||
|
|
259aa829d4 | ||
|
|
c4ec50d437 | ||
|
|
b50b7b667d | ||
|
|
fbeb2e23d4 | ||
|
|
4b60c03caa | ||
|
|
a56a28fbb7 | ||
|
|
4051d5b803 | ||
|
|
87242ce6cb | ||
|
|
72d9ffd8b4 | ||
|
|
f606711463 | ||
|
|
d1f69feb4a | ||
|
|
e4ca3bf132 | ||
|
|
7aaf866064 | ||
|
|
484342f26a | ||
|
|
42ada66fdd | ||
|
|
f732ef05d5 | ||
|
|
4fb4fe0931 | ||
|
|
06ffd8ee72 | ||
|
|
90a8070518 | ||
|
|
3e656efb00 | ||
|
|
7c39dd5cba | ||
|
|
21ccfa97dd | ||
|
|
bf0262d7d1 | ||
|
|
42b9700673 | ||
|
|
42bd07d733 | ||
|
|
6f1c54d018 | ||
|
|
1930af91ce | ||
|
|
e088d09e47 | ||
|
|
209fa04752 | ||
|
|
f41c02cbd7 | ||
|
|
4dc75bad05 | ||
|
|
a3d0f7478f | ||
|
|
b9b5003239 | ||
|
|
2e8d6ce7d9 | ||
|
|
a58102d6ef | ||
|
|
65453bd94e | ||
|
|
d22413b931 | ||
|
|
8b9bcc1768 | ||
|
|
51287d545b | ||
|
|
c314a60a16 | ||
|
|
9b2520aa0c | ||
|
|
346b88ae43 | ||
|
|
2766c76491 | ||
|
|
be6529d0a1 | ||
|
|
b1a3ea1aa4 | ||
|
|
6646dcc24d | ||
|
|
966ff91386 | ||
|
|
cd84d08157 | ||
|
|
93c677a6a9 | ||
|
|
177cfd72bf | ||
|
|
34ade50181 | ||
|
|
e65655594f | ||
|
|
514db60617 | ||
|
|
8bc6e75319 | ||
|
|
2f74cfb42c | ||
|
|
1302e3c959 | ||
|
|
a5b031f906 | ||
|
|
f583354748 | ||
|
|
d12e8ec923 | ||
|
|
89f84c9a95 | ||
|
|
6103a22feb | ||
|
|
42264f402d | ||
|
|
abda9bc00a | ||
|
|
eec639d84e | ||
|
|
56b9107c6b | ||
|
|
b35b62d59f | ||
|
|
1b9310e766 | ||
|
|
a62d8381be | ||
|
|
8b32e6c15a | ||
|
|
c8ccb2bac7 | ||
|
|
ef3de1050f | ||
|
|
2add15bd72 | ||
|
|
e6edd9340e | ||
|
|
654a7a5d03 | ||
|
|
dba8ab947f | ||
|
|
787e06e3d8 | ||
|
|
ccd486f2a9 | ||
|
|
22d078b47f | ||
|
|
03490d6597 | ||
|
|
5f46d71af0 | ||
|
|
4f890c431c | ||
|
|
c110a97d8a | ||
|
|
6872eb802c | ||
|
|
662110c269 | ||
|
|
5083188ed8 | ||
|
|
2036438203 | ||
|
|
476c2be5a6 | ||
|
|
ced66f1671 | ||
|
|
fb49371c6b | ||
|
|
fd07aa0f05 | ||
|
|
16518a4f89 | ||
|
|
bed2c29a33 | ||
|
|
e5b6d28bca | ||
|
|
1c9afcb84e | ||
|
|
3a058a6e34 | ||
|
|
aac7d564c8 | ||
|
|
9aa3442a17 | ||
|
|
c68d154f0f | ||
|
|
1b4ed69f41 | ||
|
|
8cef998f49 | ||
|
|
90d1223acd | ||
|
|
1f2506221a | ||
|
|
9f68ca5358 | ||
|
|
1ebb0f8c93 | ||
|
|
8a13a9df80 | ||
|
|
ddf5f2543c | ||
|
|
dbb2fe3e59 | ||
|
|
aa1fac62d5 | ||
|
|
111a313d51 | ||
|
|
0039f893cc | ||
|
|
ad6b26ba97 | ||
|
|
1ef4044419 | ||
|
|
accf2565a0 | ||
|
|
ec965f28c0 | ||
|
|
ebf95f637a | ||
|
|
abbfd42a6c | ||
|
|
db4208a7eb | ||
|
|
da54e1d87c | ||
|
|
e8532ef4de | ||
|
|
fa6d66db49 | ||
|
|
6604e7365f | ||
|
|
fcc1c2968d | ||
|
|
b3d3b14f79 | ||
|
|
8939f310db | ||
|
|
efec752985 | ||
|
|
e94ad78ea7 | ||
|
|
a27a325af7 | ||
|
|
6b06d490c5 | ||
|
|
13f8f39dd5 | ||
|
|
fe05cff64f | ||
|
|
d86837ac07 | ||
|
|
9a7edc6e52 | ||
|
|
ce8c9dd079 | ||
|
|
c8f6b7e0d6 | ||
|
|
f284d31861 | ||
|
|
76b0d2d5d8 | ||
|
|
2cab778f19 | ||
|
|
c31f8eb2e0 | ||
|
|
b618287585 | ||
|
|
63f4b42453 | ||
|
|
c7c0df0964 | ||
|
|
fb87fb5750 | ||
|
|
634b0aaa07 | ||
|
|
5002a89754 | ||
|
|
b367490edc | ||
|
|
e145f21512 | ||
|
|
ea4c50c2c2 | ||
|
|
47ac0d5c3e | ||
|
|
75f225d6dc | ||
|
|
adb7bf7016 | ||
|
|
897bb338f9 | ||
|
|
767699a066 | ||
|
|
7161f22706 | ||
|
|
ddec8097b7 | ||
|
|
95c3cc5c00 | ||
|
|
60c53705ca | ||
|
|
51d8044a54 | ||
|
|
ce697ab0f5 | ||
|
|
ca310966b2 | ||
|
|
25f92ce584 | ||
|
|
2c96af9aea | ||
|
|
04c7e680fd | ||
|
|
9b0ef85f77 | ||
|
|
a8f1160743 | ||
|
|
feca1f0502 | ||
|
|
d0a5a5ef37 | ||
|
|
97f570a4ee | ||
|
|
9ebbf7ce94 | ||
|
|
c2ecbf071f | ||
|
|
b1c489090e | ||
|
|
c9a03c5b01 | ||
|
|
517c578a5f | ||
|
|
14837e34fb | ||
|
|
f10ec3271a | ||
|
|
4e2820d6e3 | ||
|
|
72a0e081ca | ||
|
|
b1130cb1c3 | ||
|
|
59936631ec | ||
|
|
3af22ce754 | ||
|
|
5546b8ff43 | ||
|
|
a07092b7e6 | ||
|
|
ac01c62e6e | ||
|
|
f47f7dd9d2 | ||
|
|
13d970c7ce | ||
|
|
e2409a5fab | ||
|
|
e30aae3399 | ||
|
|
b81f2b52d0 | ||
|
|
9e43e03db4 | ||
|
|
a475cf68bf | ||
|
|
e889bc680b | ||
|
|
c096b20d9c | ||
|
|
11a7ccc37e | ||
|
|
d9b9e6c0b1 | ||
|
|
f18d42f08e | ||
|
|
4986f008b9 | ||
|
|
a8ce199e0d | ||
|
|
c77e8730d6 | ||
|
|
3406846c82 | ||
|
|
bddc6ae66b | ||
|
|
5c343638b6 | ||
|
|
0722960260 | ||
|
|
e959c468f6 | ||
|
|
ba871ec46a | ||
|
|
bd6e3c022f | ||
|
|
a74e04141c | ||
|
|
7c504a10a8 | ||
|
|
ae98745439 | ||
|
|
57259aee00 | ||
|
|
8759fff116 | ||
|
|
dc1a40ea74 | ||
|
|
483d9bf26c | ||
|
|
b24d60e98d | ||
|
|
0f8bd869d8 | ||
|
|
49546cd627 | ||
|
|
6e852d2e65 | ||
|
|
5a4f595341 | ||
|
|
6019d2ee14 | ||
|
|
f937bf3abb | ||
|
|
586e8963a8 | ||
|
|
bdfa76ed9a | ||
|
|
d133f904d3 | ||
|
|
69af9e0dbd | ||
|
|
72c5141dec | ||
|
|
5651d2c43d | ||
|
|
fc236f930b | ||
|
|
570af500f4 | ||
|
|
38913288d8 | ||
|
|
c14d7d9509 | ||
|
|
79f5be4170 | ||
|
|
a3a776d4a6 | ||
|
|
2b9b0f91cb | ||
|
|
424e8f503e | ||
|
|
d206129f3d | ||
|
|
baad7fa9cb | ||
|
|
d54c7b4783 | ||
|
|
67df127c26 | ||
|
|
3946158e88 | ||
|
|
dd251d9e62 | ||
|
|
5c28bcf865 | ||
|
|
7b3b28d3f8 | ||
|
|
20e86bf376 | ||
|
|
f9e087330b | ||
|
|
b0720777be | ||
|
|
8087123f2e | ||
|
|
4c1c315594 | ||
|
|
f95fb640af | ||
|
|
493d8027cd | ||
|
|
06bb55184c | ||
|
|
6b681961e5 | ||
|
|
e1149a27e9 | ||
|
|
f0dd33c1b4 | ||
|
|
5860e1e2ce | ||
|
|
1c7128c2cb | ||
|
|
40200856af | ||
|
|
bb6670d395 | ||
|
|
0d2a268be0 | ||
|
|
16399b63be | ||
|
|
d949b97cc1 | ||
|
|
8b14a701a4 | ||
|
|
0958909cd9 | ||
|
|
b18cee3dc4 | ||
|
|
31272e60b6 | ||
|
|
1b1cb18839 | ||
|
|
fa543bbd4d | ||
|
|
7d7cd32ca7 | ||
|
|
a71c8c60b7 | ||
|
|
9183e7f2fe | ||
|
|
d640411adb | ||
|
|
dc6013fd7e | ||
|
|
80ac66e0a6 | ||
|
|
f05ec4cc26 | ||
|
|
d9ff001ffe | ||
|
|
0f6cb9ed84 | ||
|
|
dde1f27882 | ||
|
|
f5e6f9574d | ||
|
|
ee40adf11a | ||
|
|
3e23f456fe | ||
|
|
b9e2d33ed4 | ||
|
|
19f78dbe6c | ||
|
|
a33dbcb04a | ||
|
|
58f6219cb3 | ||
|
|
18269f2c60 | ||
|
|
06ef95dc5f | ||
|
|
76c7166268 | ||
|
|
6c063f424c | ||
|
|
3345680f7d | ||
|
|
a2fd80954b | ||
|
|
0c524c7c8f | ||
|
|
5f306a11e7 | ||
|
|
ed956a4cf0 | ||
|
|
55a2a6db88 | ||
|
|
f789359886 | ||
|
|
c221a00e1e | ||
|
|
83913af68b | ||
|
|
fa5395a02b | ||
|
|
85dd71507e | ||
|
|
28d6292278 | ||
|
|
b4b84f81a0 | ||
|
|
2345fd4677 | ||
|
|
3250fc732c | ||
|
|
45d52f27ae | ||
|
|
d6b7717985 | ||
|
|
794671ef32 | ||
|
|
70479df5dc | ||
|
|
07761524af | ||
|
|
2ed931aeed | ||
|
|
0d3de40459 | ||
|
|
3619f79ca6 | ||
|
|
c9d9ad10f2 | ||
|
|
d5a689366c | ||
|
|
bc24a1360f | ||
|
|
77f125208e | ||
|
|
b7d4bd5bce | ||
|
|
5a5f0b8de9 | ||
|
|
8e01345f14 | ||
|
|
f5f96f84e7 | ||
|
|
2009d4d6a8 | ||
|
|
4ccfde6d02 | ||
|
|
c4279c9697 | ||
|
|
48ea0bc291 | ||
|
|
a75d5b8bc1 | ||
|
|
055bbf17de | ||
|
|
be3423a16e | ||
|
|
bbb41e8b5c | ||
|
|
c290d01adb | ||
|
|
16327cf40c | ||
|
|
999d41a7f5 | ||
|
|
9ff9b9c805 | ||
|
|
8f1d8cef9e | ||
|
|
8688ad99b6 | ||
|
|
ed0718d3f7 | ||
|
|
c53c9f6866 | ||
|
|
3fdee6a93b | ||
|
|
cafea1c02d | ||
|
|
32e20e5059 | ||
|
|
c66b8ad842 | ||
|
|
c9a5c29abf | ||
|
|
12daa1c2b9 | ||
|
|
ff8daad22b | ||
|
|
1ea2ac864a | ||
|
|
ed9c013f6e | ||
|
|
ed21a6d798 | ||
|
|
b77ab6f3af | ||
|
|
546cfb0dcc | ||
|
|
483410749b | ||
|
|
c95f4ca40f | ||
|
|
222c665018 | ||
|
|
8e78b4c43e | ||
|
|
05ac0fcd1d | ||
|
|
9fa68fd8ab | ||
|
|
3886aedf54 | ||
|
|
1559b0acd1 | ||
|
|
a70ed81908 | ||
|
|
2460e7c56e | ||
|
|
779f09bff6 | ||
|
|
43a72fb9a5 | ||
|
|
4137cf9c8f | ||
|
|
16af833124 | ||
|
|
47f082c085 | ||
|
|
fee9045dac | ||
|
|
06901b878f | ||
|
|
e9a19d5878 | ||
|
|
adf0baebb9 | ||
|
|
5c92b72fdd | ||
|
|
24e6dc4b37 | ||
|
|
4a8f70240f | ||
|
|
64c783c6f8 | ||
|
|
2a849894be | ||
|
|
415663a9bc | ||
|
|
1dc094ffaf | ||
|
|
3e9e196cda | ||
|
|
5903823eed | ||
|
|
8fb9d9d4c2 | ||
|
|
eff7aa0f73 | ||
|
|
14ecb19b05 | ||
|
|
0fc02a2532 | ||
|
|
8c6b116472 | ||
|
|
69c8ff5c2d | ||
|
|
788327fffb | ||
|
|
655ae5ecae | ||
|
|
d5a91d0d35 | ||
|
|
a4fd825fe2 | ||
|
|
496b4264d9 | ||
|
|
57284bb869 | ||
|
|
adf1806fea | ||
|
|
2dc454d206 | ||
|
|
c1552fb799 | ||
|
|
91d8d6eaaa | ||
|
|
afbbcafd44 | ||
|
|
d94762549a | ||
|
|
b4d9029dc3 | ||
|
|
70bfebcd7c | ||
|
|
c76d12d1de |
@@ -26,6 +26,13 @@ DB_DATABASE=database_database
|
|||||||
DB_USERNAME=database_username
|
DB_USERNAME=database_username
|
||||||
DB_PASSWORD=database_user_password
|
DB_PASSWORD=database_user_password
|
||||||
|
|
||||||
|
# Storage system to use
|
||||||
|
# By default files are stored on the local filesystem, with images being placed in
|
||||||
|
# public web space so they can be efficiently served directly by the web-server.
|
||||||
|
# For other options with different security levels & considerations, refer to:
|
||||||
|
# https://www.bookstackapp.com/docs/admin/upload-config/
|
||||||
|
STORAGE_TYPE=local
|
||||||
|
|
||||||
# Mail system to use
|
# Mail system to use
|
||||||
# Can be 'smtp' or 'sendmail'
|
# Can be 'smtp' or 'sendmail'
|
||||||
MAIL_DRIVER=smtp
|
MAIL_DRIVER=smtp
|
||||||
|
|||||||
@@ -36,10 +36,14 @@ APP_LANG=en
|
|||||||
# APP_LANG will be used if such a header is not provided.
|
# APP_LANG will be used if such a header is not provided.
|
||||||
APP_AUTO_LANG_PUBLIC=true
|
APP_AUTO_LANG_PUBLIC=true
|
||||||
|
|
||||||
# Application timezone
|
# Application timezones
|
||||||
# Used where dates are displayed such as on exported content.
|
# The first option is used to determine what timezone is used for date storage.
|
||||||
|
# Leaving that as "UTC" is advised.
|
||||||
|
# The second option is used to set the timezone which will be used for date
|
||||||
|
# formatting and display. This defaults to the "APP_TIMEZONE" value.
|
||||||
# Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
|
# Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
|
||||||
APP_TIMEZONE=UTC
|
APP_TIMEZONE=UTC
|
||||||
|
APP_DISPLAY_TIMEZONE=UTC
|
||||||
|
|
||||||
# Application theme
|
# Application theme
|
||||||
# Used to specific a themes/<APP_THEME> folder where BookStack UI
|
# Used to specific a themes/<APP_THEME> folder where BookStack UI
|
||||||
@@ -56,6 +60,7 @@ APP_PROXIES=null
|
|||||||
|
|
||||||
# Database details
|
# Database details
|
||||||
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
|
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
|
||||||
|
# An ipv6 address can be used via the square bracket format ([::1]).
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
DB_DATABASE=database_database
|
DB_DATABASE=database_database
|
||||||
@@ -215,10 +220,11 @@ LDAP_SERVER=false
|
|||||||
LDAP_BASE_DN=false
|
LDAP_BASE_DN=false
|
||||||
LDAP_DN=false
|
LDAP_DN=false
|
||||||
LDAP_PASS=false
|
LDAP_PASS=false
|
||||||
LDAP_USER_FILTER=false
|
LDAP_USER_FILTER="(&(uid={user}))"
|
||||||
LDAP_VERSION=false
|
LDAP_VERSION=false
|
||||||
LDAP_START_TLS=false
|
LDAP_START_TLS=false
|
||||||
LDAP_TLS_INSECURE=false
|
LDAP_TLS_INSECURE=false
|
||||||
|
LDAP_TLS_CA_CERT=false
|
||||||
LDAP_ID_ATTRIBUTE=uid
|
LDAP_ID_ATTRIBUTE=uid
|
||||||
LDAP_EMAIL_ATTRIBUTE=mail
|
LDAP_EMAIL_ATTRIBUTE=mail
|
||||||
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
|
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
|
||||||
@@ -267,6 +273,7 @@ OIDC_ISSUER_DISCOVER=false
|
|||||||
OIDC_PUBLIC_KEY=null
|
OIDC_PUBLIC_KEY=null
|
||||||
OIDC_AUTH_ENDPOINT=null
|
OIDC_AUTH_ENDPOINT=null
|
||||||
OIDC_TOKEN_ENDPOINT=null
|
OIDC_TOKEN_ENDPOINT=null
|
||||||
|
OIDC_USERINFO_ENDPOINT=null
|
||||||
OIDC_ADDITIONAL_SCOPES=null
|
OIDC_ADDITIONAL_SCOPES=null
|
||||||
OIDC_DUMP_USER_DETAILS=false
|
OIDC_DUMP_USER_DETAILS=false
|
||||||
OIDC_USER_TO_GROUPS=false
|
OIDC_USER_TO_GROUPS=false
|
||||||
@@ -324,6 +331,19 @@ FILE_UPLOAD_SIZE_LIMIT=50
|
|||||||
# Can be 'a4' or 'letter'.
|
# Can be 'a4' or 'letter'.
|
||||||
EXPORT_PAGE_SIZE=a4
|
EXPORT_PAGE_SIZE=a4
|
||||||
|
|
||||||
|
# Export PDF Command
|
||||||
|
# Set a command which can be used to convert a HTML file into a PDF file.
|
||||||
|
# When false this will not be used.
|
||||||
|
# String values represent the command to be called for conversion.
|
||||||
|
# Supports '{input_html_path}' and '{output_pdf_path}' placeholder values.
|
||||||
|
# Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
|
||||||
|
EXPORT_PDF_COMMAND=false
|
||||||
|
|
||||||
|
# Export PDF Command Timeout
|
||||||
|
# The number of seconds that the export PDF command will run before a timeout occurs.
|
||||||
|
# Only applies for the EXPORT_PDF_COMMAND option, not for DomPDF or wkhtmltopdf.
|
||||||
|
EXPORT_PDF_COMMAND_TIMEOUT=15
|
||||||
|
|
||||||
# Set path to wkhtmltopdf binary for PDF generation.
|
# Set path to wkhtmltopdf binary for PDF generation.
|
||||||
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
|
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
|
||||||
# When false, BookStack will attempt to find a wkhtmltopdf in the application
|
# When false, BookStack will attempt to find a wkhtmltopdf in the application
|
||||||
@@ -331,10 +351,25 @@ EXPORT_PAGE_SIZE=a4
|
|||||||
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
|
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
|
||||||
WKHTMLTOPDF=false
|
WKHTMLTOPDF=false
|
||||||
|
|
||||||
# Allow <script> tags in page content
|
# Allow JavaScript, and other potentiall dangerous content in page content.
|
||||||
|
# This also removes CSP-level JavaScript control.
|
||||||
# Note, if set to 'true' the page editor may still escape scripts.
|
# Note, if set to 'true' the page editor may still escape scripts.
|
||||||
|
# DEPRECATED: Use 'APP_CONTENT_FILTERING' instead as detailed below. Activiting this option
|
||||||
|
# effectively sets APP_CONTENT_FILTERING='' (No filtering)
|
||||||
ALLOW_CONTENT_SCRIPTS=false
|
ALLOW_CONTENT_SCRIPTS=false
|
||||||
|
|
||||||
|
# Control the behaviour of content filtering, primarily used for page content.
|
||||||
|
# This setting is a string of characters which represent different available filters:
|
||||||
|
# - j - Filter out JavaScript and unknown binary data based content
|
||||||
|
# - h - Filter out unexpected, and potentially dangerous, HTML elements
|
||||||
|
# - f - Filter out unexpected form elements
|
||||||
|
# - a - Run content through a more complex allowlist filter
|
||||||
|
# This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
|
||||||
|
# Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
|
||||||
|
# Note: The default value will always be the most-strict, so it's advised to leave this unset in your own configuration
|
||||||
|
# to ensure you are always using the full range of filters.
|
||||||
|
APP_CONTENT_FILTERING="jfha"
|
||||||
|
|
||||||
# Indicate if robots/crawlers should crawl your instance.
|
# Indicate if robots/crawlers should crawl your instance.
|
||||||
# Can be 'true', 'false' or 'null'.
|
# Can be 'true', 'false' or 'null'.
|
||||||
# The behaviour of the default 'null' option will depend on the 'app-public' admin setting.
|
# The behaviour of the default 'null' option will depend on the 'app-public' admin setting.
|
||||||
|
|||||||
1
.github/ISSUE_TEMPLATE/support_request.yml
vendored
1
.github/ISSUE_TEMPLATE/support_request.yml
vendored
@@ -42,6 +42,7 @@ body:
|
|||||||
label: Log Content
|
label: Log Content
|
||||||
description: If the issue has produced an error, provide any [BookStack or server log](https://www.bookstackapp.com/docs/admin/debugging/) content below.
|
description: If the issue has produced an error, provide any [BookStack or server log](https://www.bookstackapp.com/docs/admin/debugging/) content below.
|
||||||
placeholder: Be sure to remove any confidential details in your logs
|
placeholder: Be sure to remove any confidential details in your logs
|
||||||
|
render: text
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
9
.github/ISSUE_TEMPLATE/z_blank_request.yml
vendored
Normal file
9
.github/ISSUE_TEMPLATE/z_blank_request.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
name: Blank Request (Maintainers Only)
|
||||||
|
description: For maintainers only - Start a blank request
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "**This blank request option is only for existing official maintainers of the project!** Please instead use a different request option. If you use this your issue will be closed off."
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Description
|
||||||
156
.github/translators.txt
vendored
156
.github/translators.txt
vendored
@@ -141,7 +141,7 @@ Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
|
|||||||
MatthieuParis :: French
|
MatthieuParis :: French
|
||||||
Douradinho :: Portuguese, Brazilian; Portuguese
|
Douradinho :: Portuguese, Brazilian; Portuguese
|
||||||
Gaku Yaguchi (tama11) :: Japanese
|
Gaku Yaguchi (tama11) :: Japanese
|
||||||
johnroyer :: Chinese Traditional
|
Zero Huang (johnroyer) :: Chinese Traditional
|
||||||
jackaaa :: Chinese Traditional
|
jackaaa :: Chinese Traditional
|
||||||
Irfan Hukama Arsyad (IrfanArsyad) :: Indonesian
|
Irfan Hukama Arsyad (IrfanArsyad) :: Indonesian
|
||||||
Jeff Huang (s8321414) :: Chinese Traditional
|
Jeff Huang (s8321414) :: Chinese Traditional
|
||||||
@@ -177,7 +177,7 @@ Alexander Predl (Harveyhase68) :: German
|
|||||||
Rem (Rem9000) :: Dutch
|
Rem (Rem9000) :: Dutch
|
||||||
Michał Stelmach (stelmach-web) :: Polish
|
Michał Stelmach (stelmach-web) :: Polish
|
||||||
arniom :: French
|
arniom :: French
|
||||||
REMOVED_USER :: French; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
|
REMOVED_USER :: French; German; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
|
||||||
林祖年 (contagion) :: Chinese Traditional
|
林祖年 (contagion) :: Chinese Traditional
|
||||||
Siamak Guodarzi (siamakgoudarzi88) :: Persian
|
Siamak Guodarzi (siamakgoudarzi88) :: Persian
|
||||||
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
|
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
|
||||||
@@ -222,7 +222,7 @@ SmokingCrop :: Dutch
|
|||||||
Maciej Lebiest (Szwendacz) :: Polish
|
Maciej Lebiest (Szwendacz) :: Polish
|
||||||
DiscordDigital :: German; German Informal
|
DiscordDigital :: German; German Informal
|
||||||
Gábor Marton (dodver) :: Hungarian
|
Gábor Marton (dodver) :: Hungarian
|
||||||
Jasell :: Swedish
|
Jakob Åsell (Jasell) :: Swedish
|
||||||
Ghost_chu (ghostchu) :: Chinese Simplified
|
Ghost_chu (ghostchu) :: Chinese Simplified
|
||||||
Ravid Shachar (ravidshachar) :: Hebrew
|
Ravid Shachar (ravidshachar) :: Hebrew
|
||||||
Helga Guchshenskaya (guchshenskaya) :: Russian
|
Helga Guchshenskaya (guchshenskaya) :: Russian
|
||||||
@@ -324,7 +324,7 @@ Robin Flikkema (RobinFlikkema) :: Dutch
|
|||||||
Michal Gurcik (mgurcik) :: Slovak
|
Michal Gurcik (mgurcik) :: Slovak
|
||||||
Pooyan Arab (pooyanarab) :: Persian
|
Pooyan Arab (pooyanarab) :: Persian
|
||||||
Ochi Darma Putra (troke12) :: Indonesian
|
Ochi Darma Putra (troke12) :: Indonesian
|
||||||
H.-H. Peng (Hsins) :: Chinese Traditional
|
Hsin-Hsiang Peng (Hsins) :: Chinese Traditional
|
||||||
Mosi Wang (mosiwang) :: Chinese Traditional
|
Mosi Wang (mosiwang) :: Chinese Traditional
|
||||||
骆言 (LawssssCat) :: Chinese Simplified
|
骆言 (LawssssCat) :: Chinese Simplified
|
||||||
Stickers Gaming Shøw (StickerSGSHOW) :: French
|
Stickers Gaming Shøw (StickerSGSHOW) :: French
|
||||||
@@ -347,7 +347,7 @@ Taygun Yıldırım (yildirimtaygun) :: Turkish
|
|||||||
robing29 :: German
|
robing29 :: German
|
||||||
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
|
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
|
||||||
Igor V Belousov (biv) :: Russian
|
Igor V Belousov (biv) :: Russian
|
||||||
David Bauer (davbauer) :: German
|
David Bauer (davbauer) :: German; German Informal
|
||||||
Guttorm Hveem (guttormhveem) :: Norwegian Nynorsk; Norwegian Bokmal
|
Guttorm Hveem (guttormhveem) :: Norwegian Nynorsk; Norwegian Bokmal
|
||||||
Minh Giang Truong (minhgiang1204) :: Vietnamese
|
Minh Giang Truong (minhgiang1204) :: Vietnamese
|
||||||
Ioannis Ioannides (i.ioannides) :: Greek
|
Ioannis Ioannides (i.ioannides) :: Greek
|
||||||
@@ -374,7 +374,7 @@ balmag :: Hungarian
|
|||||||
Antti-Jussi Nygård (ajnyga) :: Finnish
|
Antti-Jussi Nygård (ajnyga) :: Finnish
|
||||||
Eduard Ereza Martínez (Ereza) :: Catalan
|
Eduard Ereza Martínez (Ereza) :: Catalan
|
||||||
Jabir Lang (amar.almrad) :: Arabic
|
Jabir Lang (amar.almrad) :: Arabic
|
||||||
Jaroslav Koblizek (foretix) :: Czech; French
|
Jaroslav Kobližek (foretix) :: Czech; French
|
||||||
Wiktor Adamczyk (adamczyk.wiktor) :: Polish
|
Wiktor Adamczyk (adamczyk.wiktor) :: Polish
|
||||||
Abdulmajeed Alshuaibi (4Majeed) :: Arabic
|
Abdulmajeed Alshuaibi (4Majeed) :: Arabic
|
||||||
NotSmartZakk :: Czech
|
NotSmartZakk :: Czech
|
||||||
@@ -386,3 +386,147 @@ Y (cnsr) :: Ukrainian
|
|||||||
ZY ZV (vy0b0x) :: Chinese Simplified
|
ZY ZV (vy0b0x) :: Chinese Simplified
|
||||||
diegobenitez :: Spanish
|
diegobenitez :: Spanish
|
||||||
Marc Hagen (MarcHagen) :: Dutch
|
Marc Hagen (MarcHagen) :: Dutch
|
||||||
|
Kasper Alsøe (zeonos) :: Danish
|
||||||
|
sultani :: Persian
|
||||||
|
renge :: Korean
|
||||||
|
Tim (thegatesdev) :: Dutch; German Informal; French; Romanian; Catalan; Czech; Danish; German; Finnish; Hungarian; Italian; Japanese; Korean; Polish; Russian; Ukrainian; Chinese Simplified; Chinese Traditional; Portuguese, Brazilian; Persian; Spanish, Argentina; Croatian; Norwegian Nynorsk; Estonian; Uzbek; Norwegian Bokmal
|
||||||
|
Irdi (irdiOL) :: Albanian
|
||||||
|
KateBarber :: Welsh
|
||||||
|
Twister (theuncles75) :: Hebrew
|
||||||
|
algernon19 :: Hungarian
|
||||||
|
Ivan Krstic (ikrstic) :: Serbian (Cyrillic)
|
||||||
|
Show :: Russian
|
||||||
|
xBahamut :: Portuguese, Brazilian
|
||||||
|
Pavle Knežević (pavleknezzevic) :: Serbian (Cyrillic)
|
||||||
|
Vanja Cvelbar (b100w11) :: Slovenian
|
||||||
|
simonpct :: French
|
||||||
|
Honza Nagy (honza.nagy) :: Czech
|
||||||
|
asd20752 :: Norwegian Bokmal
|
||||||
|
Jan Picka (polipones) :: Czech
|
||||||
|
diogoalex991 :: Portuguese
|
||||||
|
Ehsan Sadeghi (ehsansadeghi) :: Persian
|
||||||
|
ka_picit :: Danish
|
||||||
|
cracrayol :: French
|
||||||
|
CapuaSC :: Dutch
|
||||||
|
Guardian75 :: German Informal
|
||||||
|
mr-kanister :: German
|
||||||
|
Michele Bastianelli (makoblaster) :: Italian
|
||||||
|
jespernissen :: Danish
|
||||||
|
Andrey (avmaksimov) :: Russian
|
||||||
|
Gonzalo Loyola (AlFcl) :: Spanish, Argentina; Spanish
|
||||||
|
grobert63 :: French
|
||||||
|
wusst. (Supporti) :: German
|
||||||
|
MaximMaximS :: Czech
|
||||||
|
damian-klima :: Slovak
|
||||||
|
crow_ :: Latvian
|
||||||
|
JocelynDelalande :: French
|
||||||
|
Jan (JW-CH) :: German Informal
|
||||||
|
Timo B (lommes) :: German Informal
|
||||||
|
Erik Lundstedt (Erik.Lundstedt) :: Swedish
|
||||||
|
yngams (younessmouhid) :: Arabic
|
||||||
|
Ohadp :: Hebrew
|
||||||
|
cbridi :: Portuguese, Brazilian
|
||||||
|
nanangsb :: Indonesian
|
||||||
|
Michal Melich (michalmelich) :: Czech
|
||||||
|
David (david-prv) :: German; German Informal
|
||||||
|
Larry (lahoje) :: Swedish
|
||||||
|
Marcia dos Santos (marciab80) :: Portuguese
|
||||||
|
Ricard López Torres (richilpez.torres) :: Catalan
|
||||||
|
sarahalves7 :: Portuguese, Brazilian
|
||||||
|
petr.husak :: Czech
|
||||||
|
javadataherian :: Persian
|
||||||
|
Ludo-code :: French
|
||||||
|
hollsten :: Swedish
|
||||||
|
Ngoc Lan Phung (lanpncz) :: Vietnamese
|
||||||
|
Worive :: Catalan; French
|
||||||
|
Илья Скаба (skabailya) :: Russian
|
||||||
|
Irjan Olsen (Irch) :: Norwegian Bokmal
|
||||||
|
Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic)
|
||||||
|
Red (RedVortex) :: Hebrew
|
||||||
|
xgrug :: Chinese Simplified
|
||||||
|
HrCalmar :: Danish
|
||||||
|
Avishay Rapp (AvishayRapp) :: Hebrew
|
||||||
|
matthias4217 :: French
|
||||||
|
Berke BOYLU2 (berkeboylu2) :: Turkish
|
||||||
|
etwas7B :: German
|
||||||
|
Mohammed srhiri (m.sghiri20) :: Arabic
|
||||||
|
YongMin Kim (kym0118) :: Korean
|
||||||
|
Rivo Zängov (Eraser) :: Estonian
|
||||||
|
Francisco Rafael Fonseca (chicoraf) :: Portuguese, Brazilian
|
||||||
|
ИEØ_ΙΙØZ (NEO_IIOZ) :: Chinese Traditional
|
||||||
|
madnjpn (madnjpn.) :: Georgian
|
||||||
|
Ásgeir Shiny Ásgeirsson (AsgeirShiny) :: Icelandic
|
||||||
|
Mohammad Aftab Uddin (chirohorit) :: Bengali
|
||||||
|
Yannis Karlaftis (meliseus) :: Greek
|
||||||
|
felixxx :: German Informal
|
||||||
|
randi (randi65535) :: Korean
|
||||||
|
test65428 :: Greek
|
||||||
|
zeronell :: Chinese Simplified
|
||||||
|
julien Vinber (julienVinber) :: French
|
||||||
|
Hyunwoo Park (oksure) :: Korean
|
||||||
|
aram.rafeq.7 (aramrafeq2) :: Kurdish
|
||||||
|
Raphael Moreno (RaphaelMoreno) :: Portuguese, Brazilian
|
||||||
|
yn (user99) :: Arabic
|
||||||
|
Pavel Zlatarov (pzlatarov) :: Bulgarian
|
||||||
|
ingelres :: French
|
||||||
|
mabdullah :: Arabic
|
||||||
|
Skrabák Csaba (kekcsi) :: Hungarian
|
||||||
|
Evert Meulie (Evert) :: Norwegian Bokmal
|
||||||
|
Jasper Backer (jasperb) :: Dutch
|
||||||
|
Alexandar Cavdarovski (ace.200112) :: Swedish
|
||||||
|
구닥다리TV (yjj8353) :: Korean
|
||||||
|
Onur Oskay (o.oskay) :: Turkish
|
||||||
|
Sébastien Merveille (SebastienMerv) :: French
|
||||||
|
Maxim Kouznetsov (masya.work) :: Hebrew
|
||||||
|
neodvisnost :: Slovenian
|
||||||
|
Soubi Agatsuma (bisouya) :: Hebrew
|
||||||
|
Ilya Shaulov (ishaulov) :: Russian
|
||||||
|
Konstantin Bobkov (b.konstantv) :: Russian
|
||||||
|
Ruben Sutter (rubensutter) :: German
|
||||||
|
jellium :: French
|
||||||
|
Qxlkdr :: Swedish
|
||||||
|
Hari (muhhari) :: Indonesian
|
||||||
|
仙君御 (xjy) :: Chinese Simplified
|
||||||
|
TapioM :: Finnish
|
||||||
|
lingb58 :: Chinese Traditional
|
||||||
|
Angel Pandey (angel-pandey) :: Nepali
|
||||||
|
Supriya Shrestha (supriyashrestha) :: Nepali
|
||||||
|
gprabhat :: Nepali
|
||||||
|
CellCat :: Chinese Simplified
|
||||||
|
Al Desrahim (aldesrahim) :: Indonesian
|
||||||
|
ahmad abbaspour (deshneh.dar.diss) :: Persian
|
||||||
|
Erjon K. (ekr) :: Albanian
|
||||||
|
LiZerui (iamzrli) :: Chinese Traditional
|
||||||
|
Ticker (ticker.com) :: Hebrew
|
||||||
|
CrazyComputer :: Chinese Simplified
|
||||||
|
Firr (FirrV) :: Russian
|
||||||
|
João Faro (FaroJoaoFaro) :: Portuguese
|
||||||
|
Danilo dos Santos Barbosa (bozochegou) :: Portuguese, Brazilian
|
||||||
|
Chris (furesoft) :: German
|
||||||
|
Silvia Isern (eiendragon) :: Catalan
|
||||||
|
Dennis Kron Pedersen (ahjdp) :: Danish
|
||||||
|
iamwhoiamwhoami :: Swedish
|
||||||
|
Grogui :: French
|
||||||
|
MrCharlesIII :: Arabic
|
||||||
|
David Olsen (dawin) :: Danish
|
||||||
|
ltnzr :: French
|
||||||
|
Frank Holler (holler.frank) :: German; German Informal
|
||||||
|
Korab Arifi (korabidev) :: Albanian
|
||||||
|
Petr Husák (petrhusak) :: Czech
|
||||||
|
Bernardo Maia (bernardo.bmaia2) :: Portuguese, Brazilian
|
||||||
|
Amr (amr3k) :: Arabic
|
||||||
|
Tahsin Ahmed (tahsinahmed2012) :: Bengali
|
||||||
|
bojan_che :: Serbian (Cyrillic)
|
||||||
|
setiawan setiawan (culture.setiawan) :: Indonesian
|
||||||
|
Donald Mac Kenzie (kiuman) :: Norwegian Bokmal
|
||||||
|
Gabriel Silver (GabrielBSilver) :: Hebrew
|
||||||
|
Tomas Darius Davainis (Tomasdd) :: Lithuanian
|
||||||
|
CriedHero :: Chinese Simplified
|
||||||
|
Henrik (henrik2105) :: Norwegian Bokmal
|
||||||
|
FoW (fofwisdom) :: Korean
|
||||||
|
serinf-lauza :: French
|
||||||
|
Diyan Nikolaev (nikolaev.diyan) :: Bulgarian
|
||||||
|
Shadluk Avan (quldosh) :: Uzbek
|
||||||
|
Marci (MartonPoto) :: Hungarian
|
||||||
|
Michał Sadurski (wheeskeey) :: Polish
|
||||||
|
JanDziaslo :: Polish
|
||||||
|
|||||||
10
.github/workflows/analyse-php.yml
vendored
10
.github/workflows/analyse-php.yml
vendored
@@ -11,14 +11,14 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: 8.1
|
php-version: 8.3
|
||||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
||||||
|
|
||||||
- name: Get Composer Cache Directory
|
- name: Get Composer Cache Directory
|
||||||
@@ -27,10 +27,10 @@ jobs:
|
|||||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache composer packages
|
- name: Cache composer packages
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.composer-cache.outputs.dir }}
|
path: ${{ steps.composer-cache.outputs.dir }}
|
||||||
key: ${{ runner.os }}-composer-8.1
|
key: ${{ runner.os }}-composer-8.3
|
||||||
restore-keys: ${{ runner.os }}-composer-
|
restore-keys: ${{ runner.os }}-composer-
|
||||||
|
|
||||||
- name: Install composer dependencies
|
- name: Install composer dependencies
|
||||||
|
|||||||
4
.github/workflows/lint-js.yml
vendored
4
.github/workflows/lint-js.yml
vendored
@@ -13,9 +13,9 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install NPM deps
|
- name: Install NPM deps
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
6
.github/workflows/lint-php.yml
vendored
6
.github/workflows/lint-php.yml
vendored
@@ -11,14 +11,14 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: 8.1
|
php-version: 8.3
|
||||||
tools: phpcs
|
tools: phpcs
|
||||||
|
|
||||||
- name: Run formatting check
|
- name: Run formatting check
|
||||||
|
|||||||
29
.github/workflows/test-js.yml
vendored
Normal file
29
.github/workflows/test-js.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: test-js
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- '**.js'
|
||||||
|
- '**.ts'
|
||||||
|
- '**.json'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '**.js'
|
||||||
|
- '**.ts'
|
||||||
|
- '**.json'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install NPM deps
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Run TypeScript type checking
|
||||||
|
run: npm run ts:lint
|
||||||
|
|
||||||
|
- name: Run JavaScript tests
|
||||||
|
run: npm run test
|
||||||
8
.github/workflows/test-migrations.yml
vendored
8
.github/workflows/test-migrations.yml
vendored
@@ -13,12 +13,12 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php: ['8.0', '8.1', '8.2', '8.3']
|
php: ['8.2', '8.3', '8.4', '8.5']
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache composer packages
|
- name: Cache composer packages
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.composer-cache.outputs.dir }}
|
path: ${{ steps.composer-cache.outputs.dir }}
|
||||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||||
|
|||||||
8
.github/workflows/test-php.yml
vendored
8
.github/workflows/test-php.yml
vendored
@@ -13,12 +13,12 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php: ['8.0', '8.1', '8.2', '8.3']
|
php: ['8.2', '8.3', '8.4', '8.5']
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup PHP
|
- name: Setup PHP
|
||||||
uses: shivammathur/setup-php@v2
|
uses: shivammathur/setup-php@v2
|
||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache composer packages
|
- name: Cache composer packages
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.composer-cache.outputs.dir }}
|
path: ${{ steps.composer-cache.outputs.dir }}
|
||||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -2,15 +2,16 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
/.vscode
|
/.vscode
|
||||||
/composer
|
/composer
|
||||||
|
/coverage
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
.env
|
.env
|
||||||
.idea
|
.idea
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
/public/dist/*.map
|
/public/dist
|
||||||
/public/plugins
|
/public/plugins
|
||||||
/public/css/*.map
|
/public/css
|
||||||
/public/js/*.map
|
/public/js
|
||||||
/public/bower
|
/public/bower
|
||||||
/public/build/
|
/public/build/
|
||||||
/public/favicon.ico
|
/public/favicon.ico
|
||||||
@@ -31,3 +32,4 @@ webpack-stats.json
|
|||||||
phpstan.neon
|
phpstan.neon
|
||||||
esbuild-meta.json
|
esbuild-meta.json
|
||||||
.phpactor.json
|
.phpactor.json
|
||||||
|
/*.zip
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
The MIT License (MIT)
|
The MIT License (MIT)
|
||||||
|
|
||||||
Copyright (c) 2015-2023, Dan Brown and the BookStack Project contributors.
|
Copyright (c) 2015-2026, Dan Brown and the BookStack project contributors.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -32,13 +32,17 @@ class ConfirmEmailController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a notice that a user's email address has not been confirmed,
|
* Shows a notice that a user's email address has not been confirmed,
|
||||||
* Also has the option to re-send the confirmation email.
|
* along with the option to re-send the confirmation email.
|
||||||
*/
|
*/
|
||||||
public function showAwaiting()
|
public function showAwaiting()
|
||||||
{
|
{
|
||||||
$user = $this->loginService->getLastLoginAttemptUser();
|
$user = $this->loginService->getLastLoginAttemptUser();
|
||||||
|
if ($user === null) {
|
||||||
|
$this->showErrorNotification(trans('errors.login_user_not_found'));
|
||||||
|
return redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
return view('auth.user-unconfirmed', ['user' => $user]);
|
return view('auth.register-confirm-awaiting');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,19 +94,24 @@ class ConfirmEmailController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Resend the confirmation email.
|
* Resend the confirmation email.
|
||||||
*/
|
*/
|
||||||
public function resend(Request $request)
|
public function resend()
|
||||||
{
|
{
|
||||||
$this->validate($request, [
|
$user = $this->loginService->getLastLoginAttemptUser();
|
||||||
'email' => ['required', 'email', 'exists:users,email'],
|
if ($user === null) {
|
||||||
]);
|
$this->showErrorNotification(trans('errors.login_user_not_found'));
|
||||||
$user = $this->userRepo->getByEmail($request->get('email'));
|
return redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->emailConfirmationService->sendConfirmation($user);
|
$this->emailConfirmationService->sendConfirmation($user);
|
||||||
|
} catch (ConfirmationEmailException $e) {
|
||||||
|
$this->showErrorNotification($e->getMessage());
|
||||||
|
|
||||||
|
return redirect('/login');
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$this->showErrorNotification(trans('auth.email_confirm_send_error'));
|
$this->showErrorNotification(trans('auth.email_confirm_send_error'));
|
||||||
|
|
||||||
return redirect('/register/confirm');
|
return redirect('/register/awaiting');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->showSuccessNotification(trans('auth.email_confirm_resent'));
|
$this->showSuccessNotification(trans('auth.email_confirm_resent'));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType;
|
|||||||
use BookStack\Http\Controller;
|
use BookStack\Http\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Password;
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Illuminate\Support\Sleep;
|
||||||
|
|
||||||
class ForgotPasswordController extends Controller
|
class ForgotPasswordController extends Controller
|
||||||
{
|
{
|
||||||
@@ -32,6 +33,10 @@ class ForgotPasswordController extends Controller
|
|||||||
'email' => ['required', 'email'],
|
'email' => ['required', 'email'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Add random pause to the response to help avoid time-base sniffing
|
||||||
|
// of valid resets via slower email send handling.
|
||||||
|
Sleep::for(random_int(1000, 3000))->milliseconds();
|
||||||
|
|
||||||
// We will send the password reset link to this user. Once we have attempted
|
// We will send the password reset link to this user. Once we have attempted
|
||||||
// to send the link, we will examine the response then see the message we
|
// to send the link, we will examine the response then see the message we
|
||||||
// need to show to the user. Finally, we'll send out a proper response.
|
// need to show to the user. Finally, we'll send out a proper response.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ trait HandlesPartialLogins
|
|||||||
$user = auth()->user() ?? $loginService->getLastLoginAttemptUser();
|
$user = auth()->user() ?? $loginService->getLastLoginAttemptUser();
|
||||||
|
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
throw new NotFoundException('A user for this action could not be found');
|
throw new NotFoundException(trans('errors.login_user_not_found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
|
|||||||
@@ -19,20 +19,25 @@ class MfaTotpController extends Controller
|
|||||||
|
|
||||||
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-totp-secret';
|
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-totp-secret';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected TotpService $totp
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show a view that generates and displays a TOTP QR code.
|
* Show a view that generates and displays a TOTP QR code.
|
||||||
*/
|
*/
|
||||||
public function generate(TotpService $totp)
|
public function generate()
|
||||||
{
|
{
|
||||||
if (session()->has(static::SETUP_SECRET_SESSION_KEY)) {
|
if (session()->has(static::SETUP_SECRET_SESSION_KEY)) {
|
||||||
$totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY));
|
$totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY));
|
||||||
} else {
|
} else {
|
||||||
$totpSecret = $totp->generateSecret();
|
$totpSecret = $this->totp->generateSecret();
|
||||||
session()->put(static::SETUP_SECRET_SESSION_KEY, encrypt($totpSecret));
|
session()->put(static::SETUP_SECRET_SESSION_KEY, encrypt($totpSecret));
|
||||||
}
|
}
|
||||||
|
|
||||||
$qrCodeUrl = $totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
|
$qrCodeUrl = $this->totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
|
||||||
$svg = $totp->generateQrCodeSvg($qrCodeUrl);
|
$svg = $this->totp->generateQrCodeSvg($qrCodeUrl);
|
||||||
|
|
||||||
$this->setPageTitle(trans('auth.mfa_gen_totp_title'));
|
$this->setPageTitle(trans('auth.mfa_gen_totp_title'));
|
||||||
|
|
||||||
@@ -56,7 +61,7 @@ class MfaTotpController extends Controller
|
|||||||
'code' => [
|
'code' => [
|
||||||
'required',
|
'required',
|
||||||
'max:12', 'min:4',
|
'max:12', 'min:4',
|
||||||
new TotpValidationRule($totpSecret),
|
new TotpValidationRule($totpSecret, $this->totp),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -87,7 +92,7 @@ class MfaTotpController extends Controller
|
|||||||
'code' => [
|
'code' => [
|
||||||
'required',
|
'required',
|
||||||
'max:12', 'min:4',
|
'max:12', 'min:4',
|
||||||
new TotpValidationRule($totpSecret),
|
new TotpValidationRule($totpSecret, $this->totp),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ use Illuminate\Http\Request;
|
|||||||
|
|
||||||
class OidcController extends Controller
|
class OidcController extends Controller
|
||||||
{
|
{
|
||||||
protected OidcService $oidcService;
|
public function __construct(
|
||||||
|
protected OidcService $oidcService
|
||||||
public function __construct(OidcService $oidcService)
|
) {
|
||||||
{
|
|
||||||
$this->oidcService = $oidcService;
|
|
||||||
$this->middleware('guard:oidc');
|
$this->middleware('guard:oidc');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +28,7 @@ class OidcController extends Controller
|
|||||||
return redirect('/login');
|
return redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->flash('oidc_state', $loginDetails['state']);
|
session()->put('oidc_state', time() . ':' . $loginDetails['state']);
|
||||||
|
|
||||||
return redirect($loginDetails['url']);
|
return redirect($loginDetails['url']);
|
||||||
}
|
}
|
||||||
@@ -41,10 +39,16 @@ class OidcController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function callback(Request $request)
|
public function callback(Request $request)
|
||||||
{
|
{
|
||||||
$storedState = session()->pull('oidc_state');
|
|
||||||
$responseState = $request->query('state');
|
$responseState = $request->query('state');
|
||||||
|
$splitState = explode(':', session()->pull('oidc_state', ':'), 2);
|
||||||
|
if (count($splitState) !== 2) {
|
||||||
|
$splitState = [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
if ($storedState !== $responseState) {
|
[$storedStateTime, $storedState] = $splitState;
|
||||||
|
$threeMinutesAgo = time() - 3 * 60;
|
||||||
|
|
||||||
|
if (!$storedState || $storedState !== $responseState || intval($storedStateTime) < $threeMinutesAgo) {
|
||||||
$this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
|
$this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
|
||||||
|
|
||||||
return redirect('/login');
|
return redirect('/login');
|
||||||
@@ -62,7 +66,7 @@ class OidcController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log the user out then start the OIDC RP-initiated logout process.
|
* Log the user out, then start the OIDC RP-initiated logout process.
|
||||||
*/
|
*/
|
||||||
public function logout()
|
public function logout()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,24 +15,13 @@ use Illuminate\Validation\Rules\Password;
|
|||||||
|
|
||||||
class RegisterController extends Controller
|
class RegisterController extends Controller
|
||||||
{
|
{
|
||||||
protected SocialDriverManager $socialDriverManager;
|
|
||||||
protected RegistrationService $registrationService;
|
|
||||||
protected LoginService $loginService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new controller instance.
|
|
||||||
*/
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
SocialDriverManager $socialDriverManager,
|
protected SocialDriverManager $socialDriverManager,
|
||||||
RegistrationService $registrationService,
|
protected RegistrationService $registrationService,
|
||||||
LoginService $loginService
|
protected LoginService $loginService
|
||||||
) {
|
) {
|
||||||
$this->middleware('guest');
|
$this->middleware('guest');
|
||||||
$this->middleware('guard:standard');
|
$this->middleware('guard:standard');
|
||||||
|
|
||||||
$this->socialDriverManager = $socialDriverManager;
|
|
||||||
$this->registrationService = $registrationService;
|
|
||||||
$this->loginService = $loginService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -87,6 +76,8 @@ class RegisterController extends Controller
|
|||||||
'name' => ['required', 'min:2', 'max:100'],
|
'name' => ['required', 'min:2', 'max:100'],
|
||||||
'email' => ['required', 'email', 'max:255', 'unique:users'],
|
'email' => ['required', 'email', 'max:255', 'unique:users'],
|
||||||
'password' => ['required', Password::default()],
|
'password' => ['required', Password::default()],
|
||||||
|
// Basic honey for bots that must not be filled in
|
||||||
|
'username' => ['prohibited'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,11 @@ use Illuminate\Validation\Rules\Password as PasswordRule;
|
|||||||
|
|
||||||
class ResetPasswordController extends Controller
|
class ResetPasswordController extends Controller
|
||||||
{
|
{
|
||||||
protected LoginService $loginService;
|
public function __construct(
|
||||||
|
protected LoginService $loginService
|
||||||
public function __construct(LoginService $loginService)
|
) {
|
||||||
{
|
|
||||||
$this->middleware('guest');
|
$this->middleware('guest');
|
||||||
$this->middleware('guard:standard');
|
$this->middleware('guard:standard');
|
||||||
|
|
||||||
$this->loginService = $loginService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class EmailConfirmationService extends UserTokenService
|
|||||||
*
|
*
|
||||||
* @throws ConfirmationEmailException
|
* @throws ConfirmationEmailException
|
||||||
*/
|
*/
|
||||||
public function sendConfirmation(User $user)
|
public function sendConfirmation(User $user): void
|
||||||
{
|
{
|
||||||
if ($user->email_confirmed) {
|
if ($user->email_confirmed) {
|
||||||
throw new ConfirmationEmailException(trans('errors.email_already_confirmed'), '/login');
|
throw new ConfirmationEmailException(trans('errors.email_already_confirmed'), '/login');
|
||||||
|
|||||||
@@ -2,60 +2,26 @@
|
|||||||
|
|
||||||
namespace BookStack\Access;
|
namespace BookStack\Access;
|
||||||
|
|
||||||
|
use BookStack\Users\Models\User;
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
use Illuminate\Contracts\Auth\UserProvider;
|
use Illuminate\Contracts\Auth\UserProvider;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class ExternalBaseUserProvider implements UserProvider
|
class ExternalBaseUserProvider implements UserProvider
|
||||||
{
|
{
|
||||||
/**
|
|
||||||
* The user model.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $model;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LdapUserProvider constructor.
|
|
||||||
*/
|
|
||||||
public function __construct(string $model)
|
|
||||||
{
|
|
||||||
$this->model = $model;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new instance of the model.
|
|
||||||
*
|
|
||||||
* @return Model
|
|
||||||
*/
|
|
||||||
public function createModel()
|
|
||||||
{
|
|
||||||
$class = '\\' . ltrim($this->model, '\\');
|
|
||||||
|
|
||||||
return new $class();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve a user by their unique identifier.
|
* Retrieve a user by their unique identifier.
|
||||||
*
|
|
||||||
* @param mixed $identifier
|
|
||||||
*
|
|
||||||
* @return Authenticatable|null
|
|
||||||
*/
|
*/
|
||||||
public function retrieveById($identifier)
|
public function retrieveById(mixed $identifier): ?Authenticatable
|
||||||
{
|
{
|
||||||
return $this->createModel()->newQuery()->find($identifier);
|
return User::query()->find($identifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve a user by their unique identifier and "remember me" token.
|
* Retrieve a user by their unique identifier and "remember me" token.
|
||||||
*
|
*
|
||||||
* @param mixed $identifier
|
|
||||||
* @param string $token
|
* @param string $token
|
||||||
*
|
|
||||||
* @return Authenticatable|null
|
|
||||||
*/
|
*/
|
||||||
public function retrieveByToken($identifier, $token)
|
public function retrieveByToken(mixed $identifier, $token): null
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -75,32 +41,25 @@ class ExternalBaseUserProvider implements UserProvider
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve a user by the given credentials.
|
* Retrieve a user by the given credentials.
|
||||||
*
|
|
||||||
* @param array $credentials
|
|
||||||
*
|
|
||||||
* @return Authenticatable|null
|
|
||||||
*/
|
*/
|
||||||
public function retrieveByCredentials(array $credentials)
|
public function retrieveByCredentials(array $credentials): ?Authenticatable
|
||||||
{
|
{
|
||||||
// Search current user base by looking up a uid
|
return User::query()
|
||||||
$model = $this->createModel();
|
|
||||||
|
|
||||||
return $model->newQuery()
|
|
||||||
->where('external_auth_id', $credentials['external_auth_id'])
|
->where('external_auth_id', $credentials['external_auth_id'])
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a user against the given credentials.
|
* Validate a user against the given credentials.
|
||||||
*
|
|
||||||
* @param Authenticatable $user
|
|
||||||
* @param array $credentials
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
*/
|
||||||
public function validateCredentials(Authenticatable $user, array $credentials)
|
public function validateCredentials(Authenticatable $user, array $credentials): bool
|
||||||
{
|
{
|
||||||
// Should be done in the guard.
|
// Should be done in the guard.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false)
|
||||||
|
{
|
||||||
|
// No action to perform, any passwords are external in the auth system
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,23 +3,18 @@
|
|||||||
namespace BookStack\Access\Guards;
|
namespace BookStack\Access\Guards;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saml2 Session Guard.
|
* External Auth Session Guard.
|
||||||
*
|
*
|
||||||
* The saml2 login process is async in nature meaning it does not fit very well
|
* The login process for external auth (SAML2/OIDC) is async in nature, meaning it does not fit very well
|
||||||
* into the default laravel 'Guard' auth flow. Instead most of the logic is done
|
* into the default laravel 'Guard' auth flow. Instead, most of the logic is done via the relevant
|
||||||
* via the Saml2 controller & Saml2Service. This class provides a safer, thin
|
* controller and services. This class provides a safer, thin version of SessionGuard.
|
||||||
* version of SessionGuard.
|
|
||||||
*/
|
*/
|
||||||
class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
|
class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Validate a user's credentials.
|
* Validate a user's credentials.
|
||||||
*
|
|
||||||
* @param array $credentials
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
*/
|
||||||
public function validate(array $credentials = [])
|
public function validate(array $credentials = []): bool
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -27,12 +22,9 @@ class AsyncExternalBaseSessionGuard extends ExternalBaseSessionGuard
|
|||||||
/**
|
/**
|
||||||
* Attempt to authenticate a user using the given credentials.
|
* Attempt to authenticate a user using the given credentials.
|
||||||
*
|
*
|
||||||
* @param array $credentials
|
|
||||||
* @param bool $remember
|
* @param bool $remember
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
*/
|
||||||
public function attempt(array $credentials = [], $remember = false)
|
public function attempt(array $credentials = [], $remember = false): bool
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace BookStack\Access\Guards;
|
|||||||
|
|
||||||
use BookStack\Access\RegistrationService;
|
use BookStack\Access\RegistrationService;
|
||||||
use Illuminate\Auth\GuardHelpers;
|
use Illuminate\Auth\GuardHelpers;
|
||||||
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
use Illuminate\Contracts\Auth\StatefulGuard;
|
use Illuminate\Contracts\Auth\StatefulGuard;
|
||||||
use Illuminate\Contracts\Auth\UserProvider;
|
use Illuminate\Contracts\Auth\UserProvider;
|
||||||
use Illuminate\Contracts\Session\Session;
|
use Illuminate\Contracts\Session\Session;
|
||||||
@@ -24,43 +24,31 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
|||||||
* The name of the Guard. Typically "session".
|
* The name of the Guard. Typically "session".
|
||||||
*
|
*
|
||||||
* Corresponds to guard name in authentication configuration.
|
* Corresponds to guard name in authentication configuration.
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
*/
|
||||||
protected $name;
|
protected readonly string $name;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The user we last attempted to retrieve.
|
* The user we last attempted to retrieve.
|
||||||
*
|
|
||||||
* @var \Illuminate\Contracts\Auth\Authenticatable
|
|
||||||
*/
|
*/
|
||||||
protected $lastAttempted;
|
protected Authenticatable|null $lastAttempted;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The session used by the guard.
|
* The session used by the guard.
|
||||||
*
|
|
||||||
* @var \Illuminate\Contracts\Session\Session
|
|
||||||
*/
|
*/
|
||||||
protected $session;
|
protected Session $session;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicates if the logout method has been called.
|
* Indicates if the logout method has been called.
|
||||||
*
|
|
||||||
* @var bool
|
|
||||||
*/
|
*/
|
||||||
protected $loggedOut = false;
|
protected bool $loggedOut = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service to handle common registration actions.
|
* Service to handle common registration actions.
|
||||||
*
|
|
||||||
* @var RegistrationService
|
|
||||||
*/
|
*/
|
||||||
protected $registrationService;
|
protected RegistrationService $registrationService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new authentication guard.
|
* Create a new authentication guard.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function __construct(string $name, UserProvider $provider, Session $session, RegistrationService $registrationService)
|
public function __construct(string $name, UserProvider $provider, Session $session, RegistrationService $registrationService)
|
||||||
{
|
{
|
||||||
@@ -72,13 +60,11 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the currently authenticated user.
|
* Get the currently authenticated user.
|
||||||
*
|
|
||||||
* @return \Illuminate\Contracts\Auth\Authenticatable|null
|
|
||||||
*/
|
*/
|
||||||
public function user()
|
public function user(): Authenticatable|null
|
||||||
{
|
{
|
||||||
if ($this->loggedOut) {
|
if ($this->loggedOut) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we've already retrieved the user for the current request we can just
|
// If we've already retrieved the user for the current request we can just
|
||||||
@@ -101,13 +87,11 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the ID for the currently authenticated user.
|
* Get the ID for the currently authenticated user.
|
||||||
*
|
|
||||||
* @return int|null
|
|
||||||
*/
|
*/
|
||||||
public function id()
|
public function id(): int|null
|
||||||
{
|
{
|
||||||
if ($this->loggedOut) {
|
if ($this->loggedOut) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->user()
|
return $this->user()
|
||||||
@@ -117,12 +101,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Log a user into the application without sessions or cookies.
|
* Log a user into the application without sessions or cookies.
|
||||||
*
|
|
||||||
* @param array $credentials
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
*/
|
||||||
public function once(array $credentials = [])
|
public function once(array $credentials = []): bool
|
||||||
{
|
{
|
||||||
if ($this->validate($credentials)) {
|
if ($this->validate($credentials)) {
|
||||||
$this->setUser($this->lastAttempted);
|
$this->setUser($this->lastAttempted);
|
||||||
@@ -135,12 +115,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Log the given user ID into the application without sessions or cookies.
|
* Log the given user ID into the application without sessions or cookies.
|
||||||
*
|
|
||||||
* @param mixed $id
|
|
||||||
*
|
|
||||||
* @return \Illuminate\Contracts\Auth\Authenticatable|false
|
|
||||||
*/
|
*/
|
||||||
public function onceUsingId($id)
|
public function onceUsingId($id): Authenticatable|false
|
||||||
{
|
{
|
||||||
if (!is_null($user = $this->provider->retrieveById($id))) {
|
if (!is_null($user = $this->provider->retrieveById($id))) {
|
||||||
$this->setUser($user);
|
$this->setUser($user);
|
||||||
@@ -153,38 +129,26 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a user's credentials.
|
* Validate a user's credentials.
|
||||||
*
|
|
||||||
* @param array $credentials
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
*/
|
||||||
public function validate(array $credentials = [])
|
public function validate(array $credentials = []): bool
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempt to authenticate a user using the given credentials.
|
* Attempt to authenticate a user using the given credentials.
|
||||||
*
|
* @param bool $remember
|
||||||
* @param array $credentials
|
|
||||||
* @param bool $remember
|
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
*/
|
||||||
public function attempt(array $credentials = [], $remember = false)
|
public function attempt(array $credentials = [], $remember = false): bool
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Log the given user ID into the application.
|
* Log the given user ID into the application.
|
||||||
*
|
|
||||||
* @param mixed $id
|
|
||||||
* @param bool $remember
|
* @param bool $remember
|
||||||
*
|
|
||||||
* @return \Illuminate\Contracts\Auth\Authenticatable|false
|
|
||||||
*/
|
*/
|
||||||
public function loginUsingId($id, $remember = false)
|
public function loginUsingId(mixed $id, $remember = false): Authenticatable|false
|
||||||
{
|
{
|
||||||
// Always return false as to disable this method,
|
// Always return false as to disable this method,
|
||||||
// Logins should route through LoginService.
|
// Logins should route through LoginService.
|
||||||
@@ -194,12 +158,9 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
|||||||
/**
|
/**
|
||||||
* Log a user into the application.
|
* Log a user into the application.
|
||||||
*
|
*
|
||||||
* @param \Illuminate\Contracts\Auth\Authenticatable $user
|
* @param bool $remember
|
||||||
* @param bool $remember
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function login(AuthenticatableContract $user, $remember = false)
|
public function login(Authenticatable $user, $remember = false): void
|
||||||
{
|
{
|
||||||
$this->updateSession($user->getAuthIdentifier());
|
$this->updateSession($user->getAuthIdentifier());
|
||||||
|
|
||||||
@@ -208,12 +169,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the session with the given ID.
|
* Update the session with the given ID.
|
||||||
*
|
|
||||||
* @param string $id
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
protected function updateSession($id)
|
protected function updateSession(string|int $id): void
|
||||||
{
|
{
|
||||||
$this->session->put($this->getName(), $id);
|
$this->session->put($this->getName(), $id);
|
||||||
|
|
||||||
@@ -222,10 +179,8 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Log the user out of the application.
|
* Log the user out of the application.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function logout()
|
public function logout(): void
|
||||||
{
|
{
|
||||||
$this->clearUserDataFromStorage();
|
$this->clearUserDataFromStorage();
|
||||||
|
|
||||||
@@ -239,62 +194,48 @@ class ExternalBaseSessionGuard implements StatefulGuard
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove the user data from the session and cookies.
|
* Remove the user data from the session and cookies.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
protected function clearUserDataFromStorage()
|
protected function clearUserDataFromStorage(): void
|
||||||
{
|
{
|
||||||
$this->session->remove($this->getName());
|
$this->session->remove($this->getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the last user we attempted to authenticate.
|
* Get the last user we attempted to authenticate.
|
||||||
*
|
|
||||||
* @return \Illuminate\Contracts\Auth\Authenticatable
|
|
||||||
*/
|
*/
|
||||||
public function getLastAttempted()
|
public function getLastAttempted(): Authenticatable
|
||||||
{
|
{
|
||||||
return $this->lastAttempted;
|
return $this->lastAttempted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a unique identifier for the auth session value.
|
* Get a unique identifier for the auth session value.
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
public function getName()
|
public function getName(): string
|
||||||
{
|
{
|
||||||
return 'login_' . $this->name . '_' . sha1(static::class);
|
return 'login_' . $this->name . '_' . sha1(static::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if the user was authenticated via "remember me" cookie.
|
* Determine if the user was authenticated via "remember me" cookie.
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
*/
|
||||||
public function viaRemember()
|
public function viaRemember(): bool
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the currently cached user.
|
* Return the currently cached user.
|
||||||
*
|
|
||||||
* @return \Illuminate\Contracts\Auth\Authenticatable|null
|
|
||||||
*/
|
*/
|
||||||
public function getUser()
|
public function getUser(): Authenticatable|null
|
||||||
{
|
{
|
||||||
return $this->user;
|
return $this->user;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the current user.
|
* Set the current user.
|
||||||
*
|
|
||||||
* @param \Illuminate\Contracts\Auth\Authenticatable $user
|
|
||||||
*
|
|
||||||
* @return $this
|
|
||||||
*/
|
*/
|
||||||
public function setUser(AuthenticatableContract $user)
|
public function setUser(Authenticatable $user): self
|
||||||
{
|
{
|
||||||
$this->user = $user;
|
$this->user = $user;
|
||||||
|
|
||||||
|
|||||||
@@ -35,13 +35,9 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
|
|||||||
/**
|
/**
|
||||||
* Validate a user's credentials.
|
* Validate a user's credentials.
|
||||||
*
|
*
|
||||||
* @param array $credentials
|
|
||||||
*
|
|
||||||
* @throws LdapException
|
* @throws LdapException
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
*/
|
||||||
public function validate(array $credentials = [])
|
public function validate(array $credentials = []): bool
|
||||||
{
|
{
|
||||||
$userDetails = $this->ldapService->getUserDetails($credentials['username']);
|
$userDetails = $this->ldapService->getUserDetails($credentials['username']);
|
||||||
|
|
||||||
@@ -57,16 +53,13 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
|
|||||||
/**
|
/**
|
||||||
* Attempt to authenticate a user using the given credentials.
|
* Attempt to authenticate a user using the given credentials.
|
||||||
*
|
*
|
||||||
* @param array $credentials
|
|
||||||
* @param bool $remember
|
* @param bool $remember
|
||||||
*
|
*
|
||||||
* @throws LdapException*@throws \BookStack\Exceptions\JsonDebugException
|
* @throws LdapException
|
||||||
* @throws LoginAttemptException
|
* @throws LoginAttemptException
|
||||||
* @throws JsonDebugException
|
* @throws JsonDebugException
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
*/
|
||||||
public function attempt(array $credentials = [], $remember = false)
|
public function attempt(array $credentials = [], $remember = false): bool
|
||||||
{
|
{
|
||||||
$username = $credentials['username'];
|
$username = $credentials['username'];
|
||||||
$userDetails = $this->ldapService->getUserDetails($username);
|
$userDetails = $this->ldapService->getUserDetails($username);
|
||||||
|
|||||||
@@ -52,13 +52,25 @@ class Ldap
|
|||||||
*
|
*
|
||||||
* @param resource|\LDAP\Connection $ldapConnection
|
* @param resource|\LDAP\Connection $ldapConnection
|
||||||
*
|
*
|
||||||
* @return resource|\LDAP\Result
|
* @return \LDAP\Result|array|false
|
||||||
*/
|
*/
|
||||||
public function search($ldapConnection, string $baseDn, string $filter, array $attributes = null)
|
public function search($ldapConnection, string $baseDn, string $filter, array $attributes = [])
|
||||||
{
|
{
|
||||||
return ldap_search($ldapConnection, $baseDn, $filter, $attributes);
|
return ldap_search($ldapConnection, $baseDn, $filter, $attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read an entry from the LDAP tree.
|
||||||
|
*
|
||||||
|
* @param resource|\Ldap\Connection $ldapConnection
|
||||||
|
*
|
||||||
|
* @return \LDAP\Result|array|false
|
||||||
|
*/
|
||||||
|
public function read($ldapConnection, string $baseDn, string $filter, array $attributes = [])
|
||||||
|
{
|
||||||
|
return ldap_read($ldapConnection, $baseDn, $filter, $attributes);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get entries from an LDAP search result.
|
* Get entries from an LDAP search result.
|
||||||
*
|
*
|
||||||
@@ -75,7 +87,7 @@ class Ldap
|
|||||||
*
|
*
|
||||||
* @param resource|\LDAP\Connection $ldapConnection
|
* @param resource|\LDAP\Connection $ldapConnection
|
||||||
*/
|
*/
|
||||||
public function searchAndGetEntries($ldapConnection, string $baseDn, string $filter, array $attributes = null): array|false
|
public function searchAndGetEntries($ldapConnection, string $baseDn, string $filter, array $attributes = []): array|false
|
||||||
{
|
{
|
||||||
$search = $this->search($ldapConnection, $baseDn, $filter, $attributes);
|
$search = $this->search($ldapConnection, $baseDn, $filter, $attributes);
|
||||||
|
|
||||||
@@ -87,7 +99,7 @@ class Ldap
|
|||||||
*
|
*
|
||||||
* @param resource|\LDAP\Connection $ldapConnection
|
* @param resource|\LDAP\Connection $ldapConnection
|
||||||
*/
|
*/
|
||||||
public function bind($ldapConnection, string $bindRdn = null, string $bindPassword = null): bool
|
public function bind($ldapConnection, ?string $bindRdn = null, ?string $bindPassword = null): bool
|
||||||
{
|
{
|
||||||
return ldap_bind($ldapConnection, $bindRdn, $bindPassword);
|
return ldap_bind($ldapConnection, $bindRdn, $bindPassword);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,26 @@ class LdapService
|
|||||||
return $users[0];
|
return $users[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the user display name from the (potentially multiple) attributes defined by the configuration.
|
||||||
|
*/
|
||||||
|
protected function getUserDisplayName(array $userDetails, array $displayNameAttrs, string $defaultValue): string
|
||||||
|
{
|
||||||
|
$displayNameParts = [];
|
||||||
|
foreach ($displayNameAttrs as $dnAttr) {
|
||||||
|
$dnComponent = $this->getUserResponseProperty($userDetails, $dnAttr, null);
|
||||||
|
if ($dnComponent) {
|
||||||
|
$displayNameParts[] = $dnComponent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($displayNameParts)) {
|
||||||
|
return $defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' ', $displayNameParts);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the details of a user from LDAP using the given username.
|
* Get the details of a user from LDAP using the given username.
|
||||||
* User found via configurable user filter.
|
* User found via configurable user filter.
|
||||||
@@ -81,21 +101,25 @@ class LdapService
|
|||||||
{
|
{
|
||||||
$idAttr = $this->config['id_attribute'];
|
$idAttr = $this->config['id_attribute'];
|
||||||
$emailAttr = $this->config['email_attribute'];
|
$emailAttr = $this->config['email_attribute'];
|
||||||
$displayNameAttr = $this->config['display_name_attribute'];
|
$displayNameAttrs = explode('|', $this->config['display_name_attribute']);
|
||||||
$thumbnailAttr = $this->config['thumbnail_attribute'];
|
$thumbnailAttr = $this->config['thumbnail_attribute'];
|
||||||
|
|
||||||
$user = $this->getUserWithAttributes($userName, array_filter([
|
$user = $this->getUserWithAttributes($userName, array_filter([
|
||||||
'cn', 'dn', $idAttr, $emailAttr, $displayNameAttr, $thumbnailAttr,
|
'cn', 'dn', $idAttr, $emailAttr, ...$displayNameAttrs, $thumbnailAttr,
|
||||||
]));
|
]));
|
||||||
|
|
||||||
if (is_null($user)) {
|
if (is_null($user)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$userCn = $this->getUserResponseProperty($user, 'cn', null);
|
$nameDefault = $this->getUserResponseProperty($user, 'cn', null);
|
||||||
|
if (is_null($nameDefault)) {
|
||||||
|
$nameDefault = ldap_explode_dn($user['dn'], 1)[0] ?? $user['dn'];
|
||||||
|
}
|
||||||
|
|
||||||
$formatted = [
|
$formatted = [
|
||||||
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
|
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
|
||||||
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
|
'name' => $this->getUserDisplayName($user, $displayNameAttrs, $nameDefault),
|
||||||
'dn' => $user['dn'],
|
'dn' => $user['dn'],
|
||||||
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
|
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
|
||||||
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
|
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
|
||||||
@@ -209,6 +233,12 @@ class LdapService
|
|||||||
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
|
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Configure any user-provided CA cert files for LDAP.
|
||||||
|
// This option works globally and must be set before a connection is created.
|
||||||
|
if ($this->config['tls_ca_cert']) {
|
||||||
|
$this->configureTlsCaCerts($this->config['tls_ca_cert']);
|
||||||
|
}
|
||||||
|
|
||||||
$ldapHost = $this->parseServerString($this->config['server']);
|
$ldapHost = $this->parseServerString($this->config['server']);
|
||||||
$ldapConnection = $this->ldap->connect($ldapHost);
|
$ldapConnection = $this->ldap->connect($ldapHost);
|
||||||
|
|
||||||
@@ -223,7 +253,14 @@ class LdapService
|
|||||||
|
|
||||||
// Start and verify TLS if it's enabled
|
// Start and verify TLS if it's enabled
|
||||||
if ($this->config['start_tls']) {
|
if ($this->config['start_tls']) {
|
||||||
$started = $this->ldap->startTls($ldapConnection);
|
try {
|
||||||
|
$started = $this->ldap->startTls($ldapConnection);
|
||||||
|
} catch (\Exception $exception) {
|
||||||
|
$error = $exception->getMessage() . ' :: ' . ldap_error($ldapConnection);
|
||||||
|
ldap_get_option($ldapConnection, LDAP_OPT_DIAGNOSTIC_MESSAGE, $detail);
|
||||||
|
Log::info("LDAP STARTTLS failure: {$error} {$detail}");
|
||||||
|
throw new LdapException('Could not start TLS connection. Further details in the application log.');
|
||||||
|
}
|
||||||
if (!$started) {
|
if (!$started) {
|
||||||
throw new LdapException('Could not start TLS connection');
|
throw new LdapException('Could not start TLS connection');
|
||||||
}
|
}
|
||||||
@@ -234,6 +271,33 @@ class LdapService
|
|||||||
return $this->ldapConnection;
|
return $this->ldapConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure TLS CA certs globally for ldap use.
|
||||||
|
* This will detect if the given path is a directory or file, and set the relevant
|
||||||
|
* LDAP TLS options appropriately otherwise throw an exception if no file/folder found.
|
||||||
|
*
|
||||||
|
* Note: When using a folder, certificates are expected to be correctly named by hash
|
||||||
|
* which can be done via the c_rehash utility.
|
||||||
|
*
|
||||||
|
* @throws LdapException
|
||||||
|
*/
|
||||||
|
protected function configureTlsCaCerts(string $caCertPath): void
|
||||||
|
{
|
||||||
|
$errMessage = "Provided path [{$caCertPath}] for LDAP TLS CA certs could not be resolved to an existing location";
|
||||||
|
$path = realpath($caCertPath);
|
||||||
|
if ($path === false) {
|
||||||
|
throw new LdapException($errMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_dir($path)) {
|
||||||
|
$this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTDIR, $path);
|
||||||
|
} else if (is_file($path)) {
|
||||||
|
$this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTFILE, $path);
|
||||||
|
} else {
|
||||||
|
throw new LdapException($errMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse an LDAP server string and return the host suitable for a connection.
|
* Parse an LDAP server string and return the host suitable for a connection.
|
||||||
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
|
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
|
||||||
@@ -249,13 +313,18 @@ class LdapService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a filter string by injecting common variables.
|
* Build a filter string by injecting common variables.
|
||||||
|
* Both "${var}" and "{var}" style placeholders are supported.
|
||||||
|
* Dollar based are old format but supported for compatibility.
|
||||||
*/
|
*/
|
||||||
protected function buildFilter(string $filterString, array $attrs): string
|
protected function buildFilter(string $filterString, array $attrs): string
|
||||||
{
|
{
|
||||||
$newAttrs = [];
|
$newAttrs = [];
|
||||||
foreach ($attrs as $key => $attrText) {
|
foreach ($attrs as $key => $attrText) {
|
||||||
$newKey = '${' . $key . '}';
|
$escapedText = $this->ldap->escape($attrText);
|
||||||
$newAttrs[$newKey] = $this->ldap->escape($attrText);
|
$oldVarKey = '${' . $key . '}';
|
||||||
|
$newVarKey = '{' . $key . '}';
|
||||||
|
$newAttrs[$oldVarKey] = $escapedText;
|
||||||
|
$newAttrs[$newVarKey] = $escapedText;
|
||||||
}
|
}
|
||||||
|
|
||||||
return strtr($filterString, $newAttrs);
|
return strtr($filterString, $newAttrs);
|
||||||
@@ -276,94 +345,105 @@ class LdapService
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$userGroups = $this->groupFilter($user);
|
$userGroups = $this->extractGroupsFromSearchResponseEntry($user);
|
||||||
$allGroups = $this->getGroupsRecursive($userGroups, []);
|
$allGroups = $this->getGroupsRecursive($userGroups, []);
|
||||||
|
$formattedGroups = $this->extractGroupNamesFromLdapGroupDns($allGroups);
|
||||||
|
|
||||||
if ($this->config['dump_user_groups']) {
|
if ($this->config['dump_user_groups']) {
|
||||||
throw new JsonDebugException([
|
throw new JsonDebugException([
|
||||||
'details_from_ldap' => $user,
|
'details_from_ldap' => $user,
|
||||||
'parsed_direct_user_groups' => $userGroups,
|
'parsed_direct_user_groups' => $userGroups,
|
||||||
'parsed_recursive_user_groups' => $allGroups,
|
'parsed_recursive_user_groups' => $allGroups,
|
||||||
|
'parsed_resulting_group_names' => $formattedGroups,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $allGroups;
|
return $formattedGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function extractGroupNamesFromLdapGroupDns(array $groupDNs): array
|
||||||
|
{
|
||||||
|
$names = [];
|
||||||
|
|
||||||
|
foreach ($groupDNs as $groupDN) {
|
||||||
|
$exploded = $this->ldap->explodeDn($groupDN, 1);
|
||||||
|
if ($exploded !== false && count($exploded) > 0) {
|
||||||
|
$names[] = $exploded[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_unique($names);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the parent groups of an array of groups.
|
* Build an array of all relevant groups DNs after recursively scanning
|
||||||
|
* across parents of the groups given.
|
||||||
*
|
*
|
||||||
* @throws LdapException
|
* @throws LdapException
|
||||||
*/
|
*/
|
||||||
private function getGroupsRecursive(array $groupsArray, array $checked): array
|
protected function getGroupsRecursive(array $groupDNs, array $checked): array
|
||||||
{
|
{
|
||||||
$groupsToAdd = [];
|
$groupsToAdd = [];
|
||||||
foreach ($groupsArray as $groupName) {
|
foreach ($groupDNs as $groupDN) {
|
||||||
if (in_array($groupName, $checked)) {
|
if (in_array($groupDN, $checked)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$parentGroups = $this->getGroupGroups($groupName);
|
$parentGroups = $this->getParentsOfGroup($groupDN);
|
||||||
$groupsToAdd = array_merge($groupsToAdd, $parentGroups);
|
$groupsToAdd = array_merge($groupsToAdd, $parentGroups);
|
||||||
$checked[] = $groupName;
|
$checked[] = $groupDN;
|
||||||
}
|
}
|
||||||
|
|
||||||
$groupsArray = array_unique(array_merge($groupsArray, $groupsToAdd), SORT_REGULAR);
|
$uniqueDNs = array_unique(array_merge($groupDNs, $groupsToAdd), SORT_REGULAR);
|
||||||
|
|
||||||
if (empty($groupsToAdd)) {
|
if (empty($groupsToAdd)) {
|
||||||
return $groupsArray;
|
return $uniqueDNs;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->getGroupsRecursive($groupsArray, $checked);
|
return $this->getGroupsRecursive($uniqueDNs, $checked);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the parent groups of a single group.
|
|
||||||
*
|
|
||||||
* @throws LdapException
|
* @throws LdapException
|
||||||
*/
|
*/
|
||||||
private function getGroupGroups(string $groupName): array
|
protected function getParentsOfGroup(string $groupDN): array
|
||||||
{
|
{
|
||||||
|
$groupsAttr = strtolower($this->config['group_attribute']);
|
||||||
$ldapConnection = $this->getConnection();
|
$ldapConnection = $this->getConnection();
|
||||||
$this->bindSystemUser($ldapConnection);
|
$this->bindSystemUser($ldapConnection);
|
||||||
|
|
||||||
$followReferrals = $this->config['follow_referrals'] ? 1 : 0;
|
$followReferrals = $this->config['follow_referrals'] ? 1 : 0;
|
||||||
$this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
|
$this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
|
||||||
|
$read = $this->ldap->read($ldapConnection, $groupDN, '(objectClass=*)', [$groupsAttr]);
|
||||||
$baseDn = $this->config['base_dn'];
|
$results = $this->ldap->getEntries($ldapConnection, $read);
|
||||||
$groupsAttr = strtolower($this->config['group_attribute']);
|
if ($results['count'] === 0) {
|
||||||
|
|
||||||
$groupFilter = 'CN=' . $this->ldap->escape($groupName);
|
|
||||||
$groups = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $groupFilter, [$groupsAttr]);
|
|
||||||
if ($groups['count'] === 0) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->groupFilter($groups[0]);
|
return $this->extractGroupsFromSearchResponseEntry($results[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter out LDAP CN and DN language in a ldap search return.
|
* Extract an array of group DN values from the given LDAP search response entry
|
||||||
* Gets the base CN (common name) of the string.
|
|
||||||
*/
|
*/
|
||||||
protected function groupFilter(array $userGroupSearchResponse): array
|
protected function extractGroupsFromSearchResponseEntry(array $ldapEntry): array
|
||||||
{
|
{
|
||||||
$groupsAttr = strtolower($this->config['group_attribute']);
|
$groupsAttr = strtolower($this->config['group_attribute']);
|
||||||
$ldapGroups = [];
|
$groupDNs = [];
|
||||||
$count = 0;
|
$count = 0;
|
||||||
|
|
||||||
if (isset($userGroupSearchResponse[$groupsAttr]['count'])) {
|
if (isset($ldapEntry[$groupsAttr]['count'])) {
|
||||||
$count = (int) $userGroupSearchResponse[$groupsAttr]['count'];
|
$count = (int) $ldapEntry[$groupsAttr]['count'];
|
||||||
}
|
}
|
||||||
|
|
||||||
for ($i = 0; $i < $count; $i++) {
|
for ($i = 0; $i < $count; $i++) {
|
||||||
$dnComponents = $this->ldap->explodeDn($userGroupSearchResponse[$groupsAttr][$i], 1);
|
$dn = $ldapEntry[$groupsAttr][$i];
|
||||||
if (!in_array($dnComponents[0], $ldapGroups)) {
|
if (!in_array($dn, $groupDNs)) {
|
||||||
$ldapGroups[] = $dnComponents[0];
|
$groupDNs[] = $dn;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $ldapGroups;
|
return $groupDNs;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ namespace BookStack\Access;
|
|||||||
use BookStack\Access\Mfa\MfaSession;
|
use BookStack\Access\Mfa\MfaSession;
|
||||||
use BookStack\Activity\ActivityType;
|
use BookStack\Activity\ActivityType;
|
||||||
use BookStack\Exceptions\LoginAttemptException;
|
use BookStack\Exceptions\LoginAttemptException;
|
||||||
|
use BookStack\Exceptions\LoginAttemptInvalidUserException;
|
||||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||||
use BookStack\Facades\Activity;
|
use BookStack\Facades\Activity;
|
||||||
use BookStack\Facades\Theme;
|
use BookStack\Facades\Theme;
|
||||||
|
use BookStack\Permissions\Permission;
|
||||||
use BookStack\Theming\ThemeEvents;
|
use BookStack\Theming\ThemeEvents;
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
use Exception;
|
use Exception;
|
||||||
@@ -29,10 +31,14 @@ class LoginService
|
|||||||
* a reason to (MFA or Unconfirmed Email).
|
* a reason to (MFA or Unconfirmed Email).
|
||||||
* Returns a boolean to indicate the current login result.
|
* Returns a boolean to indicate the current login result.
|
||||||
*
|
*
|
||||||
* @throws StoppedAuthenticationException
|
* @throws StoppedAuthenticationException|LoginAttemptInvalidUserException
|
||||||
*/
|
*/
|
||||||
public function login(User $user, string $method, bool $remember = false): void
|
public function login(User $user, string $method, bool $remember = false): void
|
||||||
{
|
{
|
||||||
|
if ($user->isGuest()) {
|
||||||
|
throw new LoginAttemptInvalidUserException('Login not allowed for guest user');
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) {
|
if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) {
|
||||||
$this->setLastLoginAttemptedForUser($user, $method, $remember);
|
$this->setLastLoginAttemptedForUser($user, $method, $remember);
|
||||||
|
|
||||||
@@ -45,7 +51,7 @@ class LoginService
|
|||||||
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user);
|
Theme::dispatch(ThemeEvents::AUTH_LOGIN, $method, $user);
|
||||||
|
|
||||||
// Authenticate on all session guards if a likely admin
|
// Authenticate on all session guards if a likely admin
|
||||||
if ($user->can('users-manage') && $user->can('user-roles-manage')) {
|
if ($user->can(Permission::UsersManage) && $user->can(Permission::UserRolesManage)) {
|
||||||
$guards = ['standard', 'ldap', 'saml2', 'oidc'];
|
$guards = ['standard', 'ldap', 'saml2', 'oidc'];
|
||||||
foreach ($guards as $guard) {
|
foreach ($guards as $guard) {
|
||||||
auth($guard)->login($user);
|
auth($guard)->login($user);
|
||||||
@@ -58,7 +64,7 @@ class LoginService
|
|||||||
*
|
*
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function reattemptLoginFor(User $user)
|
public function reattemptLoginFor(User $user): void
|
||||||
{
|
{
|
||||||
if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) {
|
if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) {
|
||||||
throw new Exception('Login reattempt user does align with current session state');
|
throw new Exception('Login reattempt user does align with current session state');
|
||||||
@@ -90,7 +96,7 @@ class LoginService
|
|||||||
{
|
{
|
||||||
$value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
|
$value = session()->get(self::LAST_LOGIN_ATTEMPTED_SESSION_KEY);
|
||||||
if (!$value) {
|
if (!$value) {
|
||||||
return ['user_id' => null, 'method' => null];
|
return ['user_id' => null, 'method' => null, 'remember' => false];
|
||||||
}
|
}
|
||||||
|
|
||||||
[$id, $method, $remember, $time] = explode(':', $value);
|
[$id, $method, $remember, $time] = explode(':', $value);
|
||||||
@@ -98,18 +104,18 @@ class LoginService
|
|||||||
if ($time < $hourAgo) {
|
if ($time < $hourAgo) {
|
||||||
$this->clearLastLoginAttempted();
|
$this->clearLastLoginAttempted();
|
||||||
|
|
||||||
return ['user_id' => null, 'method' => null];
|
return ['user_id' => null, 'method' => null, 'remember' => false];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['user_id' => $id, 'method' => $method, 'remember' => boolval($remember)];
|
return ['user_id' => $id, 'method' => $method, 'remember' => boolval($remember)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the last login attempted user.
|
* Set the last login-attempted user.
|
||||||
* Must be only used when credentials are correct and a login could be
|
* Must be only used when credentials are correct and a login could be
|
||||||
* achieved but a secondary factor has stopped the login.
|
* achieved, but a secondary factor has stopped the login.
|
||||||
*/
|
*/
|
||||||
protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember)
|
protected function setLastLoginAttemptedForUser(User $user, string $method, bool $remember): void
|
||||||
{
|
{
|
||||||
session()->put(
|
session()->put(
|
||||||
self::LAST_LOGIN_ATTEMPTED_SESSION_KEY,
|
self::LAST_LOGIN_ATTEMPTED_SESSION_KEY,
|
||||||
@@ -152,16 +158,40 @@ class LoginService
|
|||||||
*/
|
*/
|
||||||
public function attempt(array $credentials, string $method, bool $remember = false): bool
|
public function attempt(array $credentials, string $method, bool $remember = false): bool
|
||||||
{
|
{
|
||||||
|
if ($this->areCredentialsForGuest($credentials)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$result = auth()->attempt($credentials, $remember);
|
$result = auth()->attempt($credentials, $remember);
|
||||||
if ($result) {
|
if ($result) {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
auth()->logout();
|
auth()->logout();
|
||||||
$this->login($user, $method, $remember);
|
try {
|
||||||
|
$this->login($user, $method, $remember);
|
||||||
|
} catch (LoginAttemptInvalidUserException $e) {
|
||||||
|
// Catch and return false for non-login accounts
|
||||||
|
// so it looks like a normal invalid login.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given credentials are likely for the system guest account.
|
||||||
|
*/
|
||||||
|
protected function areCredentialsForGuest(array $credentials): bool
|
||||||
|
{
|
||||||
|
if (isset($credentials['email'])) {
|
||||||
|
return User::query()->where('email', '=', $credentials['email'])
|
||||||
|
->where('system_name', '=', 'public')
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Logs the current user out of the application.
|
* Logs the current user out of the application.
|
||||||
* Returns an app post-redirect path.
|
* Returns an app post-redirect path.
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ class MfaSession
|
|||||||
*/
|
*/
|
||||||
public function isRequiredForUser(User $user): bool
|
public function isRequiredForUser(User $user): bool
|
||||||
{
|
{
|
||||||
// TODO - Test both these cases
|
|
||||||
return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
|
return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace BookStack\Access\Mfa;
|
|||||||
|
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,6 +17,8 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
*/
|
*/
|
||||||
class MfaValue extends Model
|
class MfaValue extends Model
|
||||||
{
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
protected static $unguarded = true;
|
protected static $unguarded = true;
|
||||||
|
|
||||||
const METHOD_TOTP = 'totp';
|
const METHOD_TOTP = 'totp';
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ use PragmaRX\Google2FA\Support\Constants;
|
|||||||
|
|
||||||
class TotpService
|
class TotpService
|
||||||
{
|
{
|
||||||
protected $google2fa;
|
public function __construct(
|
||||||
|
protected Google2FA $google2fa
|
||||||
public function __construct(Google2FA $google2fa)
|
) {
|
||||||
{
|
|
||||||
$this->google2fa = $google2fa;
|
$this->google2fa = $google2fa;
|
||||||
// Use SHA1 as a default, Personal testing of other options in 2021 found
|
// Use SHA1 as a default, Personal testing of other options in 2021 found
|
||||||
// many apps lack support for other algorithms yet still will scan
|
// many apps lack support for other algorithms yet still will scan
|
||||||
@@ -35,7 +34,7 @@ class TotpService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a TOTP URL from secret key.
|
* Generate a TOTP URL from a secret key.
|
||||||
*/
|
*/
|
||||||
public function generateUrl(string $secret, User $user): string
|
public function generateUrl(string $secret, User $user): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,36 +2,26 @@
|
|||||||
|
|
||||||
namespace BookStack\Access\Mfa;
|
namespace BookStack\Access\Mfa;
|
||||||
|
|
||||||
use Illuminate\Contracts\Validation\Rule;
|
use Closure;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
|
||||||
class TotpValidationRule implements Rule
|
class TotpValidationRule implements ValidationRule
|
||||||
{
|
{
|
||||||
protected $secret;
|
|
||||||
protected $totpService;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new rule instance.
|
* Create a new rule instance.
|
||||||
* Takes the TOTP secret that must be system provided, not user provided.
|
* Takes the TOTP secret that must be system provided, not user provided.
|
||||||
*/
|
*/
|
||||||
public function __construct(string $secret)
|
public function __construct(
|
||||||
{
|
protected string $secret,
|
||||||
$this->secret = $secret;
|
protected TotpService $totpService,
|
||||||
$this->totpService = app()->make(TotpService::class);
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||||
* Determine if the validation rule passes.
|
|
||||||
*/
|
|
||||||
public function passes($attribute, $value)
|
|
||||||
{
|
{
|
||||||
return $this->totpService->verifyCode($value, $this->secret);
|
$passes = $this->totpService->verifyCode($value, $this->secret);
|
||||||
}
|
if (!$passes) {
|
||||||
|
$fail(trans('validation.totp'));
|
||||||
/**
|
}
|
||||||
* Get the validation error message.
|
|
||||||
*/
|
|
||||||
public function message()
|
|
||||||
{
|
|
||||||
return trans('validation.totp');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,58 +2,8 @@
|
|||||||
|
|
||||||
namespace BookStack\Access\Oidc;
|
namespace BookStack\Access\Oidc;
|
||||||
|
|
||||||
class OidcIdToken
|
class OidcIdToken extends OidcJwtWithClaims implements ProvidesClaims
|
||||||
{
|
{
|
||||||
protected array $header;
|
|
||||||
protected array $payload;
|
|
||||||
protected string $signature;
|
|
||||||
protected string $issuer;
|
|
||||||
protected array $tokenParts = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array[]|string[]
|
|
||||||
*/
|
|
||||||
protected array $keys;
|
|
||||||
|
|
||||||
public function __construct(string $token, string $issuer, array $keys)
|
|
||||||
{
|
|
||||||
$this->keys = $keys;
|
|
||||||
$this->issuer = $issuer;
|
|
||||||
$this->parse($token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse the token content into its components.
|
|
||||||
*/
|
|
||||||
protected function parse(string $token): void
|
|
||||||
{
|
|
||||||
$this->tokenParts = explode('.', $token);
|
|
||||||
$this->header = $this->parseEncodedTokenPart($this->tokenParts[0]);
|
|
||||||
$this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? '');
|
|
||||||
$this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a Base64-JSON encoded token part.
|
|
||||||
* Returns the data as a key-value array or empty array upon error.
|
|
||||||
*/
|
|
||||||
protected function parseEncodedTokenPart(string $part): array
|
|
||||||
{
|
|
||||||
$json = $this->base64UrlDecode($part) ?: '{}';
|
|
||||||
$decoded = json_decode($json, true);
|
|
||||||
|
|
||||||
return is_array($decoded) ? $decoded : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base64URL decode. Needs some character conversions to be compatible
|
|
||||||
* with PHP's default base64 handling.
|
|
||||||
*/
|
|
||||||
protected function base64UrlDecode(string $encoded): string
|
|
||||||
{
|
|
||||||
return base64_decode(strtr($encoded, '-_', '+/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate all possible parts of the id token.
|
* Validate all possible parts of the id token.
|
||||||
*
|
*
|
||||||
@@ -61,91 +11,12 @@ class OidcIdToken
|
|||||||
*/
|
*/
|
||||||
public function validate(string $clientId): bool
|
public function validate(string $clientId): bool
|
||||||
{
|
{
|
||||||
$this->validateTokenStructure();
|
parent::validateCommonTokenDetails($clientId);
|
||||||
$this->validateTokenSignature();
|
|
||||||
$this->validateTokenClaims($clientId);
|
$this->validateTokenClaims($clientId);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch a specific claim from this token.
|
|
||||||
* Returns null if it is null or does not exist.
|
|
||||||
*
|
|
||||||
* @return mixed|null
|
|
||||||
*/
|
|
||||||
public function getClaim(string $claim)
|
|
||||||
{
|
|
||||||
return $this->payload[$claim] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all returned claims within the token.
|
|
||||||
*/
|
|
||||||
public function getAllClaims(): array
|
|
||||||
{
|
|
||||||
return $this->payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace the existing claim data of this token with that provided.
|
|
||||||
*/
|
|
||||||
public function replaceClaims(array $claims): void
|
|
||||||
{
|
|
||||||
$this->payload = $claims;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the structure of the given token and ensure we have the required pieces.
|
|
||||||
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
|
|
||||||
*
|
|
||||||
* @throws OidcInvalidTokenException
|
|
||||||
*/
|
|
||||||
protected function validateTokenStructure(): void
|
|
||||||
{
|
|
||||||
foreach (['header', 'payload'] as $prop) {
|
|
||||||
if (empty($this->$prop) || !is_array($this->$prop)) {
|
|
||||||
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($this->signature) || !is_string($this->signature)) {
|
|
||||||
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate the signature of the given token and ensure it validates against the provided key.
|
|
||||||
*
|
|
||||||
* @throws OidcInvalidTokenException
|
|
||||||
*/
|
|
||||||
protected function validateTokenSignature(): void
|
|
||||||
{
|
|
||||||
if ($this->header['alg'] !== 'RS256') {
|
|
||||||
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$parsedKeys = array_map(function ($key) {
|
|
||||||
try {
|
|
||||||
return new OidcJwtSigningKey($key);
|
|
||||||
} catch (OidcInvalidKeyException $e) {
|
|
||||||
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
|
|
||||||
}
|
|
||||||
}, $this->keys);
|
|
||||||
|
|
||||||
$parsedKeys = array_filter($parsedKeys);
|
|
||||||
|
|
||||||
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
|
|
||||||
/** @var OidcJwtSigningKey $parsedKey */
|
|
||||||
foreach ($parsedKeys as $parsedKey) {
|
|
||||||
if ($parsedKey->verify($contentToSign, $this->signature)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate the claims of the token.
|
* Validate the claims of the token.
|
||||||
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation.
|
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation.
|
||||||
@@ -156,27 +27,18 @@ class OidcIdToken
|
|||||||
{
|
{
|
||||||
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
|
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
|
||||||
// MUST exactly match the value of the iss (issuer) Claim.
|
// MUST exactly match the value of the iss (issuer) Claim.
|
||||||
if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
|
// Already done in parent.
|
||||||
throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
|
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
|
||||||
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
|
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
|
||||||
// if the ID Token does not list the Client as a valid audience, or if it contains additional
|
// if the ID Token does not list the Client as a valid audience, or if it contains additional
|
||||||
// audiences not trusted by the Client.
|
// audiences not trusted by the Client.
|
||||||
if (empty($this->payload['aud'])) {
|
// Partially done in parent.
|
||||||
throw new OidcInvalidTokenException('Missing token audience value');
|
|
||||||
}
|
|
||||||
|
|
||||||
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
|
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
|
||||||
if (count($aud) !== 1) {
|
if (count($aud) !== 1) {
|
||||||
throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
|
throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($aud[0] !== $clientId) {
|
|
||||||
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
|
// 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
|
||||||
// NOTE: Addressed by enforcing a count of 1 above.
|
// NOTE: Addressed by enforcing a count of 1 above.
|
||||||
|
|
||||||
|
|||||||
174
app/Access/Oidc/OidcJwtWithClaims.php
Normal file
174
app/Access/Oidc/OidcJwtWithClaims.php
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Access\Oidc;
|
||||||
|
|
||||||
|
class OidcJwtWithClaims implements ProvidesClaims
|
||||||
|
{
|
||||||
|
protected array $header;
|
||||||
|
protected array $payload;
|
||||||
|
protected string $signature;
|
||||||
|
protected string $issuer;
|
||||||
|
protected array $tokenParts = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array[]|string[]
|
||||||
|
*/
|
||||||
|
protected array $keys;
|
||||||
|
|
||||||
|
public function __construct(string $token, string $issuer, array $keys)
|
||||||
|
{
|
||||||
|
$this->keys = $keys;
|
||||||
|
$this->issuer = $issuer;
|
||||||
|
$this->parse($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the token content into its components.
|
||||||
|
*/
|
||||||
|
protected function parse(string $token): void
|
||||||
|
{
|
||||||
|
$this->tokenParts = explode('.', $token);
|
||||||
|
$this->header = $this->parseEncodedTokenPart($this->tokenParts[0]);
|
||||||
|
$this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? '');
|
||||||
|
$this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Base64-JSON encoded token part.
|
||||||
|
* Returns the data as a key-value array or empty array upon error.
|
||||||
|
*/
|
||||||
|
protected function parseEncodedTokenPart(string $part): array
|
||||||
|
{
|
||||||
|
$json = $this->base64UrlDecode($part) ?: '{}';
|
||||||
|
$decoded = json_decode($json, true);
|
||||||
|
|
||||||
|
return is_array($decoded) ? $decoded : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base64URL decode. Needs some character conversions to be compatible
|
||||||
|
* with PHP's default base64 handling.
|
||||||
|
*/
|
||||||
|
protected function base64UrlDecode(string $encoded): string
|
||||||
|
{
|
||||||
|
return base64_decode(strtr($encoded, '-_', '+/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate common parts of OIDC JWT tokens.
|
||||||
|
*
|
||||||
|
* @throws OidcInvalidTokenException
|
||||||
|
*/
|
||||||
|
public function validateCommonTokenDetails(string $clientId): bool
|
||||||
|
{
|
||||||
|
$this->validateTokenStructure();
|
||||||
|
$this->validateTokenSignature();
|
||||||
|
$this->validateCommonClaims($clientId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a specific claim from this token.
|
||||||
|
* Returns null if it is null or does not exist.
|
||||||
|
*/
|
||||||
|
public function getClaim(string $claim): mixed
|
||||||
|
{
|
||||||
|
return $this->payload[$claim] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all returned claims within the token.
|
||||||
|
*/
|
||||||
|
public function getAllClaims(): array
|
||||||
|
{
|
||||||
|
return $this->payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the existing claim data of this token with that provided.
|
||||||
|
*/
|
||||||
|
public function replaceClaims(array $claims): void
|
||||||
|
{
|
||||||
|
$this->payload = $claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the structure of the given token and ensure we have the required pieces.
|
||||||
|
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
|
||||||
|
*
|
||||||
|
* @throws OidcInvalidTokenException
|
||||||
|
*/
|
||||||
|
protected function validateTokenStructure(): void
|
||||||
|
{
|
||||||
|
foreach (['header', 'payload'] as $prop) {
|
||||||
|
if (empty($this->$prop) || !is_array($this->$prop)) {
|
||||||
|
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($this->signature) || !is_string($this->signature)) {
|
||||||
|
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the signature of the given token and ensure it validates against the provided key.
|
||||||
|
*
|
||||||
|
* @throws OidcInvalidTokenException
|
||||||
|
*/
|
||||||
|
protected function validateTokenSignature(): void
|
||||||
|
{
|
||||||
|
if ($this->header['alg'] !== 'RS256') {
|
||||||
|
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsedKeys = array_map(function ($key) {
|
||||||
|
try {
|
||||||
|
return new OidcJwtSigningKey($key);
|
||||||
|
} catch (OidcInvalidKeyException $e) {
|
||||||
|
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}, $this->keys);
|
||||||
|
|
||||||
|
$parsedKeys = array_filter($parsedKeys);
|
||||||
|
|
||||||
|
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
|
||||||
|
/** @var OidcJwtSigningKey $parsedKey */
|
||||||
|
foreach ($parsedKeys as $parsedKey) {
|
||||||
|
if ($parsedKey->verify($contentToSign, $this->signature)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate common claims for OIDC JWT tokens.
|
||||||
|
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation
|
||||||
|
* and https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
|
||||||
|
*
|
||||||
|
* @throws OidcInvalidTokenException
|
||||||
|
*/
|
||||||
|
protected function validateCommonClaims(string $clientId): void
|
||||||
|
{
|
||||||
|
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
|
||||||
|
// MUST exactly match the value of the iss (issuer) Claim.
|
||||||
|
if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
|
||||||
|
throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
|
||||||
|
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
|
||||||
|
// if the ID Token does not list the Client as a valid audience.
|
||||||
|
if (empty($this->payload['aud'])) {
|
||||||
|
throw new OidcInvalidTokenException('Missing token audience value');
|
||||||
|
}
|
||||||
|
|
||||||
|
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
|
||||||
|
if (!in_array($clientId, $aud, true)) {
|
||||||
|
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,15 +83,9 @@ class OidcOAuthProvider extends AbstractProvider
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks a provider response for errors.
|
* Checks a provider response for errors.
|
||||||
*
|
|
||||||
* @param ResponseInterface $response
|
|
||||||
* @param array|string $data Parsed response data
|
|
||||||
*
|
|
||||||
* @throws IdentityProviderException
|
* @throws IdentityProviderException
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
protected function checkResponse(ResponseInterface $response, $data)
|
protected function checkResponse(ResponseInterface $response, $data): void
|
||||||
{
|
{
|
||||||
if ($response->getStatusCode() >= 400 || isset($data['error'])) {
|
if ($response->getStatusCode() >= 400 || isset($data['error'])) {
|
||||||
throw new IdentityProviderException(
|
throw new IdentityProviderException(
|
||||||
@@ -105,13 +99,8 @@ class OidcOAuthProvider extends AbstractProvider
|
|||||||
/**
|
/**
|
||||||
* Generates a resource owner object from a successful resource owner
|
* Generates a resource owner object from a successful resource owner
|
||||||
* details request.
|
* details request.
|
||||||
*
|
|
||||||
* @param array $response
|
|
||||||
* @param AccessToken $token
|
|
||||||
*
|
|
||||||
* @return ResourceOwnerInterface
|
|
||||||
*/
|
*/
|
||||||
protected function createResourceOwner(array $response, AccessToken $token)
|
protected function createResourceOwner(array $response, AccessToken $token): ResourceOwnerInterface
|
||||||
{
|
{
|
||||||
return new GenericResourceOwner($response, '');
|
return new GenericResourceOwner($response, '');
|
||||||
}
|
}
|
||||||
@@ -121,14 +110,18 @@ class OidcOAuthProvider extends AbstractProvider
|
|||||||
*
|
*
|
||||||
* The grant that was used to fetch the response can be used to provide
|
* The grant that was used to fetch the response can be used to provide
|
||||||
* additional context.
|
* additional context.
|
||||||
*
|
|
||||||
* @param array $response
|
|
||||||
* @param AbstractGrant $grant
|
|
||||||
*
|
|
||||||
* @return OidcAccessToken
|
|
||||||
*/
|
*/
|
||||||
protected function createAccessToken(array $response, AbstractGrant $grant)
|
protected function createAccessToken(array $response, AbstractGrant $grant): OidcAccessToken
|
||||||
{
|
{
|
||||||
return new OidcAccessToken($response);
|
return new OidcAccessToken($response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the method used for PKCE code verifier hashing, which is passed
|
||||||
|
* in the "code_challenge_method" parameter in the authorization request.
|
||||||
|
*/
|
||||||
|
protected function getPkceMethod(): string
|
||||||
|
{
|
||||||
|
return static::PKCE_METHOD_S256;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ class OidcProviderSettings
|
|||||||
public string $issuer;
|
public string $issuer;
|
||||||
public string $clientId;
|
public string $clientId;
|
||||||
public string $clientSecret;
|
public string $clientSecret;
|
||||||
public ?string $redirectUri;
|
|
||||||
public ?string $authorizationEndpoint;
|
public ?string $authorizationEndpoint;
|
||||||
public ?string $tokenEndpoint;
|
public ?string $tokenEndpoint;
|
||||||
public ?string $endSessionEndpoint;
|
public ?string $endSessionEndpoint;
|
||||||
|
public ?string $userinfoEndpoint;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var string[]|array[]
|
* @var string[]|array[]
|
||||||
@@ -37,7 +37,7 @@ class OidcProviderSettings
|
|||||||
/**
|
/**
|
||||||
* Apply an array of settings to populate setting properties within this class.
|
* Apply an array of settings to populate setting properties within this class.
|
||||||
*/
|
*/
|
||||||
protected function applySettingsFromArray(array $settingsArray)
|
protected function applySettingsFromArray(array $settingsArray): void
|
||||||
{
|
{
|
||||||
foreach ($settingsArray as $key => $value) {
|
foreach ($settingsArray as $key => $value) {
|
||||||
if (property_exists($this, $key)) {
|
if (property_exists($this, $key)) {
|
||||||
@@ -51,9 +51,9 @@ class OidcProviderSettings
|
|||||||
*
|
*
|
||||||
* @throws InvalidArgumentException
|
* @throws InvalidArgumentException
|
||||||
*/
|
*/
|
||||||
protected function validateInitial()
|
protected function validateInitial(): void
|
||||||
{
|
{
|
||||||
$required = ['clientId', 'clientSecret', 'redirectUri', 'issuer'];
|
$required = ['clientId', 'clientSecret', 'issuer'];
|
||||||
foreach ($required as $prop) {
|
foreach ($required as $prop) {
|
||||||
if (empty($this->$prop)) {
|
if (empty($this->$prop)) {
|
||||||
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
|
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
|
||||||
@@ -73,12 +73,20 @@ class OidcProviderSettings
|
|||||||
public function validate(): void
|
public function validate(): void
|
||||||
{
|
{
|
||||||
$this->validateInitial();
|
$this->validateInitial();
|
||||||
|
|
||||||
$required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
|
$required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
|
||||||
foreach ($required as $prop) {
|
foreach ($required as $prop) {
|
||||||
if (empty($this->$prop)) {
|
if (empty($this->$prop)) {
|
||||||
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
|
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$endpointProperties = ['tokenEndpoint', 'authorizationEndpoint', 'userinfoEndpoint'];
|
||||||
|
foreach ($endpointProperties as $prop) {
|
||||||
|
if (is_string($this->$prop) && !str_starts_with($this->$prop, 'https://')) {
|
||||||
|
throw new InvalidArgumentException("Endpoint value for \"{$prop}\" must start with https://");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -86,7 +94,7 @@ class OidcProviderSettings
|
|||||||
*
|
*
|
||||||
* @throws OidcIssuerDiscoveryException
|
* @throws OidcIssuerDiscoveryException
|
||||||
*/
|
*/
|
||||||
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes)
|
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$cacheKey = 'oidc-discovery::' . $this->issuer;
|
$cacheKey = 'oidc-discovery::' . $this->issuer;
|
||||||
@@ -128,6 +136,10 @@ class OidcProviderSettings
|
|||||||
$discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
|
$discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!empty($result['userinfo_endpoint'])) {
|
||||||
|
$discoveredSettings['userinfoEndpoint'] = $result['userinfo_endpoint'];
|
||||||
|
}
|
||||||
|
|
||||||
if (!empty($result['jwks_uri'])) {
|
if (!empty($result['jwks_uri'])) {
|
||||||
$keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
|
$keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
|
||||||
$discoveredSettings['keys'] = $this->filterKeys($keys);
|
$discoveredSettings['keys'] = $this->filterKeys($keys);
|
||||||
@@ -175,9 +187,9 @@ class OidcProviderSettings
|
|||||||
/**
|
/**
|
||||||
* Get the settings needed by an OAuth provider, as a key=>value array.
|
* Get the settings needed by an OAuth provider, as a key=>value array.
|
||||||
*/
|
*/
|
||||||
public function arrayForProvider(): array
|
public function arrayForOAuthProvider(): array
|
||||||
{
|
{
|
||||||
$settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint'];
|
$settingKeys = ['clientId', 'clientSecret', 'authorizationEndpoint', 'tokenEndpoint', 'userinfoEndpoint'];
|
||||||
$settings = [];
|
$settings = [];
|
||||||
foreach ($settingKeys as $setting) {
|
foreach ($settingKeys as $setting) {
|
||||||
$settings[$setting] = $this->$setting;
|
$settings[$setting] = $this->$setting;
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ use BookStack\Exceptions\UserRegistrationException;
|
|||||||
use BookStack\Facades\Theme;
|
use BookStack\Facades\Theme;
|
||||||
use BookStack\Http\HttpRequestService;
|
use BookStack\Http\HttpRequestService;
|
||||||
use BookStack\Theming\ThemeEvents;
|
use BookStack\Theming\ThemeEvents;
|
||||||
|
use BookStack\Uploads\UserAvatars;
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
|
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
|
||||||
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
||||||
@@ -27,12 +27,15 @@ class OidcService
|
|||||||
protected RegistrationService $registrationService,
|
protected RegistrationService $registrationService,
|
||||||
protected LoginService $loginService,
|
protected LoginService $loginService,
|
||||||
protected HttpRequestService $http,
|
protected HttpRequestService $http,
|
||||||
protected GroupSyncService $groupService
|
protected GroupSyncService $groupService,
|
||||||
|
protected UserAvatars $userAvatars
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initiate an authorization flow.
|
* Initiate an authorization flow.
|
||||||
|
* Provides back an authorize redirect URL, in addition to other
|
||||||
|
* details which may be required for the auth flow.
|
||||||
*
|
*
|
||||||
* @throws OidcException
|
* @throws OidcException
|
||||||
*
|
*
|
||||||
@@ -42,8 +45,12 @@ class OidcService
|
|||||||
{
|
{
|
||||||
$settings = $this->getProviderSettings();
|
$settings = $this->getProviderSettings();
|
||||||
$provider = $this->getProvider($settings);
|
$provider = $this->getProvider($settings);
|
||||||
|
|
||||||
|
$url = $provider->getAuthorizationUrl();
|
||||||
|
session()->put('oidc_pkce_code', $provider->getPkceCode() ?? '');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'url' => $provider->getAuthorizationUrl(),
|
'url' => $url,
|
||||||
'state' => $provider->getState(),
|
'state' => $provider->getState(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -63,6 +70,10 @@ class OidcService
|
|||||||
$settings = $this->getProviderSettings();
|
$settings = $this->getProviderSettings();
|
||||||
$provider = $this->getProvider($settings);
|
$provider = $this->getProvider($settings);
|
||||||
|
|
||||||
|
// Set PKCE code flashed at login
|
||||||
|
$pkceCode = session()->pull('oidc_pkce_code', '');
|
||||||
|
$provider->setPkceCode($pkceCode);
|
||||||
|
|
||||||
// Try to exchange authorization code for access token
|
// Try to exchange authorization code for access token
|
||||||
$accessToken = $provider->getAccessToken('authorization_code', [
|
$accessToken = $provider->getAccessToken('authorization_code', [
|
||||||
'code' => $authorizationCode,
|
'code' => $authorizationCode,
|
||||||
@@ -81,10 +92,10 @@ class OidcService
|
|||||||
'issuer' => $config['issuer'],
|
'issuer' => $config['issuer'],
|
||||||
'clientId' => $config['client_id'],
|
'clientId' => $config['client_id'],
|
||||||
'clientSecret' => $config['client_secret'],
|
'clientSecret' => $config['client_secret'],
|
||||||
'redirectUri' => url('/oidc/callback'),
|
|
||||||
'authorizationEndpoint' => $config['authorization_endpoint'],
|
'authorizationEndpoint' => $config['authorization_endpoint'],
|
||||||
'tokenEndpoint' => $config['token_endpoint'],
|
'tokenEndpoint' => $config['token_endpoint'],
|
||||||
'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null,
|
'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null,
|
||||||
|
'userinfoEndpoint' => $config['userinfo_endpoint'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Use keys if configured
|
// Use keys if configured
|
||||||
@@ -119,7 +130,10 @@ class OidcService
|
|||||||
*/
|
*/
|
||||||
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
|
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
|
||||||
{
|
{
|
||||||
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [
|
$provider = new OidcOAuthProvider([
|
||||||
|
...$settings->arrayForOAuthProvider(),
|
||||||
|
'redirectUri' => url('/oidc/callback'),
|
||||||
|
], [
|
||||||
'httpClient' => $this->http->buildClient(5),
|
'httpClient' => $this->http->buildClient(5),
|
||||||
'optionProvider' => new HttpBasicAuthOptionProvider(),
|
'optionProvider' => new HttpBasicAuthOptionProvider(),
|
||||||
]);
|
]);
|
||||||
@@ -146,69 +160,6 @@ class OidcService
|
|||||||
return array_filter($scopeArr);
|
return array_filter($scopeArr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the display name.
|
|
||||||
*/
|
|
||||||
protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
|
|
||||||
{
|
|
||||||
$displayNameAttrString = $this->config()['display_name_claims'] ?? '';
|
|
||||||
$displayNameAttrs = explode('|', $displayNameAttrString);
|
|
||||||
|
|
||||||
$displayName = [];
|
|
||||||
foreach ($displayNameAttrs as $dnAttr) {
|
|
||||||
$dnComponent = $token->getClaim($dnAttr) ?? '';
|
|
||||||
if ($dnComponent !== '') {
|
|
||||||
$displayName[] = $dnComponent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count($displayName) == 0) {
|
|
||||||
$displayName[] = $defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
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, groups: string[]}
|
|
||||||
*/
|
|
||||||
protected function getUserDetails(OidcIdToken $token): array
|
|
||||||
{
|
|
||||||
$idClaim = $this->config()['external_id_claim'];
|
|
||||||
$id = $token->getClaim($idClaim);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'external_id' => $id,
|
|
||||||
'email' => $token->getClaim('email'),
|
|
||||||
'name' => $this->getUserDisplayName($token, $id),
|
|
||||||
'groups' => $this->getUserGroups($token),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes a received access token for a user. Login the user when
|
* Processes a received access token for a user. Login the user when
|
||||||
* they exist, optionally registering them automatically.
|
* they exist, optionally registering them automatically.
|
||||||
@@ -245,34 +196,39 @@ class OidcService
|
|||||||
try {
|
try {
|
||||||
$idToken->validate($settings->clientId);
|
$idToken->validate($settings->clientId);
|
||||||
} catch (OidcInvalidTokenException $exception) {
|
} catch (OidcInvalidTokenException $exception) {
|
||||||
throw new OidcException("ID token validate failed with error: {$exception->getMessage()}");
|
throw new OidcException("ID token validation failed with error: {$exception->getMessage()}");
|
||||||
}
|
}
|
||||||
|
|
||||||
$userDetails = $this->getUserDetails($idToken);
|
$userDetails = $this->getUserDetailsFromToken($idToken, $accessToken, $settings);
|
||||||
$isLoggedIn = auth()->check();
|
if (empty($userDetails->email)) {
|
||||||
|
|
||||||
if (empty($userDetails['email'])) {
|
|
||||||
throw new OidcException(trans('errors.oidc_no_email_address'));
|
throw new OidcException(trans('errors.oidc_no_email_address'));
|
||||||
}
|
}
|
||||||
|
if (empty($userDetails->name)) {
|
||||||
|
$userDetails->name = $userDetails->externalId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isLoggedIn = auth()->check();
|
||||||
if ($isLoggedIn) {
|
if ($isLoggedIn) {
|
||||||
throw new OidcException(trans('errors.oidc_already_logged_in'));
|
throw new OidcException(trans('errors.oidc_already_logged_in'));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$user = $this->registrationService->findOrRegister(
|
$user = $this->registrationService->findOrRegister(
|
||||||
$userDetails['name'],
|
$userDetails->name,
|
||||||
$userDetails['email'],
|
$userDetails->email,
|
||||||
$userDetails['external_id']
|
$userDetails->externalId
|
||||||
);
|
);
|
||||||
} catch (UserRegistrationException $exception) {
|
} catch (UserRegistrationException $exception) {
|
||||||
throw new OidcException($exception->getMessage());
|
throw new OidcException($exception->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->config()['fetch_avatar'] && !$user->avatar()->exists() && $userDetails->picture) {
|
||||||
|
$this->userAvatars->assignToUserFromUrl($user, $userDetails->picture);
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->shouldSyncGroups()) {
|
if ($this->shouldSyncGroups()) {
|
||||||
$groups = $userDetails['groups'];
|
|
||||||
$detachExisting = $this->config()['remove_from_groups'];
|
$detachExisting = $this->config()['remove_from_groups'];
|
||||||
$this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
|
$this->groupService->syncUserWithFoundGroups($user, $userDetails->groups ?? [], $detachExisting);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->loginService->login($user, 'oidc');
|
$this->loginService->login($user, 'oidc');
|
||||||
@@ -280,6 +236,45 @@ class OidcService
|
|||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws OidcException
|
||||||
|
*/
|
||||||
|
protected function getUserDetailsFromToken(OidcIdToken $idToken, OidcAccessToken $accessToken, OidcProviderSettings $settings): OidcUserDetails
|
||||||
|
{
|
||||||
|
$userDetails = new OidcUserDetails();
|
||||||
|
$userDetails->populate(
|
||||||
|
$idToken,
|
||||||
|
$this->config()['external_id_claim'],
|
||||||
|
$this->config()['display_name_claims'] ?? '',
|
||||||
|
$this->config()['groups_claim'] ?? ''
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$userDetails->isFullyPopulated($this->shouldSyncGroups()) && !empty($settings->userinfoEndpoint)) {
|
||||||
|
$provider = $this->getProvider($settings);
|
||||||
|
$request = $provider->getAuthenticatedRequest('GET', $settings->userinfoEndpoint, $accessToken->getToken());
|
||||||
|
$response = new OidcUserinfoResponse(
|
||||||
|
$provider->getResponse($request),
|
||||||
|
$settings->issuer,
|
||||||
|
$settings->keys,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response->validate($idToken->getClaim('sub'), $settings->clientId);
|
||||||
|
} catch (OidcInvalidTokenException $exception) {
|
||||||
|
throw new OidcException("Userinfo endpoint response validation failed with error: {$exception->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$userDetails->populate(
|
||||||
|
$response,
|
||||||
|
$this->config()['external_id_claim'],
|
||||||
|
$this->config()['display_name_claims'] ?? '',
|
||||||
|
$this->config()['groups_claim'] ?? ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $userDetails;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the OIDC config from the application.
|
* Get the OIDC config from the application.
|
||||||
*/
|
*/
|
||||||
|
|||||||
87
app/Access/Oidc/OidcUserDetails.php
Normal file
87
app/Access/Oidc/OidcUserDetails.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Access\Oidc;
|
||||||
|
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
|
class OidcUserDetails
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public ?string $externalId = null,
|
||||||
|
public ?string $email = null,
|
||||||
|
public ?string $name = null,
|
||||||
|
public ?array $groups = null,
|
||||||
|
public ?string $picture = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user details are fully populated for our usage.
|
||||||
|
*/
|
||||||
|
public function isFullyPopulated(bool $groupSyncActive): bool
|
||||||
|
{
|
||||||
|
$hasEmpty = empty($this->externalId)
|
||||||
|
|| empty($this->email)
|
||||||
|
|| empty($this->name)
|
||||||
|
|| ($groupSyncActive && $this->groups === null);
|
||||||
|
|
||||||
|
return !$hasEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate user details from the given claim data.
|
||||||
|
*/
|
||||||
|
public function populate(
|
||||||
|
ProvidesClaims $claims,
|
||||||
|
string $idClaim,
|
||||||
|
string $displayNameClaims,
|
||||||
|
string $groupsClaim,
|
||||||
|
): void {
|
||||||
|
$this->externalId = $claims->getClaim($idClaim) ?? $this->externalId;
|
||||||
|
$this->email = $claims->getClaim('email') ?? $this->email;
|
||||||
|
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name;
|
||||||
|
$this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups;
|
||||||
|
$this->picture = static::getPicture($claims) ?: $this->picture;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $claims): string
|
||||||
|
{
|
||||||
|
$displayNameClaimParts = explode('|', $displayNameClaims);
|
||||||
|
|
||||||
|
$displayName = [];
|
||||||
|
foreach ($displayNameClaimParts as $claim) {
|
||||||
|
$component = $claims->getClaim(trim($claim)) ?? '';
|
||||||
|
if ($component !== '') {
|
||||||
|
$displayName[] = $component;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' ', $displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $claims): ?array
|
||||||
|
{
|
||||||
|
if (empty($groupsClaim)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$groupsList = Arr::get($claims->getAllClaims(), $groupsClaim);
|
||||||
|
if (!is_array($groupsList)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter($groupsList, function ($val) {
|
||||||
|
return is_string($val);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function getPicture(ProvidesClaims $claims): ?string
|
||||||
|
{
|
||||||
|
$picture = $claims->getClaim('picture');
|
||||||
|
if (is_string($picture) && str_starts_with($picture, 'http')) {
|
||||||
|
return $picture;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/Access/Oidc/OidcUserinfoResponse.php
Normal file
69
app/Access/Oidc/OidcUserinfoResponse.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Access\Oidc;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
||||||
|
class OidcUserinfoResponse implements ProvidesClaims
|
||||||
|
{
|
||||||
|
protected array $claims = [];
|
||||||
|
protected ?OidcJwtWithClaims $jwt = null;
|
||||||
|
|
||||||
|
public function __construct(ResponseInterface $response, string $issuer, array $keys)
|
||||||
|
{
|
||||||
|
$contentTypeHeaderValue = $response->getHeader('Content-Type')[0] ?? '';
|
||||||
|
$contentType = strtolower(trim(explode(';', $contentTypeHeaderValue, 2)[0]));
|
||||||
|
|
||||||
|
if ($contentType === 'application/json') {
|
||||||
|
$this->claims = json_decode($response->getBody()->getContents(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($contentType === 'application/jwt') {
|
||||||
|
$this->jwt = new OidcJwtWithClaims($response->getBody()->getContents(), $issuer, $keys);
|
||||||
|
$this->claims = $this->jwt->getAllClaims();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws OidcInvalidTokenException
|
||||||
|
*/
|
||||||
|
public function validate(string $idTokenSub, string $clientId): bool
|
||||||
|
{
|
||||||
|
if (!is_null($this->jwt)) {
|
||||||
|
$this->jwt->validateCommonTokenDetails($clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sub = $this->getClaim('sub');
|
||||||
|
|
||||||
|
// Spec: v1.0 5.3.2: The sub (subject) Claim MUST always be returned in the UserInfo Response.
|
||||||
|
if (!is_string($sub) || empty($sub)) {
|
||||||
|
throw new OidcInvalidTokenException("No valid subject value found in userinfo data");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spec: v1.0 5.3.2: The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token;
|
||||||
|
// if they do not match, the UserInfo Response values MUST NOT be used.
|
||||||
|
if ($idTokenSub !== $sub) {
|
||||||
|
throw new OidcInvalidTokenException("Subject value provided in the userinfo endpoint does not match the provided ID token value");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spec v1.0 5.3.4 Defines the following:
|
||||||
|
// Verify that the OP that responded was the intended OP through a TLS server certificate check, per RFC 6125 [RFC6125].
|
||||||
|
// This is effectively done as part of the HTTP request we're making through CURLOPT_SSL_VERIFYHOST on the request.
|
||||||
|
// If the Client has provided a userinfo_encrypted_response_alg parameter during Registration, decrypt the UserInfo Response using the keys specified during Registration.
|
||||||
|
// We don't currently support JWT encryption for OIDC
|
||||||
|
// If the response was signed, the Client SHOULD validate the signature according to JWS [JWS].
|
||||||
|
// This is done as part of the validateCommonClaims above.
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getClaim(string $claim): mixed
|
||||||
|
{
|
||||||
|
return $this->claims[$claim] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllClaims(): array
|
||||||
|
{
|
||||||
|
return $this->claims;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Access/Oidc/ProvidesClaims.php
Normal file
17
app/Access/Oidc/ProvidesClaims.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Access\Oidc;
|
||||||
|
|
||||||
|
interface ProvidesClaims
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Fetch a specific claim.
|
||||||
|
* Returns null if it is null or does not exist.
|
||||||
|
*/
|
||||||
|
public function getClaim(string $claim): mixed;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all contained claims.
|
||||||
|
*/
|
||||||
|
public function getAllClaims(): array;
|
||||||
|
}
|
||||||
@@ -14,20 +14,14 @@ use Illuminate\Support\Str;
|
|||||||
|
|
||||||
class RegistrationService
|
class RegistrationService
|
||||||
{
|
{
|
||||||
protected $userRepo;
|
public function __construct(
|
||||||
protected $emailConfirmationService;
|
protected UserRepo $userRepo,
|
||||||
|
protected EmailConfirmationService $emailConfirmationService,
|
||||||
/**
|
) {
|
||||||
* RegistrationService constructor.
|
|
||||||
*/
|
|
||||||
public function __construct(UserRepo $userRepo, EmailConfirmationService $emailConfirmationService)
|
|
||||||
{
|
|
||||||
$this->userRepo = $userRepo;
|
|
||||||
$this->emailConfirmationService = $emailConfirmationService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether or not registrations are allowed in the app settings.
|
* Check if registrations are allowed in the app settings.
|
||||||
*
|
*
|
||||||
* @throws UserRegistrationException
|
* @throws UserRegistrationException
|
||||||
*/
|
*/
|
||||||
@@ -84,6 +78,7 @@ class RegistrationService
|
|||||||
public function registerUser(array $userData, ?SocialAccount $socialAccount = null, bool $emailConfirmed = false): User
|
public function registerUser(array $userData, ?SocialAccount $socialAccount = null, bool $emailConfirmed = false): User
|
||||||
{
|
{
|
||||||
$userEmail = $userData['email'];
|
$userEmail = $userData['email'];
|
||||||
|
$authSystem = $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver();
|
||||||
|
|
||||||
// Email restriction
|
// Email restriction
|
||||||
$this->ensureEmailDomainAllowed($userEmail);
|
$this->ensureEmailDomainAllowed($userEmail);
|
||||||
@@ -94,6 +89,12 @@ class RegistrationService
|
|||||||
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login');
|
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var ?bool $shouldRegister */
|
||||||
|
$shouldRegister = Theme::dispatch(ThemeEvents::AUTH_PRE_REGISTER, $authSystem, $userData);
|
||||||
|
if ($shouldRegister === false) {
|
||||||
|
throw new UserRegistrationException(trans('errors.auth_pre_register_theme_prevention'), '/login');
|
||||||
|
}
|
||||||
|
|
||||||
// Create the user
|
// Create the user
|
||||||
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
|
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
|
||||||
$newUser->attachDefaultRole();
|
$newUser->attachDefaultRole();
|
||||||
@@ -104,7 +105,7 @@ class RegistrationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
|
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
|
||||||
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver(), $newUser);
|
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $authSystem, $newUser);
|
||||||
|
|
||||||
// Start email confirmation flow if required
|
// Start email confirmation flow if required
|
||||||
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
|
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
|
||||||
@@ -138,7 +139,7 @@ class RegistrationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
|
$restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
|
||||||
$userEmailDomain = $domain = mb_substr(mb_strrchr($userEmail, '@'), 1);
|
$userEmailDomain = mb_substr(mb_strrchr($userEmail, '@'), 1);
|
||||||
if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
|
if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
|
||||||
$redirect = $this->registrationAllowed() ? '/register' : '/login';
|
$redirect = $this->registrationAllowed() ? '/register' : '/login';
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class Saml2Service
|
|||||||
* Returns the SAML2 request ID, and the URL to redirect the user to.
|
* Returns the SAML2 request ID, and the URL to redirect the user to.
|
||||||
*
|
*
|
||||||
* @throws Error
|
* @throws Error
|
||||||
* @returns array{url: string, id: ?string}
|
* @return array{url: string, id: ?string}
|
||||||
*/
|
*/
|
||||||
public function logout(User $user): array
|
public function logout(User $user): array
|
||||||
{
|
{
|
||||||
@@ -133,6 +133,7 @@ class Saml2Service
|
|||||||
// value so that the exact encoding format is matched when checking the signature.
|
// value so that the exact encoding format is matched when checking the signature.
|
||||||
// This is primarily due to ADFS encoding query params with lowercase percent encoding while
|
// This is primarily due to ADFS encoding query params with lowercase percent encoding while
|
||||||
// PHP (And most other sensible providers) standardise on uppercase.
|
// PHP (And most other sensible providers) standardise on uppercase.
|
||||||
|
/** @var ?string $samlRedirect */
|
||||||
$samlRedirect = $toolkit->processSLO(true, $requestId, true, null, true);
|
$samlRedirect = $toolkit->processSLO(true, $requestId, true, null, true);
|
||||||
$errors = $toolkit->getErrors();
|
$errors = $toolkit->getErrors();
|
||||||
|
|
||||||
|
|||||||
@@ -5,18 +5,23 @@ namespace BookStack\Access;
|
|||||||
use BookStack\Activity\Models\Loggable;
|
use BookStack\Activity\Models\Loggable;
|
||||||
use BookStack\App\Model;
|
use BookStack\App\Model;
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class SocialAccount.
|
|
||||||
*
|
|
||||||
* @property string $driver
|
* @property string $driver
|
||||||
* @property User $user
|
* @property User $user
|
||||||
*/
|
*/
|
||||||
class SocialAccount extends Model implements Loggable
|
class SocialAccount extends Model implements Loggable
|
||||||
{
|
{
|
||||||
protected $fillable = ['user_id', 'driver', 'driver_id', 'timestamps'];
|
use HasFactory;
|
||||||
|
|
||||||
public function user()
|
protected $fillable = ['user_id', 'driver', 'driver_id'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<User, $this>
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class SocialDriverManager
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the names of the active social drivers, keyed by driver id.
|
* Gets the names of the active social drivers, keyed by driver id.
|
||||||
* @returns array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
public function getActive(): array
|
public function getActive(): array
|
||||||
{
|
{
|
||||||
@@ -92,7 +92,7 @@ class SocialDriverManager
|
|||||||
string $driverName,
|
string $driverName,
|
||||||
array $config,
|
array $config,
|
||||||
string $socialiteHandler,
|
string $socialiteHandler,
|
||||||
callable $configureForRedirect = null
|
?callable $configureForRedirect = null
|
||||||
) {
|
) {
|
||||||
$this->validDrivers[] = $driverName;
|
$this->validDrivers[] = $driverName;
|
||||||
config()->set('services.' . $driverName, $config);
|
config()->set('services.' . $driverName, $config);
|
||||||
|
|||||||
10
app/Access/UserInviteException.php
Normal file
10
app/Access/UserInviteException.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Access;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class UserInviteException extends Exception
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
@@ -13,11 +13,17 @@ class UserInviteService extends UserTokenService
|
|||||||
/**
|
/**
|
||||||
* Send an invitation to a user to sign into BookStack
|
* Send an invitation to a user to sign into BookStack
|
||||||
* Removes existing invitation tokens.
|
* Removes existing invitation tokens.
|
||||||
|
* @throws UserInviteException
|
||||||
*/
|
*/
|
||||||
public function sendInvitation(User $user)
|
public function sendInvitation(User $user)
|
||||||
{
|
{
|
||||||
$this->deleteByUser($user);
|
$this->deleteByUser($user);
|
||||||
$token = $this->createTokenForUser($user);
|
$token = $this->createTokenForUser($user);
|
||||||
$user->notify(new UserInviteNotification($token));
|
|
||||||
|
try {
|
||||||
|
$user->notify(new UserInviteNotification($token));
|
||||||
|
} catch (\Exception $exception) {
|
||||||
|
throw new UserInviteException($exception->getMessage(), $exception->getCode(), $exception);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,19 @@ use BookStack\Entities\Models\Book;
|
|||||||
use BookStack\Entities\Models\Chapter;
|
use BookStack\Entities\Models\Chapter;
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Entities\Models\Page;
|
||||||
|
use BookStack\Entities\Tools\MixedEntityListLoader;
|
||||||
use BookStack\Permissions\PermissionApplicator;
|
use BookStack\Permissions\PermissionApplicator;
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
|
|
||||||
class ActivityQueries
|
class ActivityQueries
|
||||||
{
|
{
|
||||||
protected PermissionApplicator $permissions;
|
public function __construct(
|
||||||
|
protected PermissionApplicator $permissions,
|
||||||
public function __construct(PermissionApplicator $permissions)
|
protected MixedEntityListLoader $listLoader,
|
||||||
{
|
) {
|
||||||
$this->permissions = $permissions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,13 +28,15 @@ class ActivityQueries
|
|||||||
public function latest(int $count = 20, int $page = 0): array
|
public function latest(int $count = 20, int $page = 0): array
|
||||||
{
|
{
|
||||||
$activityList = $this->permissions
|
$activityList = $this->permissions
|
||||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->with(['user', 'entity'])
|
->with(['user'])
|
||||||
->skip($count * $page)
|
->skip($count * $page)
|
||||||
->take($count)
|
->take($count)
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
$this->listLoader->loadIntoRelations($activityList->all(), 'loggable', false);
|
||||||
|
|
||||||
return $this->filterSimilar($activityList);
|
return $this->filterSimilar($activityList);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,14 +60,15 @@ class ActivityQueries
|
|||||||
$query->where(function (Builder $query) use ($queryIds) {
|
$query->where(function (Builder $query) use ($queryIds) {
|
||||||
foreach ($queryIds as $morphClass => $idArr) {
|
foreach ($queryIds as $morphClass => $idArr) {
|
||||||
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
|
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
|
||||||
$innerQuery->where('entity_type', '=', $morphClass)
|
$innerQuery->where('loggable_type', '=', $morphClass)
|
||||||
->whereIn('entity_id', $idArr);
|
->whereIn('loggable_id', $idArr);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$activity = $query->orderBy('created_at', 'desc')
|
$activity = $query->orderBy('created_at', 'desc')
|
||||||
->with(['entity' => function (Relation $query) {
|
->with(['loggable' => function (Relation $query) {
|
||||||
|
/** @var MorphTo<Entity, Activity> $query */
|
||||||
$query->withTrashed();
|
$query->withTrashed();
|
||||||
}, 'user.avatar'])
|
}, 'user.avatar'])
|
||||||
->skip($count * ($page - 1))
|
->skip($count * ($page - 1))
|
||||||
@@ -80,7 +84,7 @@ class ActivityQueries
|
|||||||
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
||||||
{
|
{
|
||||||
$activityList = $this->permissions
|
$activityList = $this->permissions
|
||||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
|
||||||
->orderBy('created_at', 'desc')
|
->orderBy('created_at', 'desc')
|
||||||
->where('user_id', '=', $user->id)
|
->where('user_id', '=', $user->id)
|
||||||
->skip($count * $page)
|
->skip($count * $page)
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ class ActivityType
|
|||||||
const WEBHOOK_UPDATE = 'webhook_update';
|
const WEBHOOK_UPDATE = 'webhook_update';
|
||||||
const WEBHOOK_DELETE = 'webhook_delete';
|
const WEBHOOK_DELETE = 'webhook_delete';
|
||||||
|
|
||||||
|
const IMPORT_CREATE = 'import_create';
|
||||||
|
const IMPORT_RUN = 'import_run';
|
||||||
|
const IMPORT_DELETE = 'import_delete';
|
||||||
|
|
||||||
|
const SORT_RULE_CREATE = 'sort_rule_create';
|
||||||
|
const SORT_RULE_UPDATE = 'sort_rule_update';
|
||||||
|
const SORT_RULE_DELETE = 'sort_rule_delete';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all the possible values.
|
* Get all the possible values.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ namespace BookStack\Activity;
|
|||||||
|
|
||||||
use BookStack\Activity\Models\Comment;
|
use BookStack\Activity\Models\Comment;
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
|
use BookStack\Entities\Models\Page;
|
||||||
|
use BookStack\Exceptions\NotifyException;
|
||||||
use BookStack\Facades\Activity as ActivityService;
|
use BookStack\Facades\Activity as ActivityService;
|
||||||
use League\CommonMark\CommonMarkConverter;
|
use BookStack\Util\HtmlDescriptionFilter;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
class CommentRepo
|
class CommentRepo
|
||||||
{
|
{
|
||||||
@@ -17,36 +20,71 @@ class CommentRepo
|
|||||||
return Comment::query()->findOrFail($id);
|
return Comment::query()->findOrFail($id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a comment by ID, ensuring it is visible to the user based upon access to the page
|
||||||
|
* which the comment is attached to.
|
||||||
|
*/
|
||||||
|
public function getVisibleById(int $id): Comment
|
||||||
|
{
|
||||||
|
return $this->getQueryForVisible()->findOrFail($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a query for comments visible to the user.
|
||||||
|
* @return Builder<Comment>
|
||||||
|
*/
|
||||||
|
public function getQueryForVisible(): Builder
|
||||||
|
{
|
||||||
|
return Comment::query()->scopes('visible');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new comment on an entity.
|
* Create a new comment on an entity.
|
||||||
*/
|
*/
|
||||||
public function create(Entity $entity, string $text, ?int $parent_id): Comment
|
public function create(Entity $entity, string $html, ?int $parentId, string $contentRef): Comment
|
||||||
{
|
{
|
||||||
|
// Prevent comments being added to draft pages
|
||||||
|
if ($entity instanceof Page && $entity->draft) {
|
||||||
|
throw new \Exception(trans('errors.cannot_add_comment_to_draft'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate parent ID
|
||||||
|
if ($parentId !== null) {
|
||||||
|
$parentCommentExists = Comment::query()
|
||||||
|
->where('commentable_id', '=', $entity->id)
|
||||||
|
->where('commentable_type', '=', $entity->getMorphClass())
|
||||||
|
->where('local_id', '=', $parentId)
|
||||||
|
->exists();
|
||||||
|
if (!$parentCommentExists) {
|
||||||
|
$parentId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$userId = user()->id;
|
$userId = user()->id;
|
||||||
$comment = new Comment();
|
$comment = new Comment();
|
||||||
|
|
||||||
$comment->text = $text;
|
$comment->html = HtmlDescriptionFilter::filterFromString($html);
|
||||||
$comment->html = $this->commentToHtml($text);
|
|
||||||
$comment->created_by = $userId;
|
$comment->created_by = $userId;
|
||||||
$comment->updated_by = $userId;
|
$comment->updated_by = $userId;
|
||||||
$comment->local_id = $this->getNextLocalId($entity);
|
$comment->local_id = $this->getNextLocalId($entity);
|
||||||
$comment->parent_id = $parent_id;
|
$comment->parent_id = $parentId;
|
||||||
|
$comment->content_ref = preg_match('/^bkmrk-(.*?):\d+:(\d*-\d*)?$/', $contentRef) === 1 ? $contentRef : '';
|
||||||
|
|
||||||
$entity->comments()->save($comment);
|
$entity->comments()->save($comment);
|
||||||
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
|
ActivityService::add(ActivityType::COMMENT_CREATE, $comment);
|
||||||
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
|
ActivityService::add(ActivityType::COMMENTED_ON, $entity);
|
||||||
|
|
||||||
|
$comment->refresh()->unsetRelations();
|
||||||
return $comment;
|
return $comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing comment.
|
* Update an existing comment.
|
||||||
*/
|
*/
|
||||||
public function update(Comment $comment, string $text): Comment
|
public function update(Comment $comment, string $html): Comment
|
||||||
{
|
{
|
||||||
$comment->updated_by = user()->id;
|
$comment->updated_by = user()->id;
|
||||||
$comment->text = $text;
|
$comment->html = HtmlDescriptionFilter::filterFromString($html);
|
||||||
$comment->html = $this->commentToHtml($text);
|
|
||||||
$comment->save();
|
$comment->save();
|
||||||
|
|
||||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||||
@@ -54,6 +92,45 @@ class CommentRepo
|
|||||||
return $comment;
|
return $comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive an existing comment.
|
||||||
|
*/
|
||||||
|
public function archive(Comment $comment, bool $log = true): Comment
|
||||||
|
{
|
||||||
|
if ($comment->parent_id) {
|
||||||
|
throw new NotifyException('Only top-level comments can be archived.', '/', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$comment->archived = true;
|
||||||
|
$comment->save();
|
||||||
|
|
||||||
|
if ($log) {
|
||||||
|
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Un-archive an existing comment.
|
||||||
|
*/
|
||||||
|
public function unarchive(Comment $comment, bool $log = true): Comment
|
||||||
|
{
|
||||||
|
if ($comment->parent_id) {
|
||||||
|
throw new NotifyException('Only top-level comments can be un-archived.', '/', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$comment->archived = false;
|
||||||
|
$comment->save();
|
||||||
|
|
||||||
|
if ($log) {
|
||||||
|
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $comment;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a comment from the system.
|
* Delete a comment from the system.
|
||||||
*/
|
*/
|
||||||
@@ -64,20 +141,6 @@ class CommentRepo
|
|||||||
ActivityService::add(ActivityType::COMMENT_DELETE, $comment);
|
ActivityService::add(ActivityType::COMMENT_DELETE, $comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert the given comment Markdown to HTML.
|
|
||||||
*/
|
|
||||||
public function commentToHtml(string $commentText): string
|
|
||||||
{
|
|
||||||
$converter = new CommonMarkConverter([
|
|
||||||
'html_input' => 'strip',
|
|
||||||
'max_nesting_level' => 10,
|
|
||||||
'allow_unsafe_links' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $converter->convert($commentText);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the next local ID relative to the linked entity.
|
* Get the next local ID relative to the linked entity.
|
||||||
*/
|
*/
|
||||||
|
|||||||
29
app/Activity/Controllers/AuditLogApiController.php
Normal file
29
app/Activity/Controllers/AuditLogApiController.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Activity\Controllers;
|
||||||
|
|
||||||
|
use BookStack\Activity\Models\Activity;
|
||||||
|
use BookStack\Http\ApiController;
|
||||||
|
use BookStack\Permissions\Permission;
|
||||||
|
|
||||||
|
class AuditLogApiController extends ApiController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get a listing of audit log events in the system.
|
||||||
|
* The loggable relation fields currently only relates to core
|
||||||
|
* content types (page, book, bookshelf, chapter) but this may be
|
||||||
|
* used more in the future across other types.
|
||||||
|
* Requires permission to manage both users and system settings.
|
||||||
|
*/
|
||||||
|
public function list()
|
||||||
|
{
|
||||||
|
$this->checkPermission(Permission::SettingsManage);
|
||||||
|
$this->checkPermission(Permission::UsersManage);
|
||||||
|
|
||||||
|
$query = Activity::query()->with(['user']);
|
||||||
|
|
||||||
|
return $this->apiListingResponse($query, [
|
||||||
|
'id', 'type', 'detail', 'user_id', 'loggable_id', 'loggable_type', 'ip', 'created_at',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ namespace BookStack\Activity\Controllers;
|
|||||||
use BookStack\Activity\ActivityType;
|
use BookStack\Activity\ActivityType;
|
||||||
use BookStack\Activity\Models\Activity;
|
use BookStack\Activity\Models\Activity;
|
||||||
use BookStack\Http\Controller;
|
use BookStack\Http\Controller;
|
||||||
|
use BookStack\Permissions\Permission;
|
||||||
|
use BookStack\Sorting\SortUrl;
|
||||||
use BookStack\Util\SimpleListOptions;
|
use BookStack\Util\SimpleListOptions;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
@@ -12,8 +14,8 @@ class AuditLogController extends Controller
|
|||||||
{
|
{
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$this->checkPermission('settings-manage');
|
$this->checkPermission(Permission::SettingsManage);
|
||||||
$this->checkPermission('users-manage');
|
$this->checkPermission(Permission::UsersManage);
|
||||||
|
|
||||||
$sort = $request->get('sort', 'activity_date');
|
$sort = $request->get('sort', 'activity_date');
|
||||||
$order = $request->get('order', 'desc');
|
$order = $request->get('order', 'desc');
|
||||||
@@ -32,7 +34,7 @@ class AuditLogController extends Controller
|
|||||||
|
|
||||||
$query = Activity::query()
|
$query = Activity::query()
|
||||||
->with([
|
->with([
|
||||||
'entity' => fn ($query) => $query->withTrashed(),
|
'loggable' => fn ($query) => $query->withTrashed(),
|
||||||
'user',
|
'user',
|
||||||
])
|
])
|
||||||
->orderBy($listOptions->getSort(), $listOptions->getOrder());
|
->orderBy($listOptions->getSort(), $listOptions->getOrder());
|
||||||
@@ -65,6 +67,7 @@ class AuditLogController extends Controller
|
|||||||
'filters' => $filters,
|
'filters' => $filters,
|
||||||
'listOptions' => $listOptions,
|
'listOptions' => $listOptions,
|
||||||
'activityTypes' => $types,
|
'activityTypes' => $types,
|
||||||
|
'filterSortUrl' => new SortUrl('settings/audit', array_filter($request->except('page')))
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
148
app/Activity/Controllers/CommentApiController.php
Normal file
148
app/Activity/Controllers/CommentApiController.php
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace BookStack\Activity\Controllers;
|
||||||
|
|
||||||
|
use BookStack\Activity\CommentRepo;
|
||||||
|
use BookStack\Activity\Models\Comment;
|
||||||
|
use BookStack\Entities\Queries\PageQueries;
|
||||||
|
use BookStack\Http\ApiController;
|
||||||
|
use BookStack\Permissions\Permission;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The comment data model has a 'local_id' property, which is a unique integer ID
|
||||||
|
* scoped to the page which the comment is on. The 'parent_id' is used for replies
|
||||||
|
* and refers to the 'local_id' of the parent comment on the same page, not the main
|
||||||
|
* globally unique 'id'.
|
||||||
|
*
|
||||||
|
* If you want to get all comments for a page in a tree-like structure, as reflected in
|
||||||
|
* the UI, then that is provided on pages-read API responses.
|
||||||
|
*/
|
||||||
|
class CommentApiController extends ApiController
|
||||||
|
{
|
||||||
|
protected array $rules = [
|
||||||
|
'create' => [
|
||||||
|
'page_id' => ['required', 'integer'],
|
||||||
|
'reply_to' => ['nullable', 'integer'],
|
||||||
|
'html' => ['required', 'string'],
|
||||||
|
'content_ref' => ['string'],
|
||||||
|
],
|
||||||
|
'update' => [
|
||||||
|
'html' => ['string'],
|
||||||
|
'archived' => ['boolean'],
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected CommentRepo $commentRepo,
|
||||||
|
protected PageQueries $pageQueries,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a listing of comments visible to the user.
|
||||||
|
*/
|
||||||
|
public function list(): JsonResponse
|
||||||
|
{
|
||||||
|
$query = $this->commentRepo->getQueryForVisible();
|
||||||
|
|
||||||
|
return $this->apiListingResponse($query, [
|
||||||
|
'id', 'commentable_id', 'commentable_type', 'parent_id', 'local_id', 'content_ref', 'created_by', 'updated_by', 'created_at', 'updated_at'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new comment on a page.
|
||||||
|
* If commenting as a reply to an existing comment, the 'reply_to' parameter
|
||||||
|
* should be provided, set to the 'local_id' of the comment being replied to.
|
||||||
|
*/
|
||||||
|
public function create(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->checkPermission(Permission::CommentCreateAll);
|
||||||
|
|
||||||
|
$input = $this->validate($request, $this->rules()['create']);
|
||||||
|
$page = $this->pageQueries->findVisibleByIdOrFail($input['page_id']);
|
||||||
|
|
||||||
|
$comment = $this->commentRepo->create(
|
||||||
|
$page,
|
||||||
|
$input['html'],
|
||||||
|
$input['reply_to'] ?? null,
|
||||||
|
$input['content_ref'] ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the details of a single comment, along with its direct replies.
|
||||||
|
*/
|
||||||
|
public function read(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$comment = $this->commentRepo->getVisibleById(intval($id));
|
||||||
|
$comment->load('createdBy', 'updatedBy');
|
||||||
|
|
||||||
|
$replies = $this->commentRepo->getQueryForVisible()
|
||||||
|
->where('parent_id', '=', $comment->local_id)
|
||||||
|
->where('commentable_id', '=', $comment->commentable_id)
|
||||||
|
->where('commentable_type', '=', $comment->commentable_type)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
/** @var Comment[] $toProcess */
|
||||||
|
$toProcess = [$comment, ...$replies];
|
||||||
|
foreach ($toProcess as $commentToProcess) {
|
||||||
|
$commentToProcess->setAttribute('html', $commentToProcess->safeHtml());
|
||||||
|
$commentToProcess->makeVisible('html');
|
||||||
|
}
|
||||||
|
|
||||||
|
$comment->setRelation('replies', $replies);
|
||||||
|
|
||||||
|
return response()->json($comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the content or archived status of an existing comment.
|
||||||
|
*
|
||||||
|
* Only provide a new archived status if needing to actively change the archive state.
|
||||||
|
* Only top-level comments (non-replies) can be archived or unarchived.
|
||||||
|
*/
|
||||||
|
public function update(Request $request, string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$comment = $this->commentRepo->getVisibleById(intval($id));
|
||||||
|
$this->checkOwnablePermission(Permission::CommentUpdate, $comment);
|
||||||
|
|
||||||
|
$input = $this->validate($request, $this->rules()['update']);
|
||||||
|
$hasHtml = isset($input['html']);
|
||||||
|
|
||||||
|
if (isset($input['archived'])) {
|
||||||
|
if ($input['archived']) {
|
||||||
|
$this->commentRepo->archive($comment, !$hasHtml);
|
||||||
|
} else {
|
||||||
|
$this->commentRepo->unarchive($comment, !$hasHtml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasHtml) {
|
||||||
|
$comment = $this->commentRepo->update($comment, $input['html']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a single comment from the system.
|
||||||
|
*/
|
||||||
|
public function delete(string $id): Response
|
||||||
|
{
|
||||||
|
$comment = $this->commentRepo->getVisibleById(intval($id));
|
||||||
|
$this->checkOwnablePermission(Permission::CommentDelete, $comment);
|
||||||
|
|
||||||
|
$this->commentRepo->delete($comment);
|
||||||
|
|
||||||
|
return response('', 204);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,50 +3,48 @@
|
|||||||
namespace BookStack\Activity\Controllers;
|
namespace BookStack\Activity\Controllers;
|
||||||
|
|
||||||
use BookStack\Activity\CommentRepo;
|
use BookStack\Activity\CommentRepo;
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Activity\Tools\CommentTree;
|
||||||
|
use BookStack\Activity\Tools\CommentTreeNode;
|
||||||
|
use BookStack\Entities\Queries\PageQueries;
|
||||||
use BookStack\Http\Controller;
|
use BookStack\Http\Controller;
|
||||||
|
use BookStack\Permissions\Permission;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class CommentController extends Controller
|
class CommentController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected CommentRepo $commentRepo
|
protected CommentRepo $commentRepo,
|
||||||
|
protected PageQueries $pageQueries,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save a new comment for a Page.
|
* Save a new comment for a Page.
|
||||||
*
|
*
|
||||||
* @throws ValidationException
|
* @throws ValidationException|\Exception
|
||||||
*/
|
*/
|
||||||
public function savePageComment(Request $request, int $pageId)
|
public function savePageComment(Request $request, int $pageId)
|
||||||
{
|
{
|
||||||
$this->validate($request, [
|
$input = $this->validate($request, [
|
||||||
'text' => ['required', 'string'],
|
'html' => ['required', 'string'],
|
||||||
'parent_id' => ['nullable', 'integer'],
|
'parent_id' => ['nullable', 'integer'],
|
||||||
|
'content_ref' => ['string'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$page = Page::visible()->find($pageId);
|
$page = $this->pageQueries->findVisibleById($pageId);
|
||||||
if ($page === null) {
|
if ($page === null) {
|
||||||
return response('Not found', 404);
|
return response('Not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent adding comments to draft pages
|
|
||||||
if ($page->draft) {
|
|
||||||
return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new comment.
|
// Create a new comment.
|
||||||
$this->checkPermission('comment-create-all');
|
$this->checkPermission(Permission::CommentCreateAll);
|
||||||
$comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id'));
|
$contentRef = $input['content_ref'] ?? '';
|
||||||
|
$comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null, $contentRef);
|
||||||
|
|
||||||
return view('comments.comment-branch', [
|
return view('comments.comment-branch', [
|
||||||
'readOnly' => false,
|
'readOnly' => false,
|
||||||
'branch' => [
|
'branch' => new CommentTreeNode($comment, 0, []),
|
||||||
'comment' => $comment,
|
|
||||||
'children' => [],
|
|
||||||
]
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,17 +55,60 @@ class CommentController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(Request $request, int $commentId)
|
public function update(Request $request, int $commentId)
|
||||||
{
|
{
|
||||||
$this->validate($request, [
|
$input = $this->validate($request, [
|
||||||
'text' => ['required', 'string'],
|
'html' => ['required', 'string'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$comment = $this->commentRepo->getById($commentId);
|
$comment = $this->commentRepo->getById($commentId);
|
||||||
$this->checkOwnablePermission('page-view', $comment->entity);
|
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
|
||||||
$this->checkOwnablePermission('comment-update', $comment);
|
$this->checkOwnablePermission(Permission::CommentUpdate, $comment);
|
||||||
|
|
||||||
$comment = $this->commentRepo->update($comment, $request->get('text'));
|
$comment = $this->commentRepo->update($comment, $input['html']);
|
||||||
|
|
||||||
return view('comments.comment', ['comment' => $comment, 'readOnly' => false]);
|
return view('comments.comment', [
|
||||||
|
'comment' => $comment,
|
||||||
|
'readOnly' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a comment as archived.
|
||||||
|
*/
|
||||||
|
public function archive(int $id)
|
||||||
|
{
|
||||||
|
$comment = $this->commentRepo->getById($id);
|
||||||
|
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
|
||||||
|
if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) {
|
||||||
|
$this->showPermissionError();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->commentRepo->archive($comment);
|
||||||
|
|
||||||
|
$tree = new CommentTree($comment->entity);
|
||||||
|
return view('comments.comment-branch', [
|
||||||
|
'readOnly' => false,
|
||||||
|
'branch' => $tree->getCommentNodeForId($id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unmark a comment as archived.
|
||||||
|
*/
|
||||||
|
public function unarchive(int $id)
|
||||||
|
{
|
||||||
|
$comment = $this->commentRepo->getById($id);
|
||||||
|
$this->checkOwnablePermission(Permission::PageView, $comment->entity);
|
||||||
|
if (!userCan(Permission::CommentUpdate, $comment) && !userCan(Permission::CommentDelete, $comment)) {
|
||||||
|
$this->showPermissionError();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->commentRepo->unarchive($comment);
|
||||||
|
|
||||||
|
$tree = new CommentTree($comment->entity);
|
||||||
|
return view('comments.comment-branch', [
|
||||||
|
'readOnly' => false,
|
||||||
|
'branch' => $tree->getCommentNodeForId($id),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,7 +117,7 @@ class CommentController extends Controller
|
|||||||
public function destroy(int $id)
|
public function destroy(int $id)
|
||||||
{
|
{
|
||||||
$comment = $this->commentRepo->getById($id);
|
$comment = $this->commentRepo->getById($id);
|
||||||
$this->checkOwnablePermission('comment-delete', $comment);
|
$this->checkOwnablePermission(Permission::CommentDelete, $comment);
|
||||||
|
|
||||||
$this->commentRepo->delete($comment);
|
$this->commentRepo->delete($comment);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace BookStack\Activity\Controllers;
|
namespace BookStack\Activity\Controllers;
|
||||||
|
|
||||||
use BookStack\Entities\Queries\TopFavourites;
|
use BookStack\Entities\Queries\QueryTopFavourites;
|
||||||
use BookStack\Entities\Tools\MixedEntityRequestHelper;
|
use BookStack\Entities\Tools\MixedEntityRequestHelper;
|
||||||
use BookStack\Http\Controller;
|
use BookStack\Http\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -17,11 +17,11 @@ class FavouriteController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Show a listing of all favourite items for the current user.
|
* Show a listing of all favourite items for the current user.
|
||||||
*/
|
*/
|
||||||
public function index(Request $request)
|
public function index(Request $request, QueryTopFavourites $topFavourites)
|
||||||
{
|
{
|
||||||
$viewCount = 20;
|
$viewCount = 20;
|
||||||
$page = intval($request->get('page', 1));
|
$page = intval($request->get('page', 1));
|
||||||
$favourites = (new TopFavourites())->run($viewCount + 1, (($page - 1) * $viewCount));
|
$favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount));
|
||||||
|
|
||||||
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
|
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ namespace BookStack\Activity\Controllers;
|
|||||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||||
use BookStack\Entities\Tools\MixedEntityRequestHelper;
|
use BookStack\Entities\Tools\MixedEntityRequestHelper;
|
||||||
use BookStack\Http\Controller;
|
use BookStack\Http\Controller;
|
||||||
|
use BookStack\Permissions\Permission;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class WatchController extends Controller
|
class WatchController extends Controller
|
||||||
{
|
{
|
||||||
public function update(Request $request, MixedEntityRequestHelper $entityHelper)
|
public function update(Request $request, MixedEntityRequestHelper $entityHelper)
|
||||||
{
|
{
|
||||||
$this->checkPermission('receive-notifications');
|
$this->checkPermission(Permission::ReceiveNotifications);
|
||||||
$this->preventGuestAccess();
|
$this->preventGuestAccess();
|
||||||
|
|
||||||
$requestData = $this->validate($request, array_merge([
|
$requestData = $this->validate($request, array_merge([
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType;
|
|||||||
use BookStack\Activity\Models\Webhook;
|
use BookStack\Activity\Models\Webhook;
|
||||||
use BookStack\Activity\Queries\WebhooksAllPaginatedAndSorted;
|
use BookStack\Activity\Queries\WebhooksAllPaginatedAndSorted;
|
||||||
use BookStack\Http\Controller;
|
use BookStack\Http\Controller;
|
||||||
|
use BookStack\Permissions\Permission;
|
||||||
use BookStack\Util\SimpleListOptions;
|
use BookStack\Util\SimpleListOptions;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ class WebhookController extends Controller
|
|||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->middleware([
|
$this->middleware([
|
||||||
'can:settings-manage',
|
Permission::SettingsManage->middleware()
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use BookStack\App\Model;
|
|||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Permissions\Models\JointPermission;
|
use BookStack\Permissions\Models\JointPermission;
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
@@ -15,26 +16,26 @@ use Illuminate\Support\Str;
|
|||||||
/**
|
/**
|
||||||
* @property string $type
|
* @property string $type
|
||||||
* @property User $user
|
* @property User $user
|
||||||
* @property Entity $entity
|
* @property Entity $loggable
|
||||||
* @property string $detail
|
* @property string $detail
|
||||||
* @property string $entity_type
|
* @property string $loggable_type
|
||||||
* @property int $entity_id
|
* @property int $loggable_id
|
||||||
* @property int $user_id
|
* @property int $user_id
|
||||||
* @property Carbon $created_at
|
* @property Carbon $created_at
|
||||||
* @property Carbon $updated_at
|
|
||||||
*/
|
*/
|
||||||
class Activity extends Model
|
class Activity extends Model
|
||||||
{
|
{
|
||||||
/**
|
use HasFactory;
|
||||||
* Get the entity for this activity.
|
|
||||||
*/
|
|
||||||
public function entity(): MorphTo
|
|
||||||
{
|
|
||||||
if ($this->entity_type === '') {
|
|
||||||
$this->entity_type = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->morphTo('entity');
|
/**
|
||||||
|
* Get the loggable model related to this activity.
|
||||||
|
* Currently only used for entities (previously entity_[id/type] columns).
|
||||||
|
* Could be used for others but will need an audit of uses where assumed
|
||||||
|
* to be entities.
|
||||||
|
*/
|
||||||
|
public function loggable(): MorphTo
|
||||||
|
{
|
||||||
|
return $this->morphTo('loggable');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,8 +48,8 @@ class Activity extends Model
|
|||||||
|
|
||||||
public function jointPermissions(): HasMany
|
public function jointPermissions(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
|
return $this->hasMany(JointPermission::class, 'entity_id', 'loggable_id')
|
||||||
->whereColumn('activities.entity_type', '=', 'joint_permissions.entity_type');
|
->whereColumn('activities.loggable_type', '=', 'joint_permissions.entity_type');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,6 +75,6 @@ class Activity extends Model
|
|||||||
*/
|
*/
|
||||||
public function isSimilarTo(self $activityB): bool
|
public function isSimilarTo(self $activityB): bool
|
||||||
{
|
{
|
||||||
return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id];
|
return [$this->type, $this->loggable_type, $this->loggable_id] === [$activityB->type, $activityB->loggable_type, $activityB->loggable_id];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,46 +3,69 @@
|
|||||||
namespace BookStack\Activity\Models;
|
namespace BookStack\Activity\Models;
|
||||||
|
|
||||||
use BookStack\App\Model;
|
use BookStack\App\Model;
|
||||||
|
use BookStack\Permissions\Models\JointPermission;
|
||||||
|
use BookStack\Permissions\PermissionApplicator;
|
||||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||||
|
use BookStack\Users\Models\OwnableInterface;
|
||||||
|
use BookStack\Util\HtmlContentFilter;
|
||||||
|
use BookStack\Util\HtmlContentFilterConfig;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property string $text
|
|
||||||
* @property string $html
|
* @property string $html
|
||||||
* @property int|null $parent_id - Relates to local_id, not id
|
* @property int|null $parent_id - Relates to local_id, not id
|
||||||
* @property int $local_id
|
* @property int $local_id
|
||||||
* @property string $entity_type
|
* @property string $commentable_type
|
||||||
* @property int $entity_id
|
* @property int $commentable_id
|
||||||
* @property int $created_by
|
* @property string $content_ref
|
||||||
* @property int $updated_by
|
* @property bool $archived
|
||||||
*/
|
*/
|
||||||
class Comment extends Model implements Loggable
|
class Comment extends Model implements Loggable, OwnableInterface
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
use HasCreatorAndUpdater;
|
use HasCreatorAndUpdater;
|
||||||
|
|
||||||
protected $fillable = ['text', 'parent_id'];
|
protected $fillable = ['parent_id'];
|
||||||
protected $appends = ['created', 'updated'];
|
protected $hidden = ['html'];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'archived' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the entity that this comment belongs to.
|
* Get the entity that this comment belongs to.
|
||||||
*/
|
*/
|
||||||
public function entity(): MorphTo
|
public function entity(): MorphTo
|
||||||
{
|
{
|
||||||
return $this->morphTo('entity');
|
// We specifically define null here to avoid the different name (commentable)
|
||||||
|
// being used by Laravel eager loading instead of the method name, which it was doing
|
||||||
|
// in some scenarios like when deserialized when going through the queue system.
|
||||||
|
// So we instead specify the type and id column names to use.
|
||||||
|
// Related to:
|
||||||
|
// https://github.com/laravel/framework/pull/24815
|
||||||
|
// https://github.com/laravel/framework/issues/27342
|
||||||
|
// https://github.com/laravel/framework/issues/47953
|
||||||
|
// (and probably more)
|
||||||
|
|
||||||
|
// Ultimately, we could just align the method name to 'commentable' but that would be a potential
|
||||||
|
// breaking change and not really worthwhile in a patch due to the risk of creating extra problems.
|
||||||
|
return $this->morphTo(null, 'commentable_type', 'commentable_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the parent comment this is in reply to (if existing).
|
* Get the parent comment this is in reply to (if existing).
|
||||||
|
* @return BelongsTo<Comment, $this>
|
||||||
*/
|
*/
|
||||||
public function parent(): BelongsTo
|
public function parent(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
|
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
|
||||||
->where('entity_type', '=', $this->entity_type)
|
->where('commentable_type', '=', $this->commentable_type)
|
||||||
->where('entity_id', '=', $this->entity_id);
|
->where('commentable_id', '=', $this->commentable_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,24 +76,30 @@ class Comment extends Model implements Loggable
|
|||||||
return $this->updated_at->timestamp > $this->created_at->timestamp;
|
return $this->updated_at->timestamp > $this->created_at->timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get created date as a relative diff.
|
|
||||||
*/
|
|
||||||
public function getCreatedAttribute(): string
|
|
||||||
{
|
|
||||||
return $this->created_at->diffForHumans();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get updated date as a relative diff.
|
|
||||||
*/
|
|
||||||
public function getUpdatedAttribute(): string
|
|
||||||
{
|
|
||||||
return $this->updated_at->diffForHumans();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function logDescriptor(): string
|
public function logDescriptor(): string
|
||||||
{
|
{
|
||||||
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
|
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->commentable_type} (ID: {$this->commentable_id})";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function safeHtml(): string
|
||||||
|
{
|
||||||
|
$filter = new HtmlContentFilter(new HtmlContentFilterConfig());
|
||||||
|
return $filter->filterString($this->html ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jointPermissions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id')
|
||||||
|
->whereColumn('joint_permissions.entity_type', '=', 'comments.commentable_type');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope the query to just the comments visible to the user based upon the
|
||||||
|
* user visibility of what has been commented on.
|
||||||
|
*/
|
||||||
|
public function scopeVisible(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return app()->make(PermissionApplicator::class)
|
||||||
|
->restrictEntityRelationQuery($query, 'comments', 'commentable_id', 'commentable_type');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ namespace BookStack\Activity\Models;
|
|||||||
|
|
||||||
use BookStack\App\Model;
|
use BookStack\App\Model;
|
||||||
use BookStack\Permissions\Models\JointPermission;
|
use BookStack\Permissions\Models\JointPermission;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
class Favourite extends Model
|
class Favourite extends Model
|
||||||
{
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = ['user_id'];
|
protected $fillable = ['user_id'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
20
app/Activity/Models/MentionHistory.php
Normal file
20
app/Activity/Models/MentionHistory.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Activity\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property int $id
|
||||||
|
* @property string $mentionable_type
|
||||||
|
* @property int $mentionable_id
|
||||||
|
* @property int $from_user_id
|
||||||
|
* @property int $to_user_id
|
||||||
|
* @property Carbon $created_at
|
||||||
|
* @property Carbon $updated_at
|
||||||
|
*/
|
||||||
|
class MentionHistory extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'mention_history';
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
|
|||||||
* @property int $id
|
* @property int $id
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property string $value
|
* @property string $value
|
||||||
|
* @property int $entity_id
|
||||||
|
* @property string $entity_type
|
||||||
* @property int $order
|
* @property int $order
|
||||||
*/
|
*/
|
||||||
class Tag extends Model
|
class Tag extends Model
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace BookStack\Activity\Models;
|
|||||||
use BookStack\Activity\WatchLevels;
|
use BookStack\Activity\WatchLevels;
|
||||||
use BookStack\Permissions\Models\JointPermission;
|
use BookStack\Permissions\Models\JointPermission;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
@@ -20,6 +21,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
|
|||||||
*/
|
*/
|
||||||
class Watch extends Model
|
class Watch extends Model
|
||||||
{
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
protected $guarded = [];
|
protected $guarded = [];
|
||||||
|
|
||||||
public function watchable(): MorphTo
|
public function watchable(): MorphTo
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ namespace BookStack\Activity\Notifications\Handlers;
|
|||||||
use BookStack\Activity\Models\Loggable;
|
use BookStack\Activity\Models\Loggable;
|
||||||
use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
|
use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
|
use BookStack\Permissions\Permission;
|
||||||
use BookStack\Permissions\PermissionApplicator;
|
use BookStack\Permissions\PermissionApplicator;
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
abstract class BaseNotificationHandler implements NotificationHandler
|
abstract class BaseNotificationHandler implements NotificationHandler
|
||||||
{
|
{
|
||||||
@@ -18,6 +20,7 @@ abstract class BaseNotificationHandler implements NotificationHandler
|
|||||||
{
|
{
|
||||||
$users = User::query()->whereIn('id', array_unique($userIds))->get();
|
$users = User::query()->whereIn('id', array_unique($userIds))->get();
|
||||||
|
|
||||||
|
/** @var User $user */
|
||||||
foreach ($users as $user) {
|
foreach ($users as $user) {
|
||||||
// Prevent sending to the user that initiated the activity
|
// Prevent sending to the user that initiated the activity
|
||||||
if ($user->id === $initiator->id) {
|
if ($user->id === $initiator->id) {
|
||||||
@@ -25,7 +28,7 @@ abstract class BaseNotificationHandler implements NotificationHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prevent sending of the user does not have notification permissions
|
// Prevent sending of the user does not have notification permissions
|
||||||
if (!$user->can('receive-notifications')) {
|
if (!$user->can(Permission::ReceiveNotifications)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +39,11 @@ abstract class BaseNotificationHandler implements NotificationHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send the notification
|
// Send the notification
|
||||||
$user->notify(new $notification($detail, $initiator));
|
try {
|
||||||
|
$user->notify(new $notification($detail, $initiator));
|
||||||
|
} catch (\Exception $exception) {
|
||||||
|
Log::error("Failed to send email notification to user [id:{$user->id}] with error: {$exception->getMessage()}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
|
|||||||
$watcherIds = $watchers->getWatcherUserIds();
|
$watcherIds = $watchers->getWatcherUserIds();
|
||||||
|
|
||||||
// Page owner if user preferences allow
|
// Page owner if user preferences allow
|
||||||
if (!$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
|
if ($page->owned_by && !$watchers->isUserIgnoring($page->owned_by) && $page->ownedBy) {
|
||||||
$userNotificationPrefs = new UserNotificationPreferences($page->ownedBy);
|
$userNotificationPrefs = new UserNotificationPreferences($page->ownedBy);
|
||||||
if ($userNotificationPrefs->notifyOnOwnPageComments()) {
|
if ($userNotificationPrefs->notifyOnOwnPageComments()) {
|
||||||
$watcherIds[] = $page->owned_by;
|
$watcherIds[] = $page->owned_by;
|
||||||
@@ -36,7 +36,7 @@ class CommentCreationNotificationHandler extends BaseNotificationHandler
|
|||||||
|
|
||||||
// Parent comment creator if preferences allow
|
// Parent comment creator if preferences allow
|
||||||
$parentComment = $detail->parent()->first();
|
$parentComment = $detail->parent()->first();
|
||||||
if ($parentComment && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
|
if ($parentComment && $parentComment->created_by && !$watchers->isUserIgnoring($parentComment->created_by) && $parentComment->createdBy) {
|
||||||
$parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy);
|
$parentCommenterNotificationsPrefs = new UserNotificationPreferences($parentComment->createdBy);
|
||||||
if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) {
|
if ($parentCommenterNotificationsPrefs->notifyOnCommentReplies()) {
|
||||||
$watcherIds[] = $parentComment->created_by;
|
$watcherIds[] = $parentComment->created_by;
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Activity\Notifications\Handlers;
|
||||||
|
|
||||||
|
use BookStack\Activity\ActivityType;
|
||||||
|
use BookStack\Activity\Models\Activity;
|
||||||
|
use BookStack\Activity\Models\Comment;
|
||||||
|
use BookStack\Activity\Models\Loggable;
|
||||||
|
use BookStack\Activity\Models\MentionHistory;
|
||||||
|
use BookStack\Activity\Notifications\Messages\CommentMentionNotification;
|
||||||
|
use BookStack\Activity\Tools\MentionParser;
|
||||||
|
use BookStack\Entities\Models\Page;
|
||||||
|
use BookStack\Settings\UserNotificationPreferences;
|
||||||
|
use BookStack\Users\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class CommentMentionNotificationHandler extends BaseNotificationHandler
|
||||||
|
{
|
||||||
|
public function handle(Activity $activity, Loggable|string $detail, User $user): void
|
||||||
|
{
|
||||||
|
if (!($detail instanceof Comment) || !($detail->entity instanceof Page)) {
|
||||||
|
throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Page $page */
|
||||||
|
$page = $detail->entity;
|
||||||
|
|
||||||
|
$parser = new MentionParser();
|
||||||
|
$mentionedUserIds = $parser->parseUserIdsFromHtml($detail->html);
|
||||||
|
$realMentionedUsers = User::whereIn('id', $mentionedUserIds)->get();
|
||||||
|
|
||||||
|
$receivingNotifications = $realMentionedUsers->filter(function (User $user) {
|
||||||
|
$prefs = new UserNotificationPreferences($user);
|
||||||
|
return $prefs->notifyOnCommentMentions();
|
||||||
|
});
|
||||||
|
$receivingNotificationsUserIds = $receivingNotifications->pluck('id')->toArray();
|
||||||
|
|
||||||
|
$userMentionsToLog = $realMentionedUsers;
|
||||||
|
|
||||||
|
// When an edit, we check our history to see if we've already notified the user about this comment before
|
||||||
|
// so that we can filter them out to avoid double notifications.
|
||||||
|
if ($activity->type === ActivityType::COMMENT_UPDATE) {
|
||||||
|
$previouslyNotifiedUserIds = $this->getPreviouslyNotifiedUserIds($detail);
|
||||||
|
$receivingNotificationsUserIds = array_values(array_diff($receivingNotificationsUserIds, $previouslyNotifiedUserIds));
|
||||||
|
$userMentionsToLog = $userMentionsToLog->filter(function (User $user) use ($previouslyNotifiedUserIds) {
|
||||||
|
return !in_array($user->id, $previouslyNotifiedUserIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->logMentions($userMentionsToLog, $detail, $user);
|
||||||
|
$this->sendNotificationToUserIds(CommentMentionNotification::class, $receivingNotificationsUserIds, $user, $detail, $page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<User> $mentionedUsers
|
||||||
|
*/
|
||||||
|
protected function logMentions(Collection $mentionedUsers, Comment $comment, User $fromUser): void
|
||||||
|
{
|
||||||
|
$mentions = [];
|
||||||
|
$now = Carbon::now();
|
||||||
|
|
||||||
|
foreach ($mentionedUsers as $mentionedUser) {
|
||||||
|
$mentions[] = [
|
||||||
|
'mentionable_type' => $comment->getMorphClass(),
|
||||||
|
'mentionable_id' => $comment->id,
|
||||||
|
'from_user_id' => $fromUser->id,
|
||||||
|
'to_user_id' => $mentionedUser->id,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
MentionHistory::query()->insert($mentions);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getPreviouslyNotifiedUserIds(Comment $comment): array
|
||||||
|
{
|
||||||
|
return MentionHistory::query()
|
||||||
|
->where('mentionable_id', $comment->id)
|
||||||
|
->where('mentionable_type', $comment->getMorphClass())
|
||||||
|
->pluck('to_user_id')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,8 @@ class PageUpdateNotificationHandler extends BaseNotificationHandler
|
|||||||
throw new \InvalidArgumentException("Detail for page update notifications must be a page");
|
throw new \InvalidArgumentException("Detail for page update notifications must be a page");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get last update from activity
|
// Get the last update from activity
|
||||||
|
/** @var ?Activity $lastUpdate */
|
||||||
$lastUpdate = $detail->activity()
|
$lastUpdate = $detail->activity()
|
||||||
->where('type', '=', ActivityType::PAGE_UPDATE)
|
->where('type', '=', ActivityType::PAGE_UPDATE)
|
||||||
->where('id', '!=', $activity->id)
|
->where('id', '!=', $activity->id)
|
||||||
@@ -38,8 +39,8 @@ class PageUpdateNotificationHandler extends BaseNotificationHandler
|
|||||||
$watchers = new EntityWatchers($detail, WatchLevels::UPDATES);
|
$watchers = new EntityWatchers($detail, WatchLevels::UPDATES);
|
||||||
$watcherIds = $watchers->getWatcherUserIds();
|
$watcherIds = $watchers->getWatcherUserIds();
|
||||||
|
|
||||||
// Add page owner if preferences allow
|
// Add the page owner if preferences allow
|
||||||
if (!$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
|
if ($detail->owned_by && !$watchers->isUserIgnoring($detail->owned_by) && $detail->ownedBy) {
|
||||||
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
|
$userNotificationPrefs = new UserNotificationPreferences($detail->ownedBy);
|
||||||
if ($userNotificationPrefs->notifyOnOwnPageChanges()) {
|
if ($userNotificationPrefs->notifyOnOwnPageChanges()) {
|
||||||
$watcherIds[] = $detail->owned_by;
|
$watcherIds[] = $detail->owned_by;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ abstract class BaseActivityNotification extends MailNotification
|
|||||||
protected function buildReasonFooterLine(LocaleDefinition $locale): LinkedMailMessageLine
|
protected function buildReasonFooterLine(LocaleDefinition $locale): LinkedMailMessageLine
|
||||||
{
|
{
|
||||||
return new LinkedMailMessageLine(
|
return new LinkedMailMessageLine(
|
||||||
url('/preferences/notifications'),
|
url('/my-account/notifications'),
|
||||||
$locale->trans('notifications.footer_reason'),
|
$locale->trans('notifications.footer_reason'),
|
||||||
$locale->trans('notifications.footer_reason_link'),
|
$locale->trans('notifications.footer_reason_link'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Activity\Notifications\Messages;
|
||||||
|
|
||||||
|
use BookStack\Activity\Models\Comment;
|
||||||
|
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
|
||||||
|
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
|
||||||
|
use BookStack\Entities\Models\Page;
|
||||||
|
use BookStack\Users\Models\User;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
|
||||||
|
class CommentMentionNotification extends BaseActivityNotification
|
||||||
|
{
|
||||||
|
public function toMail(User $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
/** @var Comment $comment */
|
||||||
|
$comment = $this->detail;
|
||||||
|
/** @var Page $page */
|
||||||
|
$page = $comment->entity;
|
||||||
|
|
||||||
|
$locale = $notifiable->getLocale();
|
||||||
|
|
||||||
|
$listLines = array_filter([
|
||||||
|
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
||||||
|
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
||||||
|
$locale->trans('notifications.detail_commenter') => $this->user->name,
|
||||||
|
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->newMailMessage($locale)
|
||||||
|
->subject($locale->trans('notifications.comment_mention_subject', ['pageName' => $page->getShortName()]))
|
||||||
|
->line($locale->trans('notifications.comment_mention_intro', ['appName' => setting('app-name')]))
|
||||||
|
->line(new ListMessageLine($listLines))
|
||||||
|
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
|
||||||
|
->line($this->buildReasonFooterLine($locale));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType;
|
|||||||
use BookStack\Activity\Models\Activity;
|
use BookStack\Activity\Models\Activity;
|
||||||
use BookStack\Activity\Models\Loggable;
|
use BookStack\Activity\Models\Loggable;
|
||||||
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
|
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
|
||||||
|
use BookStack\Activity\Notifications\Handlers\CommentMentionNotificationHandler;
|
||||||
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
|
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
|
||||||
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
|
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
|
||||||
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
|
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
|
||||||
@@ -48,5 +49,7 @@ class NotificationManager
|
|||||||
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
|
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
|
||||||
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
|
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
|
||||||
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
|
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
|
||||||
|
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class);
|
||||||
|
$this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ class TagRepo
|
|||||||
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_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'),
|
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
|
||||||
])
|
])
|
||||||
->orderBy($sort, $listOptions->getOrder());
|
->orderBy($sort, $listOptions->getOrder())
|
||||||
|
->whereHas('entity');
|
||||||
|
|
||||||
if ($nameFilter) {
|
if ($nameFilter) {
|
||||||
$query->where('name', '=', $nameFilter);
|
$query->where('name', '=', $nameFilter);
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ class ActivityLogger
|
|||||||
$activity->detail = $detailToStore;
|
$activity->detail = $detailToStore;
|
||||||
|
|
||||||
if ($detail instanceof Entity) {
|
if ($detail instanceof Entity) {
|
||||||
$activity->entity_id = $detail->id;
|
$activity->loggable_id = $detail->id;
|
||||||
$activity->entity_type = $detail->getMorphClass();
|
$activity->loggable_type = $detail->getMorphClass();
|
||||||
}
|
}
|
||||||
|
|
||||||
$activity->save();
|
$activity->save();
|
||||||
@@ -64,9 +64,9 @@ class ActivityLogger
|
|||||||
public function removeEntity(Entity $entity): void
|
public function removeEntity(Entity $entity): void
|
||||||
{
|
{
|
||||||
$entity->activity()->update([
|
$entity->activity()->update([
|
||||||
'detail' => $entity->name,
|
'detail' => $entity->name,
|
||||||
'entity_id' => null,
|
'loggable_id' => null,
|
||||||
'entity_type' => null,
|
'loggable_type' => null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ namespace BookStack\Activity\Tools;
|
|||||||
|
|
||||||
use BookStack\Activity\Models\Comment;
|
use BookStack\Activity\Models\Comment;
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Entities\Models\Page;
|
||||||
|
use BookStack\Permissions\Permission;
|
||||||
|
|
||||||
class CommentTree
|
class CommentTree
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The built nested tree structure array.
|
* The built nested tree structure array.
|
||||||
* @var array{comment: Comment, depth: int, children: array}[]
|
* @var CommentTreeNode[]
|
||||||
*/
|
*/
|
||||||
protected array $tree;
|
protected array $tree;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A linear array of loaded comments.
|
||||||
|
* @var Comment[]
|
||||||
|
*/
|
||||||
protected array $comments;
|
protected array $comments;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -28,7 +34,7 @@ class CommentTree
|
|||||||
|
|
||||||
public function empty(): bool
|
public function empty(): bool
|
||||||
{
|
{
|
||||||
return count($this->tree) === 0;
|
return count($this->getActive()) === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function count(): int
|
public function count(): int
|
||||||
@@ -36,13 +42,59 @@ class CommentTree
|
|||||||
return count($this->comments);
|
return count($this->comments);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function get(): array
|
public function getActive(): array
|
||||||
{
|
{
|
||||||
return $this->tree;
|
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => !$node->comment->archived));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function activeThreadCount(): int
|
||||||
|
{
|
||||||
|
return count($this->getActive());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getArchived(): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter($this->tree, fn (CommentTreeNode $node) => $node->comment->archived));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function archivedThreadCount(): int
|
||||||
|
{
|
||||||
|
return count($this->getArchived());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCommentNodeForId(int $commentId): ?CommentTreeNode
|
||||||
|
{
|
||||||
|
foreach ($this->tree as $node) {
|
||||||
|
if ($node->comment->id === $commentId) {
|
||||||
|
return $node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canUpdateAny(): bool
|
||||||
|
{
|
||||||
|
foreach ($this->comments as $comment) {
|
||||||
|
if (userCan(Permission::CommentUpdate, $comment)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadVisibleHtml(): void
|
||||||
|
{
|
||||||
|
foreach ($this->comments as $comment) {
|
||||||
|
$comment->setAttribute('html', $comment->safeHtml());
|
||||||
|
$comment->makeVisible('html');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Comment[] $comments
|
* @param Comment[] $comments
|
||||||
|
* @return CommentTreeNode[]
|
||||||
*/
|
*/
|
||||||
protected function createTree(array $comments): array
|
protected function createTree(array $comments): array
|
||||||
{
|
{
|
||||||
@@ -66,28 +118,27 @@ class CommentTree
|
|||||||
|
|
||||||
$tree = [];
|
$tree = [];
|
||||||
foreach ($childMap[0] ?? [] as $childId) {
|
foreach ($childMap[0] ?? [] as $childId) {
|
||||||
$tree[] = $this->createTreeForId($childId, 0, $byId, $childMap);
|
$tree[] = $this->createTreeNodeForId($childId, 0, $byId, $childMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $tree;
|
return $tree;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function createTreeForId(int $id, int $depth, array &$byId, array &$childMap): array
|
protected function createTreeNodeForId(int $id, int $depth, array &$byId, array &$childMap): CommentTreeNode
|
||||||
{
|
{
|
||||||
$childIds = $childMap[$id] ?? [];
|
$childIds = $childMap[$id] ?? [];
|
||||||
$children = [];
|
$children = [];
|
||||||
|
|
||||||
foreach ($childIds as $childId) {
|
foreach ($childIds as $childId) {
|
||||||
$children[] = $this->createTreeForId($childId, $depth + 1, $byId, $childMap);
|
$children[] = $this->createTreeNodeForId($childId, $depth + 1, $byId, $childMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return new CommentTreeNode($byId[$id], $depth, $children);
|
||||||
'comment' => $byId[$id],
|
|
||||||
'depth' => $depth,
|
|
||||||
'children' => $children,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Comment[]
|
||||||
|
*/
|
||||||
protected function loadComments(): array
|
protected function loadComments(): array
|
||||||
{
|
{
|
||||||
if (!$this->enabled()) {
|
if (!$this->enabled()) {
|
||||||
|
|||||||
23
app/Activity/Tools/CommentTreeNode.php
Normal file
23
app/Activity/Tools/CommentTreeNode.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Activity\Tools;
|
||||||
|
|
||||||
|
use BookStack\Activity\Models\Comment;
|
||||||
|
|
||||||
|
class CommentTreeNode
|
||||||
|
{
|
||||||
|
public Comment $comment;
|
||||||
|
public int $depth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var CommentTreeNode[]
|
||||||
|
*/
|
||||||
|
public array $children;
|
||||||
|
|
||||||
|
public function __construct(Comment $comment, int $depth, array $children)
|
||||||
|
{
|
||||||
|
$this->comment = $comment;
|
||||||
|
$this->depth = $depth;
|
||||||
|
$this->children = $children;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Activity/Tools/MentionParser.php
Normal file
28
app/Activity/Tools/MentionParser.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\Activity\Tools;
|
||||||
|
|
||||||
|
use BookStack\Util\HtmlDocument;
|
||||||
|
use DOMElement;
|
||||||
|
|
||||||
|
class MentionParser
|
||||||
|
{
|
||||||
|
public function parseUserIdsFromHtml(string $html): array
|
||||||
|
{
|
||||||
|
$doc = new HtmlDocument($html);
|
||||||
|
|
||||||
|
$ids = [];
|
||||||
|
$mentionLinks = $doc->queryXPath('//a[@data-mention-user-id]');
|
||||||
|
|
||||||
|
foreach ($mentionLinks as $link) {
|
||||||
|
if ($link instanceof DOMElement) {
|
||||||
|
$id = intval($link->getAttribute('data-mention-user-id'));
|
||||||
|
if ($id > 0) {
|
||||||
|
$ids[] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,17 +3,16 @@
|
|||||||
namespace BookStack\Activity\Tools;
|
namespace BookStack\Activity\Tools;
|
||||||
|
|
||||||
use BookStack\Activity\Models\Tag;
|
use BookStack\Activity\Models\Tag;
|
||||||
|
use BookStack\Entities\Models\BookChild;
|
||||||
|
use BookStack\Entities\Models\Entity;
|
||||||
|
use BookStack\Entities\Models\Page;
|
||||||
|
use BookStack\Permissions\Permission;
|
||||||
|
|
||||||
class TagClassGenerator
|
class TagClassGenerator
|
||||||
{
|
{
|
||||||
protected array $tags;
|
public function __construct(
|
||||||
|
protected Entity $entity
|
||||||
/**
|
) {
|
||||||
* @param Tag[] $tags
|
|
||||||
*/
|
|
||||||
public function __construct(array $tags)
|
|
||||||
{
|
|
||||||
$this->tags = $tags;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,14 +21,23 @@ class TagClassGenerator
|
|||||||
public function generate(): array
|
public function generate(): array
|
||||||
{
|
{
|
||||||
$classes = [];
|
$classes = [];
|
||||||
|
$tags = $this->entity->tags->all();
|
||||||
|
|
||||||
foreach ($this->tags as $tag) {
|
foreach ($tags as $tag) {
|
||||||
$name = $this->normalizeTagClassString($tag->name);
|
array_push($classes, ...$this->generateClassesForTag($tag));
|
||||||
$value = $this->normalizeTagClassString($tag->value);
|
}
|
||||||
$classes[] = 'tag-name-' . $name;
|
|
||||||
if ($value) {
|
if ($this->entity instanceof BookChild && userCan(Permission::BookView, $this->entity->book)) {
|
||||||
$classes[] = 'tag-value-' . $value;
|
$bookTags = $this->entity->book->tags;
|
||||||
$classes[] = 'tag-pair-' . $name . '-' . $value;
|
foreach ($bookTags as $bookTag) {
|
||||||
|
array_push($classes, ...$this->generateClassesForTag($bookTag, 'book-'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->entity instanceof Page && $this->entity->chapter && userCan(Permission::ChapterView, $this->entity->chapter)) {
|
||||||
|
$chapterTags = $this->entity->chapter->tags;
|
||||||
|
foreach ($chapterTags as $chapterTag) {
|
||||||
|
array_push($classes, ...$this->generateClassesForTag($chapterTag, 'chapter-'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +49,22 @@ class TagClassGenerator
|
|||||||
return implode(' ', $this->generate());
|
return implode(' ', $this->generate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
protected function generateClassesForTag(Tag $tag, string $prefix = ''): array
|
||||||
|
{
|
||||||
|
$classes = [];
|
||||||
|
$name = $this->normalizeTagClassString($tag->name);
|
||||||
|
$value = $this->normalizeTagClassString($tag->value);
|
||||||
|
$classes[] = "{$prefix}tag-name-{$name}";
|
||||||
|
if ($value) {
|
||||||
|
$classes[] = "{$prefix}tag-value-{$value}";
|
||||||
|
$classes[] = "{$prefix}tag-pair-{$name}-{$value}";
|
||||||
|
}
|
||||||
|
return $classes;
|
||||||
|
}
|
||||||
|
|
||||||
protected function normalizeTagClassString(string $value): string
|
protected function normalizeTagClassString(string $value): string
|
||||||
{
|
{
|
||||||
$value = str_replace(' ', '', strtolower($value));
|
$value = str_replace(' ', '', strtolower($value));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use BookStack\Activity\WatchLevels;
|
|||||||
use BookStack\Entities\Models\BookChild;
|
use BookStack\Entities\Models\BookChild;
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Entities\Models\Page;
|
||||||
|
use BookStack\Permissions\Permission;
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ class UserEntityWatchOptions
|
|||||||
|
|
||||||
public function canWatch(): bool
|
public function canWatch(): bool
|
||||||
{
|
{
|
||||||
return $this->user->can('receive-notifications') && !$this->user->isGuest();
|
return $this->user->can(Permission::ReceiveNotifications) && !$this->user->isGuest();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getWatchLevel(): string
|
public function getWatchLevel(): string
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class WebhookFormatter
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($this->detail instanceof Model) {
|
if ($this->detail instanceof Model) {
|
||||||
$data['related_item'] = $this->formatModel();
|
$data['related_item'] = $this->formatModel($this->detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $data;
|
return $data;
|
||||||
@@ -83,10 +83,8 @@ class WebhookFormatter
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function formatModel(): array
|
protected function formatModel(Model $model): array
|
||||||
{
|
{
|
||||||
/** @var Model $model */
|
|
||||||
$model = $this->detail;
|
|
||||||
$model->unsetRelations();
|
$model->unsetRelations();
|
||||||
|
|
||||||
foreach ($this->modelFormatters as $formatter) {
|
foreach ($this->modelFormatters as $formatter) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class WatchLevels
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all the possible values as an option_name => value array.
|
* Get all the possible values as an option_name => value array.
|
||||||
* @returns array<string, int>
|
* @return array<string, int>
|
||||||
*/
|
*/
|
||||||
public static function all(): array
|
public static function all(): array
|
||||||
{
|
{
|
||||||
@@ -50,7 +50,7 @@ class WatchLevels
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the watch options suited for the given entity.
|
* Get the watch options suited for the given entity.
|
||||||
* @returns array<string, int>
|
* @return array<string, int>
|
||||||
*/
|
*/
|
||||||
public static function allSuitedFor(Entity $entity): array
|
public static function allSuitedFor(Entity $entity): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
namespace BookStack\Api;
|
namespace BookStack\Api;
|
||||||
|
|
||||||
|
use BookStack\App\AppVersion;
|
||||||
use BookStack\Http\ApiController;
|
use BookStack\Http\ApiController;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
@@ -26,7 +26,7 @@ class ApiDocsGenerator
|
|||||||
*/
|
*/
|
||||||
public static function generateConsideringCache(): Collection
|
public static function generateConsideringCache(): Collection
|
||||||
{
|
{
|
||||||
$appVersion = trim(file_get_contents(base_path('version')));
|
$appVersion = AppVersion::get();
|
||||||
$cacheKey = 'api-docs::' . $appVersion;
|
$cacheKey = 'api-docs::' . $appVersion;
|
||||||
$isProduction = config('app.env') === 'production';
|
$isProduction = config('app.env') === 'production';
|
||||||
$cacheVal = $isProduction ? Cache::get($cacheKey) : null;
|
$cacheVal = $isProduction ? Cache::get($cacheKey) : null;
|
||||||
@@ -83,11 +83,19 @@ class ApiDocsGenerator
|
|||||||
protected function loadDetailsFromControllers(Collection $routes): Collection
|
protected function loadDetailsFromControllers(Collection $routes): Collection
|
||||||
{
|
{
|
||||||
return $routes->map(function (array $route) {
|
return $routes->map(function (array $route) {
|
||||||
|
$class = $this->getReflectionClass($route['controller']);
|
||||||
$method = $this->getReflectionMethod($route['controller'], $route['controller_method']);
|
$method = $this->getReflectionMethod($route['controller'], $route['controller_method']);
|
||||||
$comment = $method->getDocComment();
|
$comment = $method->getDocComment();
|
||||||
$route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null;
|
$route['description'] = $comment ? $this->parseDescriptionFromDocBlockComment($comment) : null;
|
||||||
$route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']);
|
$route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']);
|
||||||
|
|
||||||
|
// Load class description for the model
|
||||||
|
// Not ideal to have it here on each route, but adding it in a more structured manner would break
|
||||||
|
// docs resulting JSON format and therefore be an API break.
|
||||||
|
// Save refactoring for a more significant set of changes.
|
||||||
|
$classComment = $class->getDocComment();
|
||||||
|
$route['model_description'] = $classComment ? $this->parseDescriptionFromDocBlockComment($classComment) : null;
|
||||||
|
|
||||||
return $route;
|
return $route;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -140,7 +148,7 @@ class ApiDocsGenerator
|
|||||||
/**
|
/**
|
||||||
* Parse out the description text from a class method comment.
|
* Parse out the description text from a class method comment.
|
||||||
*/
|
*/
|
||||||
protected function parseDescriptionFromMethodComment(string $comment): string
|
protected function parseDescriptionFromDocBlockComment(string $comment): string
|
||||||
{
|
{
|
||||||
$matches = [];
|
$matches = [];
|
||||||
preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches);
|
preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches);
|
||||||
@@ -155,6 +163,16 @@ class ApiDocsGenerator
|
|||||||
* @throws ReflectionException
|
* @throws ReflectionException
|
||||||
*/
|
*/
|
||||||
protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod
|
protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod
|
||||||
|
{
|
||||||
|
return $this->getReflectionClass($className)->getMethod($methodName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a reflection class from the given class name.
|
||||||
|
*
|
||||||
|
* @throws ReflectionException
|
||||||
|
*/
|
||||||
|
protected function getReflectionClass(string $className): ReflectionClass
|
||||||
{
|
{
|
||||||
$class = $this->reflectionClasses[$className] ?? null;
|
$class = $this->reflectionClasses[$className] ?? null;
|
||||||
if ($class === null) {
|
if ($class === null) {
|
||||||
@@ -162,7 +180,7 @@ class ApiDocsGenerator
|
|||||||
$this->reflectionClasses[$className] = $class;
|
$this->reflectionClasses[$className] = $class;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $class->getMethod($methodName);
|
return $class;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace BookStack\Api;
|
namespace BookStack\Api;
|
||||||
|
|
||||||
|
use BookStack\Entities\Models\BookChild;
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
|
use BookStack\Entities\Models\Page;
|
||||||
|
|
||||||
class ApiEntityListFormatter
|
class ApiEntityListFormatter
|
||||||
{
|
{
|
||||||
@@ -20,8 +22,16 @@ class ApiEntityListFormatter
|
|||||||
* @var array<string|int, string|callable>
|
* @var array<string|int, string|callable>
|
||||||
*/
|
*/
|
||||||
protected array $fields = [
|
protected array $fields = [
|
||||||
'id', 'name', 'slug', 'book_id', 'chapter_id', 'draft',
|
'id',
|
||||||
'template', 'priority', 'created_at', 'updated_at',
|
'name',
|
||||||
|
'slug',
|
||||||
|
'book_id',
|
||||||
|
'chapter_id',
|
||||||
|
'draft',
|
||||||
|
'template',
|
||||||
|
'priority',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function __construct(array $list)
|
public function __construct(array $list)
|
||||||
@@ -62,6 +72,28 @@ class ApiEntityListFormatter
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Include parent book/chapter info in the formatted data.
|
||||||
|
*/
|
||||||
|
public function withParents(): self
|
||||||
|
{
|
||||||
|
$this->withField('book', function (Entity $entity) {
|
||||||
|
if ($entity instanceof BookChild && $entity->book) {
|
||||||
|
return $entity->book->only(['id', 'name', 'slug']);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->withField('chapter', function (Entity $entity) {
|
||||||
|
if ($entity instanceof Page && $entity->chapter) {
|
||||||
|
return $entity->chapter->only(['id', 'name', 'slug']);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format the data and return an array of formatted content.
|
* Format the data and return an array of formatted content.
|
||||||
* @return array[]
|
* @return array[]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace BookStack\Api;
|
|||||||
|
|
||||||
use BookStack\Access\LoginService;
|
use BookStack\Access\LoginService;
|
||||||
use BookStack\Exceptions\ApiAuthException;
|
use BookStack\Exceptions\ApiAuthException;
|
||||||
|
use BookStack\Permissions\Permission;
|
||||||
use Illuminate\Auth\GuardHelpers;
|
use Illuminate\Auth\GuardHelpers;
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
use Illuminate\Contracts\Auth\Guard;
|
use Illuminate\Contracts\Auth\Guard;
|
||||||
@@ -146,7 +147,7 @@ class ApiTokenGuard implements Guard
|
|||||||
throw new ApiAuthException(trans('errors.api_user_token_expired'), 403);
|
throw new ApiAuthException(trans('errors.api_user_token_expired'), 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$token->user->can('access-api')) {
|
if (!$token->user->can(Permission::AccessApi)) {
|
||||||
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
|
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace BookStack\Api;
|
|||||||
|
|
||||||
use BookStack\Activity\ActivityType;
|
use BookStack\Activity\ActivityType;
|
||||||
use BookStack\Http\Controller;
|
use BookStack\Http\Controller;
|
||||||
|
use BookStack\Permissions\Permission;
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
@@ -16,8 +17,8 @@ class UserApiTokenController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function create(Request $request, int $userId)
|
public function create(Request $request, int $userId)
|
||||||
{
|
{
|
||||||
$this->checkPermission('access-api');
|
$this->checkPermission(Permission::AccessApi);
|
||||||
$this->checkPermissionOrCurrentUser('users-manage', $userId);
|
$this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId);
|
||||||
$this->updateContext($request);
|
$this->updateContext($request);
|
||||||
|
|
||||||
$user = User::query()->findOrFail($userId);
|
$user = User::query()->findOrFail($userId);
|
||||||
@@ -35,8 +36,8 @@ class UserApiTokenController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function store(Request $request, int $userId)
|
public function store(Request $request, int $userId)
|
||||||
{
|
{
|
||||||
$this->checkPermission('access-api');
|
$this->checkPermission(Permission::AccessApi);
|
||||||
$this->checkPermissionOrCurrentUser('users-manage', $userId);
|
$this->checkPermissionOrCurrentUser(Permission::UsersManage, $userId);
|
||||||
|
|
||||||
$this->validate($request, [
|
$this->validate($request, [
|
||||||
'name' => ['required', 'max:250'],
|
'name' => ['required', 'max:250'],
|
||||||
@@ -143,8 +144,8 @@ class UserApiTokenController extends Controller
|
|||||||
*/
|
*/
|
||||||
protected function checkPermissionAndFetchUserToken(int $userId, int $tokenId): array
|
protected function checkPermissionAndFetchUserToken(int $userId, int $tokenId): array
|
||||||
{
|
{
|
||||||
$this->checkPermissionOr('users-manage', function () use ($userId) {
|
$this->checkPermissionOr(Permission::UsersManage, function () use ($userId) {
|
||||||
return $userId === user()->id && userCan('access-api');
|
return $userId === user()->id && userCan(Permission::AccessApi);
|
||||||
});
|
});
|
||||||
|
|
||||||
$user = User::query()->findOrFail($userId);
|
$user = User::query()->findOrFail($userId);
|
||||||
|
|||||||
24
app/App/AppVersion.php
Normal file
24
app/App/AppVersion.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\App;
|
||||||
|
|
||||||
|
class AppVersion
|
||||||
|
{
|
||||||
|
protected static string $version = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the application's version number from its top-level `version` text file.
|
||||||
|
*/
|
||||||
|
public static function get(): string
|
||||||
|
{
|
||||||
|
if (!empty(static::$version)) {
|
||||||
|
return static::$version;
|
||||||
|
}
|
||||||
|
|
||||||
|
$versionFile = base_path('version');
|
||||||
|
$version = trim(file_get_contents($versionFile));
|
||||||
|
static::$version = $version;
|
||||||
|
|
||||||
|
return $version;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,32 +3,36 @@
|
|||||||
namespace BookStack\App;
|
namespace BookStack\App;
|
||||||
|
|
||||||
use BookStack\Activity\ActivityQueries;
|
use BookStack\Activity\ActivityQueries;
|
||||||
use BookStack\Entities\Models\Book;
|
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Entities\Models\Page;
|
||||||
use BookStack\Entities\Queries\RecentlyViewed;
|
use BookStack\Entities\Queries\EntityQueries;
|
||||||
use BookStack\Entities\Queries\TopFavourites;
|
use BookStack\Entities\Queries\QueryRecentlyViewed;
|
||||||
use BookStack\Entities\Repos\BookRepo;
|
use BookStack\Entities\Queries\QueryTopFavourites;
|
||||||
use BookStack\Entities\Repos\BookshelfRepo;
|
|
||||||
use BookStack\Entities\Tools\PageContent;
|
use BookStack\Entities\Tools\PageContent;
|
||||||
use BookStack\Http\Controller;
|
use BookStack\Http\Controller;
|
||||||
use BookStack\Uploads\FaviconHandler;
|
|
||||||
use BookStack\Util\SimpleListOptions;
|
use BookStack\Util\SimpleListOptions;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class HomeController extends Controller
|
class HomeController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected EntityQueries $queries,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display the homepage.
|
* Display the homepage.
|
||||||
*/
|
*/
|
||||||
public function index(Request $request, ActivityQueries $activities)
|
public function index(
|
||||||
{
|
Request $request,
|
||||||
|
ActivityQueries $activities,
|
||||||
|
QueryRecentlyViewed $recentlyViewed,
|
||||||
|
QueryTopFavourites $topFavourites,
|
||||||
|
) {
|
||||||
$activity = $activities->latest(10);
|
$activity = $activities->latest(10);
|
||||||
$draftPages = [];
|
$draftPages = [];
|
||||||
|
|
||||||
if ($this->isSignedIn()) {
|
if ($this->isSignedIn()) {
|
||||||
$draftPages = Page::visible()
|
$draftPages = $this->queries->pages->currentUserDraftsForList()
|
||||||
->where('draft', '=', true)
|
|
||||||
->where('created_by', '=', user()->id)
|
|
||||||
->orderBy('updated_at', 'desc')
|
->orderBy('updated_at', 'desc')
|
||||||
->with('book')
|
->with('book')
|
||||||
->take(6)
|
->take(6)
|
||||||
@@ -37,14 +41,13 @@ class HomeController extends Controller
|
|||||||
|
|
||||||
$recentFactor = count($draftPages) > 0 ? 0.5 : 1;
|
$recentFactor = count($draftPages) > 0 ? 0.5 : 1;
|
||||||
$recents = $this->isSignedIn() ?
|
$recents = $this->isSignedIn() ?
|
||||||
(new RecentlyViewed())->run(12 * $recentFactor, 1)
|
$recentlyViewed->run(12 * $recentFactor, 1)
|
||||||
: Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
|
: $this->queries->books->visibleForList()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
|
||||||
$favourites = (new TopFavourites())->run(6);
|
$favourites = $topFavourites->run(6);
|
||||||
$recentlyUpdatedPages = Page::visible()->with('book')
|
$recentlyUpdatedPages = $this->queries->pages->visibleForList()
|
||||||
->where('draft', false)
|
->where('draft', false)
|
||||||
->orderBy('updated_at', 'desc')
|
->orderBy('updated_at', 'desc')
|
||||||
->take($favourites->count() > 0 ? 5 : 10)
|
->take($favourites->count() > 0 ? 5 : 10)
|
||||||
->select(Page::$listAttributes)
|
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$homepageOptions = ['default', 'books', 'bookshelves', 'page'];
|
$homepageOptions = ['default', 'books', 'bookshelves', 'page'];
|
||||||
@@ -78,14 +81,18 @@ class HomeController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($homepageOption === 'bookshelves') {
|
if ($homepageOption === 'bookshelves') {
|
||||||
$shelves = app()->make(BookshelfRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
|
$shelves = $this->queries->shelves->visibleForListWithCover()
|
||||||
|
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
||||||
|
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
|
||||||
$data = array_merge($commonData, ['shelves' => $shelves]);
|
$data = array_merge($commonData, ['shelves' => $shelves]);
|
||||||
|
|
||||||
return view('home.shelves', $data);
|
return view('home.shelves', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($homepageOption === 'books') {
|
if ($homepageOption === 'books') {
|
||||||
$books = app()->make(BookRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
|
$books = $this->queries->books->visibleForListWithCover()
|
||||||
|
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
||||||
|
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
|
||||||
$data = array_merge($commonData, ['books' => $books]);
|
$data = array_merge($commonData, ['books' => $books]);
|
||||||
|
|
||||||
return view('home.books', $data);
|
return view('home.books', $data);
|
||||||
@@ -95,7 +102,7 @@ class HomeController extends Controller
|
|||||||
$homepageSetting = setting('app-homepage', '0:');
|
$homepageSetting = setting('app-homepage', '0:');
|
||||||
$id = intval(explode(':', $homepageSetting)[0]);
|
$id = intval(explode(':', $homepageSetting)[0]);
|
||||||
/** @var Page $customHomepage */
|
/** @var Page $customHomepage */
|
||||||
$customHomepage = Page::query()->where('draft', '=', false)->findOrFail($id);
|
$customHomepage = $this->queries->pages->start()->where('draft', '=', false)->findOrFail($id);
|
||||||
$pageContent = new PageContent($customHomepage);
|
$pageContent = new PageContent($customHomepage);
|
||||||
$customHomepage->html = $pageContent->render(false);
|
$customHomepage->html = $pageContent->render(false);
|
||||||
|
|
||||||
@@ -104,48 +111,4 @@ class HomeController extends Controller
|
|||||||
|
|
||||||
return view('home.default', $commonData);
|
return view('home.default', $commonData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the view for /robots.txt.
|
|
||||||
*/
|
|
||||||
public function robots()
|
|
||||||
{
|
|
||||||
$sitePublic = setting('app-public', false);
|
|
||||||
$allowRobots = config('app.allow_robots');
|
|
||||||
|
|
||||||
if ($allowRobots === null) {
|
|
||||||
$allowRobots = $sitePublic;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()
|
|
||||||
->view('misc.robots', ['allowRobots' => $allowRobots])
|
|
||||||
->header('Content-Type', 'text/plain');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show the route for 404 responses.
|
|
||||||
*/
|
|
||||||
public function notFound()
|
|
||||||
{
|
|
||||||
return response()->view('errors.404', [], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serve the application favicon.
|
|
||||||
* Ensures a 'favicon.ico' file exists at the web root location (if writable) to be served
|
|
||||||
* directly by the webserver in the future.
|
|
||||||
*/
|
|
||||||
public function favicon(FaviconHandler $favicons)
|
|
||||||
{
|
|
||||||
$exists = $favicons->restoreOriginalIfNotExists();
|
|
||||||
return response()->file($exists ? $favicons->getPath() : $favicons->getOriginalPath());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serve a PWA application manifest.
|
|
||||||
*/
|
|
||||||
public function pwaManifest(PwaManifestBuilder $manifestBuilder)
|
|
||||||
{
|
|
||||||
return response()->json($manifestBuilder->build());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
77
app/App/MetaController.php
Normal file
77
app/App/MetaController.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\App;
|
||||||
|
|
||||||
|
use BookStack\Http\Controller;
|
||||||
|
use BookStack\Uploads\FaviconHandler;
|
||||||
|
|
||||||
|
class MetaController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the view for /robots.txt.
|
||||||
|
*/
|
||||||
|
public function robots()
|
||||||
|
{
|
||||||
|
$sitePublic = setting('app-public', false);
|
||||||
|
$allowRobots = config('app.allow_robots');
|
||||||
|
|
||||||
|
if ($allowRobots === null) {
|
||||||
|
$allowRobots = $sitePublic;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()
|
||||||
|
->view('misc.robots', ['allowRobots' => $allowRobots])
|
||||||
|
->header('Content-Type', 'text/plain');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the route for 404 responses.
|
||||||
|
*/
|
||||||
|
public function notFound()
|
||||||
|
{
|
||||||
|
return response()->view('errors.404', [], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve the application favicon.
|
||||||
|
* Ensures a 'favicon.ico' file exists at the web root location (if writable) to be served
|
||||||
|
* directly by the webserver in the future.
|
||||||
|
*/
|
||||||
|
public function favicon(FaviconHandler $favicons)
|
||||||
|
{
|
||||||
|
$exists = $favicons->restoreOriginalIfNotExists();
|
||||||
|
return response()->file($exists ? $favicons->getPath() : $favicons->getOriginalPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve a PWA application manifest.
|
||||||
|
*/
|
||||||
|
public function pwaManifest(PwaManifestBuilder $manifestBuilder)
|
||||||
|
{
|
||||||
|
return response()->json($manifestBuilder->build());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show license information for the application.
|
||||||
|
*/
|
||||||
|
public function licenses()
|
||||||
|
{
|
||||||
|
$this->setPageTitle(trans('settings.licenses'));
|
||||||
|
|
||||||
|
return view('help.licenses', [
|
||||||
|
'license' => file_get_contents(base_path('LICENSE')),
|
||||||
|
'phpLibData' => file_get_contents(base_path('dev/licensing/php-library-licenses.txt')),
|
||||||
|
'jsLibData' => file_get_contents(base_path('dev/licensing/js-library-licenses.txt')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the view for /opensearch.xml.
|
||||||
|
*/
|
||||||
|
public function opensearch()
|
||||||
|
{
|
||||||
|
return response()
|
||||||
|
->view('misc.opensearch')
|
||||||
|
->header('Content-Type', 'application/opensearchdescription+xml');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ class Model extends EloquentModel
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Provides public access to get the raw attribute value from the model.
|
* Provides public access to get the raw attribute value from the model.
|
||||||
* Used in areas where no mutations are required but performance is critical.
|
* Used in areas where no mutations are required, but performance is critical.
|
||||||
*
|
*
|
||||||
* @return mixed
|
* @return mixed
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace BookStack\App\Providers;
|
namespace BookStack\App\Providers;
|
||||||
|
|
||||||
use BookStack\Access\SocialDriverManager;
|
use BookStack\Access\SocialDriverManager;
|
||||||
|
use BookStack\Activity\Models\Comment;
|
||||||
use BookStack\Activity\Tools\ActivityLogger;
|
use BookStack\Activity\Tools\ActivityLogger;
|
||||||
use BookStack\Entities\Models\Book;
|
use BookStack\Entities\Models\Book;
|
||||||
use BookStack\Entities\Models\Bookshelf;
|
use BookStack\Entities\Models\Bookshelf;
|
||||||
@@ -25,7 +26,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
* Custom container bindings to register.
|
* Custom container bindings to register.
|
||||||
* @var string[]
|
* @var string[]
|
||||||
*/
|
*/
|
||||||
public $bindings = [
|
public array $bindings = [
|
||||||
ExceptionRenderer::class => BookStackExceptionHandlerPage::class,
|
ExceptionRenderer::class => BookStackExceptionHandlerPage::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
* Custom singleton bindings to register.
|
* Custom singleton bindings to register.
|
||||||
* @var string[]
|
* @var string[]
|
||||||
*/
|
*/
|
||||||
public $singletons = [
|
public array $singletons = [
|
||||||
'activity' => ActivityLogger::class,
|
'activity' => ActivityLogger::class,
|
||||||
SettingService::class => SettingService::class,
|
SettingService::class => SettingService::class,
|
||||||
SocialDriverManager::class => SocialDriverManager::class,
|
SocialDriverManager::class => SocialDriverManager::class,
|
||||||
@@ -42,11 +43,19 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bootstrap any application services.
|
* Register any application services.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function boot()
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->app->singleton(PermissionApplicator::class, function ($app) {
|
||||||
|
return new PermissionApplicator(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
{
|
{
|
||||||
// Set root URL
|
// Set root URL
|
||||||
$appUrl = config('app.url');
|
$appUrl = config('app.url');
|
||||||
@@ -65,18 +74,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
'book' => Book::class,
|
'book' => Book::class,
|
||||||
'chapter' => Chapter::class,
|
'chapter' => Chapter::class,
|
||||||
'page' => Page::class,
|
'page' => Page::class,
|
||||||
|
'comment' => Comment::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Register any application services.
|
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function register()
|
|
||||||
{
|
|
||||||
$this->app->singleton(PermissionApplicator::class, function ($app) {
|
|
||||||
return new PermissionApplicator(null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,8 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Bootstrap the application services.
|
* Bootstrap the application services.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function boot()
|
public function boot(): void
|
||||||
{
|
{
|
||||||
// Password Configuration
|
// Password Configuration
|
||||||
// Changes here must be reflected in ApiDocsGenerate@getValidationAsString.
|
// Changes here must be reflected in ApiDocsGenerate@getValidationAsString.
|
||||||
@@ -58,13 +56,11 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Register the application services.
|
* Register the application services.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function register()
|
public function register(): void
|
||||||
{
|
{
|
||||||
Auth::provider('external-users', function ($app, array $config) {
|
Auth::provider('external-users', function () {
|
||||||
return new ExternalBaseUserProvider($config['model']);
|
return new ExternalBaseUserProvider();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bind and provide the default system user as a singleton to the app instance when needed.
|
// Bind and provide the default system user as a singleton to the app instance when needed.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class EventServiceProvider extends ServiceProvider
|
|||||||
/**
|
/**
|
||||||
* The event listener mappings for the application.
|
* The event listener mappings for the application.
|
||||||
*
|
*
|
||||||
* @var array<class-string, array<int, class-string>>
|
* @var array<class-string, array<int, string>>
|
||||||
*/
|
*/
|
||||||
protected $listen = [
|
protected $listen = [
|
||||||
SocialiteWasCalled::class => [
|
SocialiteWasCalled::class => [
|
||||||
@@ -29,21 +29,25 @@ class EventServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Register any events for your application.
|
* Register any events for your application.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function boot()
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
//
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine if events and listeners should be automatically discovered.
|
* Determine if events and listeners should be automatically discovered.
|
||||||
*
|
|
||||||
* @return bool
|
|
||||||
*/
|
*/
|
||||||
public function shouldDiscoverEvents()
|
public function shouldDiscoverEvents(): bool
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overrides the registration of Laravel's default email verification system
|
||||||
|
*/
|
||||||
|
protected function configureEmailVerification(): void
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,8 @@ class RouteServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Define your route model bindings, pattern filters, etc.
|
* Define your route model bindings, pattern filters, etc.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function boot()
|
public function boot(): void
|
||||||
{
|
{
|
||||||
$this->configureRateLimiting();
|
$this->configureRateLimiting();
|
||||||
|
|
||||||
@@ -41,10 +39,8 @@ class RouteServiceProvider extends ServiceProvider
|
|||||||
* Define the "web" routes for the application.
|
* Define the "web" routes for the application.
|
||||||
*
|
*
|
||||||
* These routes all receive session state, CSRF protection, etc.
|
* These routes all receive session state, CSRF protection, etc.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
protected function mapWebRoutes()
|
protected function mapWebRoutes(): void
|
||||||
{
|
{
|
||||||
Route::group([
|
Route::group([
|
||||||
'middleware' => 'web',
|
'middleware' => 'web',
|
||||||
@@ -65,10 +61,8 @@ class RouteServiceProvider extends ServiceProvider
|
|||||||
* Define the "api" routes for the application.
|
* Define the "api" routes for the application.
|
||||||
*
|
*
|
||||||
* These routes are typically stateless.
|
* These routes are typically stateless.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
protected function mapApiRoutes()
|
protected function mapApiRoutes(): void
|
||||||
{
|
{
|
||||||
Route::group([
|
Route::group([
|
||||||
'middleware' => 'api',
|
'middleware' => 'api',
|
||||||
@@ -81,13 +75,22 @@ class RouteServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure the rate limiters for the application.
|
* Configure the rate limiters for the application.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
protected function configureRateLimiting()
|
protected function configureRateLimiting(): void
|
||||||
{
|
{
|
||||||
RateLimiter::for('api', function (Request $request) {
|
RateLimiter::for('api', function (Request $request) {
|
||||||
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
|
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('public', function (Request $request) {
|
||||||
|
return Limit::perMinute(10)->by($request->ip());
|
||||||
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('exports', function (Request $request) {
|
||||||
|
$user = user();
|
||||||
|
$attempts = $user->isGuest() ? 4 : 10;
|
||||||
|
$key = $user->isGuest() ? $request->ip() : $user->id;
|
||||||
|
return Limit::perMinute($attempts)->by($key);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,17 +4,16 @@ namespace BookStack\App\Providers;
|
|||||||
|
|
||||||
use BookStack\Theming\ThemeEvents;
|
use BookStack\Theming\ThemeEvents;
|
||||||
use BookStack\Theming\ThemeService;
|
use BookStack\Theming\ThemeService;
|
||||||
use Illuminate\Support\Facades\Route;
|
use BookStack\Theming\ThemeViews;
|
||||||
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class ThemeServiceProvider extends ServiceProvider
|
class ThemeServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Register services.
|
* Register services.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function register()
|
public function register(): void
|
||||||
{
|
{
|
||||||
// Register the ThemeService as a singleton
|
// Register the ThemeService as a singleton
|
||||||
$this->app->singleton(ThemeService::class, fn ($app) => new ThemeService());
|
$this->app->singleton(ThemeService::class, fn ($app) => new ThemeService());
|
||||||
@@ -22,14 +21,31 @@ class ThemeServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Bootstrap services.
|
* Bootstrap services.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function boot()
|
public function boot(): void
|
||||||
{
|
{
|
||||||
// Boot up the theme system
|
// Boot up the theme system
|
||||||
$themeService = $this->app->make(ThemeService::class);
|
$themeService = $this->app->make(ThemeService::class);
|
||||||
|
$viewFactory = $this->app->make('view');
|
||||||
|
$themeViews = new ThemeViews($viewFactory->getFinder());
|
||||||
|
|
||||||
|
// Use a custom include so that we can insert theme views before/after includes.
|
||||||
|
// This is done, even if no theme is active, so that view caching does not create problems
|
||||||
|
// when switching between themes or when switching a theme on/off.
|
||||||
|
$viewFactory->share('__themeViews', $themeViews);
|
||||||
|
Blade::directive('include', function ($expression) {
|
||||||
|
return "<?php echo \$__themeViews->handleViewInclude({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>";
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!$themeService->getTheme()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$themeService->loadModules();
|
||||||
$themeService->readThemeActions();
|
$themeService->readThemeActions();
|
||||||
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
|
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
|
||||||
|
|
||||||
|
$themeViews->registerViewPathsForTheme($themeService->getModules());
|
||||||
|
$themeService->dispatch(ThemeEvents::THEME_REGISTER_VIEWS, $themeViews);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ class TranslationServiceProvider extends BaseProvider
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Register the service provider.
|
* Register the service provider.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function register()
|
public function register(): void
|
||||||
{
|
{
|
||||||
$this->registerLoader();
|
$this->registerLoader();
|
||||||
|
|
||||||
@@ -41,10 +39,8 @@ class TranslationServiceProvider extends BaseProvider
|
|||||||
/**
|
/**
|
||||||
* Register the translation line loader.
|
* Register the translation line loader.
|
||||||
* Overrides the default register action from Laravel so a custom loader can be used.
|
* Overrides the default register action from Laravel so a custom loader can be used.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
protected function registerLoader()
|
protected function registerLoader(): void
|
||||||
{
|
{
|
||||||
$this->app->singleton('translation.loader', function ($app) {
|
$this->app->singleton('translation.loader', function ($app) {
|
||||||
return new FileLoader($app['files'], $app['path.lang']);
|
return new FileLoader($app['files'], $app['path.lang']);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace BookStack\App\Providers;
|
namespace BookStack\App\Providers;
|
||||||
|
|
||||||
use BookStack\Entities\BreadcrumbsViewComposer;
|
use BookStack\Entities\BreadcrumbsViewComposer;
|
||||||
|
use BookStack\Util\DateFormatter;
|
||||||
use Illuminate\Pagination\Paginator;
|
use Illuminate\Pagination\Paginator;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\Facades\View;
|
use Illuminate\Support\Facades\View;
|
||||||
@@ -10,12 +11,19 @@ use Illuminate\Support\ServiceProvider;
|
|||||||
|
|
||||||
class ViewTweaksServiceProvider extends ServiceProvider
|
class ViewTweaksServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
|
public function register()
|
||||||
|
{
|
||||||
|
$this->app->singleton(DateFormatter::class, function ($app) {
|
||||||
|
return new DateFormatter(
|
||||||
|
$app['config']->get('app.display_timezone'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bootstrap services.
|
* Bootstrap services.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function boot()
|
public function boot(): void
|
||||||
{
|
{
|
||||||
// Set paginator to use bootstrap-style pagination
|
// Set paginator to use bootstrap-style pagination
|
||||||
Paginator::useBootstrap();
|
Paginator::useBootstrap();
|
||||||
@@ -23,6 +31,9 @@ class ViewTweaksServiceProvider extends ServiceProvider
|
|||||||
// View Composers
|
// View Composers
|
||||||
View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class);
|
View::composer('entities.breadcrumbs', BreadcrumbsViewComposer::class);
|
||||||
|
|
||||||
|
// View Globals
|
||||||
|
View::share('dates', $this->app->make(DateFormatter::class));
|
||||||
|
|
||||||
// Custom blade view directives
|
// Custom blade view directives
|
||||||
Blade::directive('icon', function ($expression) {
|
Blade::directive('icon', function ($expression) {
|
||||||
return "<?php echo (new \BookStack\Util\SvgIcon($expression))->toHtml(); ?>";
|
return "<?php echo (new \BookStack\Util\SvgIcon($expression))->toHtml(); ?>";
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class PwaManifestBuilder
|
|||||||
"launch_handler" => [
|
"launch_handler" => [
|
||||||
"client_mode" => "focus-existing"
|
"client_mode" => "focus-existing"
|
||||||
],
|
],
|
||||||
"orientation" => "portrait",
|
"orientation" => "any",
|
||||||
"icons" => [
|
"icons" => [
|
||||||
[
|
[
|
||||||
"src" => setting('app-icon-32') ?: url('/icon-32.png'),
|
"src" => setting('app-icon-32') ?: url('/icon-32.png'),
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace BookStack\App;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assigned to models that can have slugs.
|
|
||||||
* Must have the below properties.
|
|
||||||
*
|
|
||||||
* @property int $id
|
|
||||||
* @property string $name
|
|
||||||
*/
|
|
||||||
interface Sluggable
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Regenerate the slug for this model.
|
|
||||||
*/
|
|
||||||
public function refreshSlug(): string;
|
|
||||||
}
|
|
||||||
13
app/App/SluggableInterface.php
Normal file
13
app/App/SluggableInterface.php
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\App;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigned to models that can have slugs.
|
||||||
|
* Must have the below properties.
|
||||||
|
*
|
||||||
|
* @property string $slug
|
||||||
|
*/
|
||||||
|
interface SluggableInterface
|
||||||
|
{
|
||||||
|
}
|
||||||
31
app/App/SystemApiController.php
Normal file
31
app/App/SystemApiController.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace BookStack\App;
|
||||||
|
|
||||||
|
use BookStack\Http\ApiController;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class SystemApiController extends ApiController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Read details regarding the BookStack instance.
|
||||||
|
* Some details may be null where not set, like the app logo for example.
|
||||||
|
*/
|
||||||
|
public function read(): JsonResponse
|
||||||
|
{
|
||||||
|
$logoSetting = setting('app-logo', '');
|
||||||
|
if ($logoSetting === 'none') {
|
||||||
|
$logo = null;
|
||||||
|
} else {
|
||||||
|
$logo = $logoSetting ? url($logoSetting) : url('/logo.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'version' => AppVersion::get(),
|
||||||
|
'instance_id' => setting('instance-id'),
|
||||||
|
'app_name' => setting('app-name'),
|
||||||
|
'app_logo' => $logo,
|
||||||
|
'base_url' => url('/'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use BookStack\App\AppVersion;
|
||||||
use BookStack\App\Model;
|
use BookStack\App\Model;
|
||||||
|
use BookStack\Facades\Theme;
|
||||||
|
use BookStack\Permissions\Permission;
|
||||||
use BookStack\Permissions\PermissionApplicator;
|
use BookStack\Permissions\PermissionApplicator;
|
||||||
use BookStack\Settings\SettingService;
|
use BookStack\Settings\SettingService;
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
@@ -12,12 +15,7 @@ use BookStack\Users\Models\User;
|
|||||||
*/
|
*/
|
||||||
function versioned_asset(string $file = ''): string
|
function versioned_asset(string $file = ''): string
|
||||||
{
|
{
|
||||||
static $version = null;
|
$version = AppVersion::get();
|
||||||
|
|
||||||
if (is_null($version)) {
|
|
||||||
$versionFile = base_path('version');
|
|
||||||
$version = trim(file_get_contents($versionFile));
|
|
||||||
}
|
|
||||||
|
|
||||||
$additional = '';
|
$additional = '';
|
||||||
if (config('app.env') === 'development') {
|
if (config('app.env') === 'development') {
|
||||||
@@ -42,9 +40,9 @@ function user(): User
|
|||||||
* Check if the current user has a permission. If an ownable element
|
* Check if the current user has a permission. If an ownable element
|
||||||
* is passed in the jointPermissions are checked against that particular item.
|
* is passed in the jointPermissions are checked against that particular item.
|
||||||
*/
|
*/
|
||||||
function userCan(string $permission, Model $ownable = null): bool
|
function userCan(string|Permission $permission, ?Model $ownable = null): bool
|
||||||
{
|
{
|
||||||
if ($ownable === null) {
|
if (is_null($ownable)) {
|
||||||
return user()->can($permission);
|
return user()->can($permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +56,7 @@ function userCan(string $permission, Model $ownable = null): bool
|
|||||||
* Check if the current user can perform the given action on any items in the system.
|
* Check if the current user can perform the given action on any items in the system.
|
||||||
* Can be provided the class name of an entity to filter ability to that specific entity type.
|
* Can be provided the class name of an entity to filter ability to that specific entity type.
|
||||||
*/
|
*/
|
||||||
function userCanOnAny(string $action, string $entityClass = ''): bool
|
function userCanOnAny(string|Permission $action, string $entityClass = ''): bool
|
||||||
{
|
{
|
||||||
$permissions = app()->make(PermissionApplicator::class);
|
$permissions = app()->make(PermissionApplicator::class);
|
||||||
|
|
||||||
@@ -70,7 +68,7 @@ function userCanOnAny(string $action, string $entityClass = ''): bool
|
|||||||
*
|
*
|
||||||
* @return mixed|SettingService
|
* @return mixed|SettingService
|
||||||
*/
|
*/
|
||||||
function setting(string $key = null, $default = null)
|
function setting(?string $key = null, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
$settingService = app()->make(SettingService::class);
|
$settingService = app()->make(SettingService::class);
|
||||||
|
|
||||||
@@ -83,48 +81,14 @@ function setting(string $key = null, $default = null)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a path to a theme resource.
|
* Get a path to a theme resource.
|
||||||
* Returns null if a theme is not configured and
|
* Returns null if a theme is not configured, and therefore a full path is not available for use.
|
||||||
* therefore a full path is not available for use.
|
|
||||||
*/
|
*/
|
||||||
function theme_path(string $path = ''): ?string
|
function theme_path(string $path = ''): ?string
|
||||||
{
|
{
|
||||||
$theme = config('view.theme');
|
$theme = Theme::getTheme();
|
||||||
|
|
||||||
if (!$theme) {
|
if (!$theme) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path));
|
return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a URL with multiple parameters for sorting purposes.
|
|
||||||
* Works out the logic to set the correct sorting direction
|
|
||||||
* Discards empty parameters and allows overriding.
|
|
||||||
*/
|
|
||||||
function sortUrl(string $path, array $data, array $overrideData = []): string
|
|
||||||
{
|
|
||||||
$queryStringSections = [];
|
|
||||||
$queryData = array_merge($data, $overrideData);
|
|
||||||
|
|
||||||
// Change sorting direction is already sorted on current attribute
|
|
||||||
if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) {
|
|
||||||
$queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc';
|
|
||||||
} elseif (isset($overrideData['sort'])) {
|
|
||||||
$queryData['order'] = 'asc';
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($queryData as $name => $value) {
|
|
||||||
$trimmedVal = trim($value);
|
|
||||||
if ($trimmedVal === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$queryStringSections[] = urlencode($name) . '=' . urlencode($trimmedVal);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count($queryStringSections) === 0) {
|
|
||||||
return url($path);
|
|
||||||
}
|
|
||||||
|
|
||||||
return url($path . '?' . implode('&', $queryStringSections));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Facade;
|
use Illuminate\Support\Facades\Facade;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
@@ -36,10 +37,15 @@ return [
|
|||||||
// The limit for all uploaded files, including images and attachments in MB.
|
// The limit for all uploaded files, including images and attachments in MB.
|
||||||
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
|
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
|
||||||
|
|
||||||
// Allow <script> tags to entered within page content.
|
// Control the behaviour of content filtering, primarily used for page content.
|
||||||
// <script> tags are escaped by default.
|
// This setting is a string of characters which represent different available filters:
|
||||||
// Even when overridden the WYSIWYG editor may still escape script content.
|
// - j - Filter out JavaScript and unknown binary data based content
|
||||||
'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false),
|
// - h - Filter out unexpected, and potentially dangerous, HTML elements
|
||||||
|
// - f - Filter out unexpected form elements
|
||||||
|
// - a - Run content through a more complex allowlist filter
|
||||||
|
// This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
|
||||||
|
// Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
|
||||||
|
'content_filtering' => env('APP_CONTENT_FILTERING', env('ALLOW_CONTENT_SCRIPTS', false) === true ? '' : 'jhfa'),
|
||||||
|
|
||||||
// Allow server-side fetches to be performed to potentially unknown
|
// Allow server-side fetches to be performed to potentially unknown
|
||||||
// and user-provided locations. Primarily used in exports when loading
|
// and user-provided locations. Primarily used in exports when loading
|
||||||
@@ -47,8 +53,8 @@ return [
|
|||||||
'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
|
'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
|
||||||
|
|
||||||
// Override the default behaviour for allowing crawlers to crawl the instance.
|
// Override the default behaviour for allowing crawlers to crawl the instance.
|
||||||
// May be ignored if view has be overridden or modified.
|
// May be ignored if the underlying view has been overridden or modified.
|
||||||
// Defaults to null since, if not set, 'app-public' status used instead.
|
// Defaults to null in which case the 'app-public' status is used instead.
|
||||||
'allow_robots' => env('ALLOW_ROBOTS', null),
|
'allow_robots' => env('ALLOW_ROBOTS', null),
|
||||||
|
|
||||||
// Application Base URL, Used by laravel in development commands
|
// Application Base URL, Used by laravel in development commands
|
||||||
@@ -69,8 +75,8 @@ return [
|
|||||||
// A list of the sources/hostnames that can be reached by application SSR calls.
|
// A list of the sources/hostnames that can be reached by application SSR calls.
|
||||||
// This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
|
// This is used wherever users can provide URLs/hosts in-platform, like for webhooks.
|
||||||
// Host-specific functionality (usually controlled via other options) like auth
|
// Host-specific functionality (usually controlled via other options) like auth
|
||||||
// or user avatars for example, won't use this list.
|
// or user avatars, for example, won't use this list.
|
||||||
// Space seperated if multiple. Can use '*' as a wildcard.
|
// Space separated if multiple. Can use '*' as a wildcard.
|
||||||
// Values will be compared prefix-matched, case-insensitive, against called SSR urls.
|
// Values will be compared prefix-matched, case-insensitive, against called SSR urls.
|
||||||
// Defaults to allow all hosts.
|
// Defaults to allow all hosts.
|
||||||
'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'),
|
'ssr_hosts' => env('ALLOWED_SSR_HOSTS', '*'),
|
||||||
@@ -79,8 +85,10 @@ return [
|
|||||||
// Integer value between 0 (IP hidden) to 4 (Full IP usage)
|
// Integer value between 0 (IP hidden) to 4 (Full IP usage)
|
||||||
'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),
|
'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),
|
||||||
|
|
||||||
// Application timezone for back-end date functions.
|
// Application timezone for stored date/time values.
|
||||||
'timezone' => env('APP_TIMEZONE', 'UTC'),
|
'timezone' => env('APP_TIMEZONE', 'UTC'),
|
||||||
|
// Application timezone for displayed date/time values in the UI.
|
||||||
|
'display_timezone' => env('APP_DISPLAY_TIMEZONE', env('APP_TIMEZONE', 'UTC')),
|
||||||
|
|
||||||
// Default locale to use
|
// Default locale to use
|
||||||
// A default variant is also stored since Laravel can overwrite
|
// A default variant is also stored since Laravel can overwrite
|
||||||
@@ -113,46 +121,20 @@ return [
|
|||||||
],
|
],
|
||||||
|
|
||||||
// Application Service Providers
|
// Application Service Providers
|
||||||
'providers' => [
|
'providers' => ServiceProvider::defaultProviders()->merge([
|
||||||
|
|
||||||
// Laravel Framework Service Providers...
|
|
||||||
Illuminate\Auth\AuthServiceProvider::class,
|
|
||||||
Illuminate\Broadcasting\BroadcastServiceProvider::class,
|
|
||||||
Illuminate\Bus\BusServiceProvider::class,
|
|
||||||
Illuminate\Cache\CacheServiceProvider::class,
|
|
||||||
Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
|
|
||||||
Illuminate\Cookie\CookieServiceProvider::class,
|
|
||||||
Illuminate\Database\DatabaseServiceProvider::class,
|
|
||||||
Illuminate\Encryption\EncryptionServiceProvider::class,
|
|
||||||
Illuminate\Filesystem\FilesystemServiceProvider::class,
|
|
||||||
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
|
|
||||||
Illuminate\Hashing\HashServiceProvider::class,
|
|
||||||
Illuminate\Mail\MailServiceProvider::class,
|
|
||||||
Illuminate\Notifications\NotificationServiceProvider::class,
|
|
||||||
Illuminate\Pagination\PaginationServiceProvider::class,
|
|
||||||
Illuminate\Pipeline\PipelineServiceProvider::class,
|
|
||||||
Illuminate\Queue\QueueServiceProvider::class,
|
|
||||||
Illuminate\Redis\RedisServiceProvider::class,
|
|
||||||
Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
|
|
||||||
Illuminate\Session\SessionServiceProvider::class,
|
|
||||||
Illuminate\Validation\ValidationServiceProvider::class,
|
|
||||||
Illuminate\View\ViewServiceProvider::class,
|
|
||||||
|
|
||||||
// Third party service providers
|
// Third party service providers
|
||||||
Barryvdh\DomPDF\ServiceProvider::class,
|
|
||||||
Barryvdh\Snappy\ServiceProvider::class,
|
|
||||||
SocialiteProviders\Manager\ServiceProvider::class,
|
SocialiteProviders\Manager\ServiceProvider::class,
|
||||||
|
|
||||||
// BookStack custom service providers
|
// BookStack custom service providers
|
||||||
\BookStack\App\Providers\ThemeServiceProvider::class,
|
BookStack\App\Providers\ThemeServiceProvider::class,
|
||||||
\BookStack\App\Providers\AppServiceProvider::class,
|
BookStack\App\Providers\AppServiceProvider::class,
|
||||||
\BookStack\App\Providers\AuthServiceProvider::class,
|
BookStack\App\Providers\AuthServiceProvider::class,
|
||||||
\BookStack\App\Providers\EventServiceProvider::class,
|
BookStack\App\Providers\EventServiceProvider::class,
|
||||||
\BookStack\App\Providers\RouteServiceProvider::class,
|
BookStack\App\Providers\RouteServiceProvider::class,
|
||||||
\BookStack\App\Providers\TranslationServiceProvider::class,
|
BookStack\App\Providers\TranslationServiceProvider::class,
|
||||||
\BookStack\App\Providers\ValidationRuleServiceProvider::class,
|
BookStack\App\Providers\ValidationRuleServiceProvider::class,
|
||||||
\BookStack\App\Providers\ViewTweaksServiceProvider::class,
|
BookStack\App\Providers\ViewTweaksServiceProvider::class,
|
||||||
],
|
])->toArray(),
|
||||||
|
|
||||||
// Class Aliases
|
// Class Aliases
|
||||||
// This array of class aliases to be registered on application start.
|
// This array of class aliases to be registered on application start.
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcasting configuration options.
|
|
||||||
*
|
|
||||||
* Changes to these config files are not supported by BookStack and may break upon updates.
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
return [
|
|
||||||
|
|
||||||
// Default Broadcaster
|
|
||||||
// This option controls the default broadcaster that will be used by the
|
|
||||||
// framework when an event needs to be broadcast. This can be set to
|
|
||||||
// any of the connections defined in the "connections" array below.
|
|
||||||
'default' => 'null',
|
|
||||||
|
|
||||||
// Broadcast Connections
|
|
||||||
// Here you may define all of the broadcast connections that will be used
|
|
||||||
// to broadcast events to other systems or over websockets. Samples of
|
|
||||||
// each available type of connection are provided inside this array.
|
|
||||||
'connections' => [
|
|
||||||
|
|
||||||
// Default options removed since we don't use broadcasting.
|
|
||||||
|
|
||||||
'log' => [
|
|
||||||
'driver' => 'log',
|
|
||||||
],
|
|
||||||
|
|
||||||
'null' => [
|
|
||||||
'driver' => 'null',
|
|
||||||
],
|
|
||||||
|
|
||||||
],
|
|
||||||
|
|
||||||
];
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user