mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-04-20 10:57:35 +08:00
Compare commits
647 Commits
v23.10.4
...
developmen
Author | SHA1 | Date | |
---|---|---|---|
![]() |
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 | ||
![]() |
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 | ||
![]() |
b191d8f99f | ||
![]() |
c017f5bed1 | ||
![]() |
5b1929a39a | ||
![]() |
02d94c8798 | ||
![]() |
88ee33ee49 | ||
![]() |
529f7bd1bc | ||
![]() |
3668949705 | ||
![]() |
7cd0629a75 | ||
![]() |
fb3cfaf7c7 | ||
![]() |
2a7a81e749 | ||
![]() |
00ae04e0bd | ||
![]() |
ed5d67e609 | ||
![]() |
a21ca44633 | ||
![]() |
7fd6d5b2cc | ||
![]() |
077b9709d4 | ||
![]() |
2fbed3919b | ||
![]() |
c07aa056c2 | ||
![]() |
bc354e8b12 | ||
![]() |
307fae39c4 | ||
![]() |
c622b785a9 | ||
![]() |
569542f0bb | ||
![]() |
fc2e8ed315 | ||
![]() |
0c4dd7874c | ||
![]() |
7250671889 | ||
![]() |
5395ca2f00 | ||
![]() |
c76d12d1de | ||
![]() |
56d07f1909 | ||
![]() |
4896c4047f | ||
![]() |
3af07addf6 | ||
![]() |
2f3806244c | ||
![]() |
2081a783f3 | ||
![]() |
d75eb06777 | ||
![]() |
4017048555 | ||
![]() |
7ebe7d4e58 | ||
![]() |
d61f42a377 | ||
![]() |
968bc8cdf3 | ||
![]() |
c13fd2a9e6 | ||
![]() |
45ce7a7126 | ||
![]() |
11955e270c | ||
![]() |
33374524bf | ||
![]() |
8cbaa3e27c | ||
![]() |
4c0b7f3123 | ||
![]() |
7312300d53 | ||
![]() |
81d256aebd | ||
![]() |
a72e0fee70 | ||
![]() |
f32cfb4292 | ||
![]() |
bba7dcce49 | ||
![]() |
cc10d1ddfc | ||
![]() |
0254527bd9 | ||
![]() |
11853361b0 | ||
![]() |
596f7314cd | ||
![]() |
1011d61713 | ||
![]() |
652d5417bf | ||
![]() |
b569827114 | ||
![]() |
71c93c8878 | ||
![]() |
4874dc1304 | ||
![]() |
c88eb729a4 | ||
![]() |
75936454cc | ||
![]() |
04d21c8a97 | ||
![]() |
22a9cf1e48 | ||
![]() |
37a17e858a | ||
![]() |
eab9c1081e | ||
![]() |
db7b11fe93 | ||
![]() |
3a6f50e668 | ||
![]() |
76417efd6f | ||
![]() |
d41fd7a8dd | ||
![]() |
65ac197be4 | ||
![]() |
f8ebbb7553 | ||
![]() |
a0942ef441 | ||
![]() |
6b55104ecb | ||
![]() |
ac519b3009 | ||
![]() |
ec3b06d83f | ||
![]() |
99ae759eff | ||
![]() |
1dbc3588cf | ||
![]() |
3599a962a3 |
@ -56,6 +56,7 @@ APP_PROXIES=null
|
||||
|
||||
# Database details
|
||||
# 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_PORT=3306
|
||||
DB_DATABASE=database_database
|
||||
@ -215,10 +216,11 @@ LDAP_SERVER=false
|
||||
LDAP_BASE_DN=false
|
||||
LDAP_DN=false
|
||||
LDAP_PASS=false
|
||||
LDAP_USER_FILTER=false
|
||||
LDAP_USER_FILTER="(&(uid={user}))"
|
||||
LDAP_VERSION=false
|
||||
LDAP_START_TLS=false
|
||||
LDAP_TLS_INSECURE=false
|
||||
LDAP_TLS_CA_CERT=false
|
||||
LDAP_ID_ATTRIBUTE=uid
|
||||
LDAP_EMAIL_ATTRIBUTE=mail
|
||||
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
|
||||
@ -267,12 +269,14 @@ OIDC_ISSUER_DISCOVER=false
|
||||
OIDC_PUBLIC_KEY=null
|
||||
OIDC_AUTH_ENDPOINT=null
|
||||
OIDC_TOKEN_ENDPOINT=null
|
||||
OIDC_USERINFO_ENDPOINT=null
|
||||
OIDC_ADDITIONAL_SCOPES=null
|
||||
OIDC_DUMP_USER_DETAILS=false
|
||||
OIDC_USER_TO_GROUPS=false
|
||||
OIDC_GROUPS_CLAIM=groups
|
||||
OIDC_REMOVE_FROM_GROUPS=false
|
||||
OIDC_EXTERNAL_ID_CLAIM=sub
|
||||
OIDC_END_SESSION_ENDPOINT=false
|
||||
|
||||
# Disable default third-party services such as Gravatar and Draw.IO
|
||||
# Service-specific options will override this option
|
||||
@ -323,6 +327,19 @@ FILE_UPLOAD_SIZE_LIMIT=50
|
||||
# Can be 'a4' or 'letter'.
|
||||
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.
|
||||
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
|
||||
# When false, BookStack will attempt to find a wkhtmltopdf in the application
|
||||
|
121
.github/translators.txt
vendored
121
.github/translators.txt
vendored
@ -141,7 +141,7 @@ Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
|
||||
MatthieuParis :: French
|
||||
Douradinho :: Portuguese, Brazilian; Portuguese
|
||||
Gaku Yaguchi (tama11) :: Japanese
|
||||
johnroyer :: Chinese Traditional
|
||||
Zero Huang (johnroyer) :: Chinese Traditional
|
||||
jackaaa :: Chinese Traditional
|
||||
Irfan Hukama Arsyad (IrfanArsyad) :: Indonesian
|
||||
Jeff Huang (s8321414) :: Chinese Traditional
|
||||
@ -177,7 +177,7 @@ Alexander Predl (Harveyhase68) :: German
|
||||
Rem (Rem9000) :: Dutch
|
||||
Michał Stelmach (stelmach-web) :: Polish
|
||||
arniom :: French
|
||||
REMOVED_USER :: French; Dutch; Turkish;
|
||||
REMOVED_USER :: French; Dutch; Portuguese, Brazilian; Portuguese; Turkish;
|
||||
林祖年 (contagion) :: Chinese Traditional
|
||||
Siamak Guodarzi (siamakgoudarzi88) :: Persian
|
||||
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
|
||||
@ -324,7 +324,7 @@ Robin Flikkema (RobinFlikkema) :: Dutch
|
||||
Michal Gurcik (mgurcik) :: Slovak
|
||||
Pooyan Arab (pooyanarab) :: Persian
|
||||
Ochi Darma Putra (troke12) :: Indonesian
|
||||
H.-H. Peng (Hsins) :: Chinese Traditional
|
||||
Hsin-Hsiang Peng (Hsins) :: Chinese Traditional
|
||||
Mosi Wang (mosiwang) :: Chinese Traditional
|
||||
骆言 (LawssssCat) :: Chinese Simplified
|
||||
Stickers Gaming Shøw (StickerSGSHOW) :: French
|
||||
@ -347,7 +347,7 @@ Taygun Yıldırım (yildirimtaygun) :: Turkish
|
||||
robing29 :: German
|
||||
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
|
||||
Igor V Belousov (biv) :: Russian
|
||||
David Bauer (davbauer) :: German
|
||||
David Bauer (davbauer) :: German; German Informal
|
||||
Guttorm Hveem (guttormhveem) :: Norwegian Nynorsk; Norwegian Bokmal
|
||||
Minh Giang Truong (minhgiang1204) :: Vietnamese
|
||||
Ioannis Ioannides (i.ioannides) :: Greek
|
||||
@ -371,3 +371,116 @@ LameeQS :: Latvian
|
||||
Sorin T. (trimbitassorin) :: Romanian
|
||||
poesty :: Chinese Simplified
|
||||
balmag :: Hungarian
|
||||
Antti-Jussi Nygård (ajnyga) :: Finnish
|
||||
Eduard Ereza Martínez (Ereza) :: Catalan
|
||||
Jabir Lang (amar.almrad) :: Arabic
|
||||
Jaroslav Kobližek (foretix) :: Czech; French
|
||||
Wiktor Adamczyk (adamczyk.wiktor) :: Polish
|
||||
Abdulmajeed Alshuaibi (4Majeed) :: Arabic
|
||||
NotSmartZakk :: Czech
|
||||
HyoungMin Lee (ddokkaebi) :: Korean
|
||||
Dasferco :: Chinese Simplified
|
||||
Marcus Teräs (mteras) :: Finnish
|
||||
Serkan Yardim (serkanzz) :: Turkish
|
||||
Y (cnsr) :: Ukrainian
|
||||
ZY ZV (vy0b0x) :: Chinese Simplified
|
||||
diegobenitez :: Spanish
|
||||
Marc Hagen (MarcHagen) :: Dutch
|
||||
Kasper Alsøe (zeonos) :: Danish
|
||||
sultani :: Persian
|
||||
renge :: Korean
|
||||
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
|
||||
Илья Скаба (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
|
||||
|
10
.github/workflows/analyse-php.yml
vendored
10
.github/workflows/analyse-php.yml
vendored
@ -11,14 +11,14 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.1
|
||||
php-version: 8.3
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
||||
|
||||
- name: Get Composer Cache Directory
|
||||
@ -27,10 +27,10 @@ jobs:
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-8.1
|
||||
key: ${{ runner.os }}-composer-8.3
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Install composer dependencies
|
||||
|
4
.github/workflows/lint-js.yml
vendored
4
.github/workflows/lint-js.yml
vendored
@ -13,9 +13,9 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install NPM deps
|
||||
run: npm ci
|
||||
|
6
.github/workflows/lint-php.yml
vendored
6
.github/workflows/lint-php.yml
vendored
@ -11,14 +11,14 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.1
|
||||
php-version: 8.3
|
||||
tools: phpcs
|
||||
|
||||
- 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:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.0', '8.1', '8.2', '8.3']
|
||||
php: ['8.2', '8.3', '8.4']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
@ -32,7 +32,7 @@ jobs:
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
|
8
.github/workflows/test-php.yml
vendored
8
.github/workflows/test-php.yml
vendored
@ -13,12 +13,12 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.0', '8.1', '8.2', '8.3']
|
||||
php: ['8.2', '8.3', '8.4']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
@ -32,7 +32,7 @@ jobs:
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
|
11
.gitignore
vendored
11
.gitignore
vendored
@ -2,15 +2,16 @@
|
||||
/node_modules
|
||||
/.vscode
|
||||
/composer
|
||||
/coverage
|
||||
Homestead.yaml
|
||||
.env
|
||||
.idea
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/public/dist/*.map
|
||||
/public/dist
|
||||
/public/plugins
|
||||
/public/css/*.map
|
||||
/public/js/*.map
|
||||
/public/css
|
||||
/public/js
|
||||
/public/bower
|
||||
/public/build/
|
||||
/public/favicon.ico
|
||||
@ -29,4 +30,6 @@ webpack-stats.json
|
||||
.phpunit.result.cache
|
||||
.DS_Store
|
||||
phpstan.neon
|
||||
esbuild-meta.json
|
||||
esbuild-meta.json
|
||||
.phpactor.json
|
||||
/*.zip
|
||||
|
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-2023, Dan Brown and the BookStack Project contributors.
|
||||
Copyright (c) 2015-2025, Dan Brown and the BookStack project contributors.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
@ -32,13 +32,17 @@ class ConfirmEmailController extends Controller
|
||||
|
||||
/**
|
||||
* 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()
|
||||
{
|
||||
$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.
|
||||
*/
|
||||
public function resend(Request $request)
|
||||
public function resend()
|
||||
{
|
||||
$this->validate($request, [
|
||||
'email' => ['required', 'email', 'exists:users,email'],
|
||||
]);
|
||||
$user = $this->userRepo->getByEmail($request->get('email'));
|
||||
$user = $this->loginService->getLastLoginAttemptUser();
|
||||
if ($user === null) {
|
||||
$this->showErrorNotification(trans('errors.login_user_not_found'));
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->emailConfirmationService->sendConfirmation($user);
|
||||
} catch (ConfirmationEmailException $e) {
|
||||
$this->showErrorNotification($e->getMessage());
|
||||
|
||||
return redirect('/login');
|
||||
} catch (Exception $e) {
|
||||
$this->showErrorNotification(trans('auth.email_confirm_send_error'));
|
||||
|
||||
return redirect('/register/confirm');
|
||||
return redirect('/register/awaiting');
|
||||
}
|
||||
|
||||
$this->showSuccessNotification(trans('auth.email_confirm_resent'));
|
||||
|
@ -6,14 +6,10 @@ use BookStack\Activity\ActivityType;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Sleep;
|
||||
|
||||
class ForgotPasswordController extends Controller
|
||||
{
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('guest');
|
||||
@ -30,10 +26,6 @@ class ForgotPasswordController extends Controller
|
||||
|
||||
/**
|
||||
* Send a reset link to the given user.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
*
|
||||
* @return \Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
public function sendResetLinkEmail(Request $request)
|
||||
{
|
||||
@ -41,6 +33,10 @@ class ForgotPasswordController extends Controller
|
||||
'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
|
||||
// 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.
|
||||
@ -56,13 +52,13 @@ class ForgotPasswordController extends Controller
|
||||
$message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
|
||||
$this->showSuccessNotification($message);
|
||||
|
||||
return back()->with('status', trans($response));
|
||||
return redirect('/password/email')->with('status', trans($response));
|
||||
}
|
||||
|
||||
// If an error was returned by the password broker, we will get this message
|
||||
// translated so we can notify a user of the problem. We'll redirect back
|
||||
// to where the users came from so they can attempt this process again.
|
||||
return back()->withErrors(
|
||||
return redirect('/password/email')->withErrors(
|
||||
['email' => trans($response)]
|
||||
);
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ trait HandlesPartialLogins
|
||||
$user = auth()->user() ?? $loginService->getLastLoginAttemptUser();
|
||||
|
||||
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;
|
||||
|
@ -3,34 +3,26 @@
|
||||
namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Access\SocialAuthService;
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
use ThrottlesLogins;
|
||||
|
||||
protected SocialAuthService $socialAuthService;
|
||||
protected LoginService $loginService;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct(SocialAuthService $socialAuthService, LoginService $loginService)
|
||||
{
|
||||
public function __construct(
|
||||
protected SocialDriverManager $socialDriverManager,
|
||||
protected LoginService $loginService,
|
||||
) {
|
||||
$this->middleware('guest', ['only' => ['getLogin', 'login']]);
|
||||
$this->middleware('guard:standard,ldap', ['only' => ['login']]);
|
||||
$this->middleware('guard:standard,ldap,oidc', ['only' => ['logout']]);
|
||||
|
||||
$this->socialAuthService = $socialAuthService;
|
||||
$this->loginService = $loginService;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -38,7 +30,7 @@ class LoginController extends Controller
|
||||
*/
|
||||
public function getLogin(Request $request)
|
||||
{
|
||||
$socialDrivers = $this->socialAuthService->getActiveDrivers();
|
||||
$socialDrivers = $this->socialDriverManager->getActive();
|
||||
$authMethod = config('auth.method');
|
||||
$preventInitiation = $request->get('prevent_auto_init') === 'true';
|
||||
|
||||
@ -52,7 +44,7 @@ class LoginController extends Controller
|
||||
// Store the previous location for redirect after login
|
||||
$this->updateIntendedFromPrevious();
|
||||
|
||||
if (!$preventInitiation && $this->shouldAutoInitiate()) {
|
||||
if (!$preventInitiation && $this->loginService->shouldAutoInitiate()) {
|
||||
return view('auth.login-initiate', [
|
||||
'authMethod' => $authMethod,
|
||||
]);
|
||||
@ -101,15 +93,9 @@ class LoginController extends Controller
|
||||
/**
|
||||
* Logout user and perform subsequent redirect.
|
||||
*/
|
||||
public function logout(Request $request)
|
||||
public function logout()
|
||||
{
|
||||
Auth::guard()->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
$redirectUri = $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
|
||||
|
||||
return redirect($redirectUri);
|
||||
return redirect($this->loginService->logout());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -200,7 +186,7 @@ class LoginController extends Controller
|
||||
{
|
||||
// Store the previous location for redirect after login
|
||||
$previous = url()->previous('');
|
||||
$isPreviousFromInstance = (strpos($previous, url('/')) === 0);
|
||||
$isPreviousFromInstance = str_starts_with($previous, url('/'));
|
||||
if (!$previous || !setting('app-public') || !$isPreviousFromInstance) {
|
||||
return;
|
||||
}
|
||||
@ -211,23 +197,11 @@ class LoginController extends Controller
|
||||
];
|
||||
|
||||
foreach ($ignorePrefixList as $ignorePrefix) {
|
||||
if (strpos($previous, url($ignorePrefix)) === 0) {
|
||||
if (str_starts_with($previous, url($ignorePrefix))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
redirect()->setIntendedUrl($previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if login auto-initiate should be valid based upon authentication config.
|
||||
*/
|
||||
protected function shouldAutoInitiate(): bool
|
||||
{
|
||||
$socialDrivers = $this->socialAuthService->getActiveDrivers();
|
||||
$authMethod = config('auth.method');
|
||||
$autoRedirect = config('auth.auto_initiate');
|
||||
|
||||
return $autoRedirect && count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
|
||||
}
|
||||
}
|
||||
|
@ -19,20 +19,25 @@ class MfaTotpController extends Controller
|
||||
|
||||
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.
|
||||
*/
|
||||
public function generate(TotpService $totp)
|
||||
public function generate()
|
||||
{
|
||||
if (session()->has(static::SETUP_SECRET_SESSION_KEY)) {
|
||||
$totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY));
|
||||
} else {
|
||||
$totpSecret = $totp->generateSecret();
|
||||
$totpSecret = $this->totp->generateSecret();
|
||||
session()->put(static::SETUP_SECRET_SESSION_KEY, encrypt($totpSecret));
|
||||
}
|
||||
|
||||
$qrCodeUrl = $totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
|
||||
$svg = $totp->generateQrCodeSvg($qrCodeUrl);
|
||||
$qrCodeUrl = $this->totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
|
||||
$svg = $this->totp->generateQrCodeSvg($qrCodeUrl);
|
||||
|
||||
$this->setPageTitle(trans('auth.mfa_gen_totp_title'));
|
||||
|
||||
@ -56,7 +61,7 @@ class MfaTotpController extends Controller
|
||||
'code' => [
|
||||
'required',
|
||||
'max:12', 'min:4',
|
||||
new TotpValidationRule($totpSecret),
|
||||
new TotpValidationRule($totpSecret, $this->totp),
|
||||
],
|
||||
]);
|
||||
|
||||
@ -87,7 +92,7 @@ class MfaTotpController extends Controller
|
||||
'code' => [
|
||||
'required',
|
||||
'max:12', 'min:4',
|
||||
new TotpValidationRule($totpSecret),
|
||||
new TotpValidationRule($totpSecret, $this->totp),
|
||||
],
|
||||
]);
|
||||
|
||||
|
@ -11,9 +11,6 @@ class OidcController extends Controller
|
||||
{
|
||||
protected OidcService $oidcService;
|
||||
|
||||
/**
|
||||
* OpenIdController constructor.
|
||||
*/
|
||||
public function __construct(OidcService $oidcService)
|
||||
{
|
||||
$this->oidcService = $oidcService;
|
||||
@ -63,4 +60,12 @@ class OidcController extends Controller
|
||||
|
||||
return redirect()->intended();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out then start the OIDC RP-initiated logout process.
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
return redirect($this->oidcService->logout());
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ namespace BookStack\Access\Controllers;
|
||||
|
||||
use BookStack\Access\LoginService;
|
||||
use BookStack\Access\RegistrationService;
|
||||
use BookStack\Access\SocialAuthService;
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Http\Controller;
|
||||
@ -15,24 +15,13 @@ use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class RegisterController extends Controller
|
||||
{
|
||||
protected SocialAuthService $socialAuthService;
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
|
||||
/**
|
||||
* Create a new controller instance.
|
||||
*/
|
||||
public function __construct(
|
||||
SocialAuthService $socialAuthService,
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService
|
||||
protected SocialDriverManager $socialDriverManager,
|
||||
protected RegistrationService $registrationService,
|
||||
protected LoginService $loginService
|
||||
) {
|
||||
$this->middleware('guest');
|
||||
$this->middleware('guard:standard');
|
||||
|
||||
$this->socialAuthService = $socialAuthService;
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -43,7 +32,7 @@ class RegisterController extends Controller
|
||||
public function getRegister()
|
||||
{
|
||||
$this->registrationService->ensureRegistrationAllowed();
|
||||
$socialDrivers = $this->socialAuthService->getActiveDrivers();
|
||||
$socialDrivers = $this->socialDriverManager->getActive();
|
||||
|
||||
return view('auth.register', [
|
||||
'socialDrivers' => $socialDrivers,
|
||||
@ -87,6 +76,8 @@ class RegisterController extends Controller
|
||||
'name' => ['required', 'min:2', 'max:100'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users'],
|
||||
'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
|
||||
{
|
||||
protected LoginService $loginService;
|
||||
|
||||
public function __construct(LoginService $loginService)
|
||||
{
|
||||
public function __construct(
|
||||
protected LoginService $loginService
|
||||
) {
|
||||
$this->middleware('guest');
|
||||
$this->middleware('guard:standard');
|
||||
|
||||
$this->loginService = $loginService;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -66,7 +63,7 @@ class ResetPasswordController extends Controller
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $response === Password::PASSWORD_RESET
|
||||
? $this->sendResetResponse()
|
||||
: $this->sendResetFailedResponse($request, $response);
|
||||
: $this->sendResetFailedResponse($request, $response, $request->get('token'));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -83,7 +80,7 @@ class ResetPasswordController extends Controller
|
||||
/**
|
||||
* Get the response for a failed password reset.
|
||||
*/
|
||||
protected function sendResetFailedResponse(Request $request, string $response): RedirectResponse
|
||||
protected function sendResetFailedResponse(Request $request, string $response, string $token): RedirectResponse
|
||||
{
|
||||
// We show invalid users as invalid tokens as to not leak what
|
||||
// users may exist in the system.
|
||||
@ -91,7 +88,7 @@ class ResetPasswordController extends Controller
|
||||
$response = Password::INVALID_TOKEN;
|
||||
}
|
||||
|
||||
return redirect()->back()
|
||||
return redirect("/password/reset/{$token}")
|
||||
->withInput($request->only('email'))
|
||||
->withErrors(['email' => trans($response)]);
|
||||
}
|
||||
|
@ -9,14 +9,9 @@ use Illuminate\Support\Str;
|
||||
|
||||
class Saml2Controller extends Controller
|
||||
{
|
||||
protected Saml2Service $samlService;
|
||||
|
||||
/**
|
||||
* Saml2Controller constructor.
|
||||
*/
|
||||
public function __construct(Saml2Service $samlService)
|
||||
{
|
||||
$this->samlService = $samlService;
|
||||
public function __construct(
|
||||
protected Saml2Service $samlService
|
||||
) {
|
||||
$this->middleware('guard:saml2');
|
||||
}
|
||||
|
||||
@ -36,7 +31,12 @@ class Saml2Controller extends Controller
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
$logoutDetails = $this->samlService->logout(auth()->user());
|
||||
$user = user();
|
||||
if ($user->isGuest()) {
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
$logoutDetails = $this->samlService->logout($user);
|
||||
|
||||
if ($logoutDetails['id']) {
|
||||
session()->flash('saml2_logout_request_id', $logoutDetails['id']);
|
||||
@ -64,7 +64,7 @@ class Saml2Controller extends Controller
|
||||
public function sls()
|
||||
{
|
||||
$requestId = session()->pull('saml2_logout_request_id', null);
|
||||
$redirect = $this->samlService->processSlsResponse($requestId) ?? '/';
|
||||
$redirect = $this->samlService->processSlsResponse($requestId);
|
||||
|
||||
return redirect($redirect);
|
||||
}
|
||||
|
@ -79,7 +79,7 @@ class SocialController extends Controller
|
||||
try {
|
||||
return $this->socialAuthService->handleLoginCallback($socialDriver, $socialUser);
|
||||
} catch (SocialSignInAccountNotUsed $exception) {
|
||||
if ($this->socialAuthService->driverAutoRegisterEnabled($socialDriver)) {
|
||||
if ($this->socialAuthService->drivers()->isAutoRegisterEnabled($socialDriver)) {
|
||||
return $this->socialRegisterCallback($socialDriver, $socialUser);
|
||||
}
|
||||
|
||||
@ -91,7 +91,7 @@ class SocialController extends Controller
|
||||
return $this->socialRegisterCallback($socialDriver, $socialUser);
|
||||
}
|
||||
|
||||
return redirect()->back();
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -114,7 +114,7 @@ class SocialController extends Controller
|
||||
{
|
||||
$socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser);
|
||||
$socialAccount = $this->socialAuthService->newSocialAccount($socialDriver, $socialUser);
|
||||
$emailVerified = $this->socialAuthService->driverAutoConfirmEmailEnabled($socialDriver);
|
||||
$emailVerified = $this->socialAuthService->drivers()->isAutoConfirmEmailEnabled($socialDriver);
|
||||
|
||||
// Create an array of the user data to create a new user instance
|
||||
$userData = [
|
||||
|
@ -17,7 +17,7 @@ class EmailConfirmationService extends UserTokenService
|
||||
*
|
||||
* @throws ConfirmationEmailException
|
||||
*/
|
||||
public function sendConfirmation(User $user)
|
||||
public function sendConfirmation(User $user): void
|
||||
{
|
||||
if ($user->email_confirmed) {
|
||||
throw new ConfirmationEmailException(trans('errors.email_already_confirmed'), '/login');
|
||||
|
@ -8,27 +8,15 @@ use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ExternalBaseUserProvider implements UserProvider
|
||||
{
|
||||
/**
|
||||
* The user model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $model;
|
||||
|
||||
/**
|
||||
* LdapUserProvider constructor.
|
||||
*/
|
||||
public function __construct(string $model)
|
||||
{
|
||||
$this->model = $model;
|
||||
public function __construct(
|
||||
protected string $model
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance of the model.
|
||||
*
|
||||
* @return Model
|
||||
*/
|
||||
public function createModel()
|
||||
public function createModel(): Model
|
||||
{
|
||||
$class = '\\' . ltrim($this->model, '\\');
|
||||
|
||||
@ -37,12 +25,8 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@ -50,12 +34,9 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
/**
|
||||
* Retrieve a user by their unique identifier and "remember me" token.
|
||||
*
|
||||
* @param mixed $identifier
|
||||
* @param string $token
|
||||
*
|
||||
* @return Authenticatable|null
|
||||
*/
|
||||
public function retrieveByToken($identifier, $token)
|
||||
public function retrieveByToken(mixed $identifier, $token): null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@ -75,12 +56,8 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
|
||||
/**
|
||||
* 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
|
||||
$model = $this->createModel();
|
||||
@ -92,15 +69,15 @@ class ExternalBaseUserProvider implements UserProvider
|
||||
|
||||
/**
|
||||
* 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.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -52,13 +52,25 @@ class Ldap
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@ -75,7 +87,7 @@ class Ldap
|
||||
*
|
||||
* @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);
|
||||
|
||||
@ -87,7 +99,7 @@ class Ldap
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
|
@ -71,6 +71,26 @@ class LdapService
|
||||
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.
|
||||
* User found via configurable user filter.
|
||||
@ -81,21 +101,25 @@ class LdapService
|
||||
{
|
||||
$idAttr = $this->config['id_attribute'];
|
||||
$emailAttr = $this->config['email_attribute'];
|
||||
$displayNameAttr = $this->config['display_name_attribute'];
|
||||
$displayNameAttrs = explode('|', $this->config['display_name_attribute']);
|
||||
$thumbnailAttr = $this->config['thumbnail_attribute'];
|
||||
|
||||
$user = $this->getUserWithAttributes($userName, array_filter([
|
||||
'cn', 'dn', $idAttr, $emailAttr, $displayNameAttr, $thumbnailAttr,
|
||||
'cn', 'dn', $idAttr, $emailAttr, ...$displayNameAttrs, $thumbnailAttr,
|
||||
]));
|
||||
|
||||
if (is_null($user)) {
|
||||
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 = [
|
||||
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
|
||||
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
|
||||
'name' => $this->getUserDisplayName($user, $displayNameAttrs, $nameDefault),
|
||||
'dn' => $user['dn'],
|
||||
'email' => $this->getUserResponseProperty($user, $emailAttr, 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);
|
||||
}
|
||||
|
||||
// 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']);
|
||||
$ldapConnection = $this->ldap->connect($ldapHost);
|
||||
|
||||
@ -223,7 +253,14 @@ class LdapService
|
||||
|
||||
// Start and verify TLS if it's enabled
|
||||
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) {
|
||||
throw new LdapException('Could not start TLS connection');
|
||||
}
|
||||
@ -234,6 +271,33 @@ class LdapService
|
||||
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.
|
||||
* 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.
|
||||
* 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
|
||||
{
|
||||
$newAttrs = [];
|
||||
foreach ($attrs as $key => $attrText) {
|
||||
$newKey = '${' . $key . '}';
|
||||
$newAttrs[$newKey] = $this->ldap->escape($attrText);
|
||||
$escapedText = $this->ldap->escape($attrText);
|
||||
$oldVarKey = '${' . $key . '}';
|
||||
$newVarKey = '{' . $key . '}';
|
||||
$newAttrs[$oldVarKey] = $escapedText;
|
||||
$newAttrs[$newVarKey] = $escapedText;
|
||||
}
|
||||
|
||||
return strtr($filterString, $newAttrs);
|
||||
@ -276,94 +345,105 @@ class LdapService
|
||||
return [];
|
||||
}
|
||||
|
||||
$userGroups = $this->groupFilter($user);
|
||||
$userGroups = $this->extractGroupsFromSearchResponseEntry($user);
|
||||
$allGroups = $this->getGroupsRecursive($userGroups, []);
|
||||
$formattedGroups = $this->extractGroupNamesFromLdapGroupDns($allGroups);
|
||||
|
||||
if ($this->config['dump_user_groups']) {
|
||||
throw new JsonDebugException([
|
||||
'details_from_ldap' => $user,
|
||||
'parsed_direct_user_groups' => $userGroups,
|
||||
'parsed_recursive_user_groups' => $allGroups,
|
||||
'details_from_ldap' => $user,
|
||||
'parsed_direct_user_groups' => $userGroups,
|
||||
'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
|
||||
*/
|
||||
private function getGroupsRecursive(array $groupsArray, array $checked): array
|
||||
protected function getGroupsRecursive(array $groupDNs, array $checked): array
|
||||
{
|
||||
$groupsToAdd = [];
|
||||
foreach ($groupsArray as $groupName) {
|
||||
if (in_array($groupName, $checked)) {
|
||||
foreach ($groupDNs as $groupDN) {
|
||||
if (in_array($groupDN, $checked)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parentGroups = $this->getGroupGroups($groupName);
|
||||
$parentGroups = $this->getParentsOfGroup($groupDN);
|
||||
$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)) {
|
||||
return $groupsArray;
|
||||
return $uniqueDNs;
|
||||
}
|
||||
|
||||
return $this->getGroupsRecursive($groupsArray, $checked);
|
||||
return $this->getGroupsRecursive($uniqueDNs, $checked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent groups of a single group.
|
||||
*
|
||||
* @throws LdapException
|
||||
*/
|
||||
private function getGroupGroups(string $groupName): array
|
||||
protected function getParentsOfGroup(string $groupDN): array
|
||||
{
|
||||
$groupsAttr = strtolower($this->config['group_attribute']);
|
||||
$ldapConnection = $this->getConnection();
|
||||
$this->bindSystemUser($ldapConnection);
|
||||
|
||||
$followReferrals = $this->config['follow_referrals'] ? 1 : 0;
|
||||
$this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
|
||||
|
||||
$baseDn = $this->config['base_dn'];
|
||||
$groupsAttr = strtolower($this->config['group_attribute']);
|
||||
|
||||
$groupFilter = 'CN=' . $this->ldap->escape($groupName);
|
||||
$groups = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $groupFilter, [$groupsAttr]);
|
||||
if ($groups['count'] === 0) {
|
||||
$read = $this->ldap->read($ldapConnection, $groupDN, '(objectClass=*)', [$groupsAttr]);
|
||||
$results = $this->ldap->getEntries($ldapConnection, $read);
|
||||
if ($results['count'] === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->groupFilter($groups[0]);
|
||||
return $this->extractGroupsFromSearchResponseEntry($results[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out LDAP CN and DN language in a ldap search return.
|
||||
* Gets the base CN (common name) of the string.
|
||||
* Extract an array of group DN values from the given LDAP search response entry
|
||||
*/
|
||||
protected function groupFilter(array $userGroupSearchResponse): array
|
||||
protected function extractGroupsFromSearchResponseEntry(array $ldapEntry): array
|
||||
{
|
||||
$groupsAttr = strtolower($this->config['group_attribute']);
|
||||
$ldapGroups = [];
|
||||
$groupDNs = [];
|
||||
$count = 0;
|
||||
|
||||
if (isset($userGroupSearchResponse[$groupsAttr]['count'])) {
|
||||
$count = (int) $userGroupSearchResponse[$groupsAttr]['count'];
|
||||
if (isset($ldapEntry[$groupsAttr]['count'])) {
|
||||
$count = (int) $ldapEntry[$groupsAttr]['count'];
|
||||
}
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$dnComponents = $this->ldap->explodeDn($userGroupSearchResponse[$groupsAttr][$i], 1);
|
||||
if (!in_array($dnComponents[0], $ldapGroups)) {
|
||||
$ldapGroups[] = $dnComponents[0];
|
||||
$dn = $ldapEntry[$groupsAttr][$i];
|
||||
if (!in_array($dn, $groupDNs)) {
|
||||
$groupDNs[] = $dn;
|
||||
}
|
||||
}
|
||||
|
||||
return $ldapGroups;
|
||||
return $groupDNs;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -5,6 +5,7 @@ namespace BookStack\Access;
|
||||
use BookStack\Access\Mfa\MfaSession;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Exceptions\LoginAttemptInvalidUserException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Facades\Theme;
|
||||
@ -16,13 +17,11 @@ class LoginService
|
||||
{
|
||||
protected const LAST_LOGIN_ATTEMPTED_SESSION_KEY = 'auth-login-last-attempted';
|
||||
|
||||
protected $mfaSession;
|
||||
protected $emailConfirmationService;
|
||||
|
||||
public function __construct(MfaSession $mfaSession, EmailConfirmationService $emailConfirmationService)
|
||||
{
|
||||
$this->mfaSession = $mfaSession;
|
||||
$this->emailConfirmationService = $emailConfirmationService;
|
||||
public function __construct(
|
||||
protected MfaSession $mfaSession,
|
||||
protected EmailConfirmationService $emailConfirmationService,
|
||||
protected SocialDriverManager $socialDriverManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -31,10 +30,14 @@ class LoginService
|
||||
* a reason to (MFA or Unconfirmed Email).
|
||||
* 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
|
||||
{
|
||||
if ($user->isGuest()) {
|
||||
throw new LoginAttemptInvalidUserException('Login not allowed for guest user');
|
||||
}
|
||||
|
||||
if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) {
|
||||
$this->setLastLoginAttemptedForUser($user, $method, $remember);
|
||||
|
||||
@ -60,7 +63,7 @@ class LoginService
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function reattemptLoginFor(User $user)
|
||||
public function reattemptLoginFor(User $user): void
|
||||
{
|
||||
if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) {
|
||||
throw new Exception('Login reattempt user does align with current session state');
|
||||
@ -154,13 +157,66 @@ class LoginService
|
||||
*/
|
||||
public function attempt(array $credentials, string $method, bool $remember = false): bool
|
||||
{
|
||||
if ($this->areCredentialsForGuest($credentials)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = auth()->attempt($credentials, $remember);
|
||||
if ($result) {
|
||||
$user = auth()->user();
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns an app post-redirect path.
|
||||
*/
|
||||
public function logout(): string
|
||||
{
|
||||
auth()->logout();
|
||||
session()->invalidate();
|
||||
session()->regenerateToken();
|
||||
|
||||
return $this->shouldAutoInitiate() ? '/login?prevent_auto_init=true' : '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if login auto-initiate should be active based upon authentication config.
|
||||
*/
|
||||
public function shouldAutoInitiate(): bool
|
||||
{
|
||||
$autoRedirect = config('auth.auto_initiate');
|
||||
if (!$autoRedirect) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$socialDrivers = $this->socialDriverManager->getActive();
|
||||
$authMethod = config('auth.method');
|
||||
|
||||
return count($socialDrivers) === 0 && in_array($authMethod, ['oidc', 'saml2']);
|
||||
}
|
||||
}
|
||||
|
@ -2,36 +2,26 @@
|
||||
|
||||
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.
|
||||
* Takes the TOTP secret that must be system provided, not user provided.
|
||||
*/
|
||||
public function __construct(string $secret)
|
||||
{
|
||||
$this->secret = $secret;
|
||||
$this->totpService = app()->make(TotpService::class);
|
||||
public function __construct(
|
||||
protected string $secret,
|
||||
protected TotpService $totpService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the validation rule passes.
|
||||
*/
|
||||
public function passes($attribute, $value)
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
return $this->totpService->verifyCode($value, $this->secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation error message.
|
||||
*/
|
||||
public function message()
|
||||
{
|
||||
return trans('validation.totp');
|
||||
$passes = $this->totpService->verifyCode($value, $this->secret);
|
||||
if (!$passes) {
|
||||
$fail(trans('validation.totp'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,58 +2,8 @@
|
||||
|
||||
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.
|
||||
*
|
||||
@ -61,91 +11,12 @@ class OidcIdToken
|
||||
*/
|
||||
public function validate(string $clientId): bool
|
||||
{
|
||||
$this->validateTokenStructure();
|
||||
$this->validateTokenSignature();
|
||||
parent::validateCommonTokenDetails($clientId);
|
||||
$this->validateTokenClaims($clientId);
|
||||
|
||||
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.
|
||||
* 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)
|
||||
// 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');
|
||||
}
|
||||
// Already done in parent.
|
||||
|
||||
// 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, or if it contains additional
|
||||
// audiences not trusted by the Client.
|
||||
if (empty($this->payload['aud'])) {
|
||||
throw new OidcInvalidTokenException('Missing token audience value');
|
||||
}
|
||||
|
||||
// Partially done in parent.
|
||||
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
|
||||
if (count($aud) !== 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.
|
||||
// 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.
|
||||
*
|
||||
* @param ResponseInterface $response
|
||||
* @param array|string $data Parsed response data
|
||||
*
|
||||
* @throws IdentityProviderException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function checkResponse(ResponseInterface $response, $data)
|
||||
protected function checkResponse(ResponseInterface $response, $data): void
|
||||
{
|
||||
if ($response->getStatusCode() >= 400 || isset($data['error'])) {
|
||||
throw new IdentityProviderException(
|
||||
@ -105,13 +99,8 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
/**
|
||||
* Generates a resource owner object from a successful resource owner
|
||||
* details request.
|
||||
*
|
||||
* @param array $response
|
||||
* @param AccessToken $token
|
||||
*
|
||||
* @return ResourceOwnerInterface
|
||||
*/
|
||||
protected function createResourceOwner(array $response, AccessToken $token)
|
||||
protected function createResourceOwner(array $response, AccessToken $token): ResourceOwnerInterface
|
||||
{
|
||||
return new GenericResourceOwner($response, '');
|
||||
}
|
||||
@ -121,14 +110,18 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
*
|
||||
* The grant that was used to fetch the response can be used to provide
|
||||
* additional context.
|
||||
*
|
||||
* @param array $response
|
||||
* @param AbstractGrant $grant
|
||||
*
|
||||
* @return OidcAccessToken
|
||||
*/
|
||||
protected function createAccessToken(array $response, AbstractGrant $grant)
|
||||
protected function createAccessToken(array $response, AbstractGrant $grant): OidcAccessToken
|
||||
{
|
||||
return new OidcAccessToken($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the method used for PKCE code verifier hashing, which is passed
|
||||
* in the "code_challenge_method" parameter in the authorization request.
|
||||
*/
|
||||
protected function getPkceMethod(): string
|
||||
{
|
||||
return static::PKCE_METHOD_S256;
|
||||
}
|
||||
}
|
||||
|
@ -18,9 +18,10 @@ class OidcProviderSettings
|
||||
public string $issuer;
|
||||
public string $clientId;
|
||||
public string $clientSecret;
|
||||
public ?string $redirectUri;
|
||||
public ?string $authorizationEndpoint;
|
||||
public ?string $tokenEndpoint;
|
||||
public ?string $endSessionEndpoint;
|
||||
public ?string $userinfoEndpoint;
|
||||
|
||||
/**
|
||||
* @var string[]|array[]
|
||||
@ -36,7 +37,7 @@ class OidcProviderSettings
|
||||
/**
|
||||
* 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) {
|
||||
if (property_exists($this, $key)) {
|
||||
@ -50,9 +51,9 @@ class OidcProviderSettings
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function validateInitial()
|
||||
protected function validateInitial(): void
|
||||
{
|
||||
$required = ['clientId', 'clientSecret', 'redirectUri', 'issuer'];
|
||||
$required = ['clientId', 'clientSecret', 'issuer'];
|
||||
foreach ($required as $prop) {
|
||||
if (empty($this->$prop)) {
|
||||
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
|
||||
@ -72,12 +73,20 @@ class OidcProviderSettings
|
||||
public function validate(): void
|
||||
{
|
||||
$this->validateInitial();
|
||||
|
||||
$required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
|
||||
foreach ($required as $prop) {
|
||||
if (empty($this->$prop)) {
|
||||
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://");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -85,7 +94,7 @@ class OidcProviderSettings
|
||||
*
|
||||
* @throws OidcIssuerDiscoveryException
|
||||
*/
|
||||
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes)
|
||||
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes): void
|
||||
{
|
||||
try {
|
||||
$cacheKey = 'oidc-discovery::' . $this->issuer;
|
||||
@ -127,11 +136,19 @@ class OidcProviderSettings
|
||||
$discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
|
||||
}
|
||||
|
||||
if (!empty($result['userinfo_endpoint'])) {
|
||||
$discoveredSettings['userinfoEndpoint'] = $result['userinfo_endpoint'];
|
||||
}
|
||||
|
||||
if (!empty($result['jwks_uri'])) {
|
||||
$keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
|
||||
$discoveredSettings['keys'] = $this->filterKeys($keys);
|
||||
}
|
||||
|
||||
if (!empty($result['end_session_endpoint'])) {
|
||||
$discoveredSettings['endSessionEndpoint'] = $result['end_session_endpoint'];
|
||||
}
|
||||
|
||||
return $discoveredSettings;
|
||||
}
|
||||
|
||||
@ -170,9 +187,9 @@ class OidcProviderSettings
|
||||
/**
|
||||
* 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 = [];
|
||||
foreach ($settingKeys as $setting) {
|
||||
$settings[$setting] = $this->$setting;
|
||||
|
@ -12,7 +12,6 @@ use BookStack\Facades\Theme;
|
||||
use BookStack\Http\HttpRequestService;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
|
||||
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
||||
@ -33,6 +32,8 @@ class OidcService
|
||||
|
||||
/**
|
||||
* Initiate an authorization flow.
|
||||
* Provides back an authorize redirect URL, in addition to other
|
||||
* details which may be required for the auth flow.
|
||||
*
|
||||
* @throws OidcException
|
||||
*
|
||||
@ -42,8 +43,12 @@ class OidcService
|
||||
{
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
|
||||
$url = $provider->getAuthorizationUrl();
|
||||
session()->put('oidc_pkce_code', $provider->getPkceCode() ?? '');
|
||||
|
||||
return [
|
||||
'url' => $provider->getAuthorizationUrl(),
|
||||
'url' => $url,
|
||||
'state' => $provider->getState(),
|
||||
];
|
||||
}
|
||||
@ -63,6 +68,10 @@ class OidcService
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
|
||||
// Set PKCE code flashed at login
|
||||
$pkceCode = session()->pull('oidc_pkce_code', '');
|
||||
$provider->setPkceCode($pkceCode);
|
||||
|
||||
// Try to exchange authorization code for access token
|
||||
$accessToken = $provider->getAccessToken('authorization_code', [
|
||||
'code' => $authorizationCode,
|
||||
@ -81,9 +90,10 @@ class OidcService
|
||||
'issuer' => $config['issuer'],
|
||||
'clientId' => $config['client_id'],
|
||||
'clientSecret' => $config['client_secret'],
|
||||
'redirectUri' => url('/oidc/callback'),
|
||||
'authorizationEndpoint' => $config['authorization_endpoint'],
|
||||
'tokenEndpoint' => $config['token_endpoint'],
|
||||
'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null,
|
||||
'userinfoEndpoint' => $config['userinfo_endpoint'],
|
||||
]);
|
||||
|
||||
// Use keys if configured
|
||||
@ -100,6 +110,14 @@ class OidcService
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent use of RP-initiated logout if specifically disabled
|
||||
// Or force use of a URL if specifically set.
|
||||
if ($config['end_session_endpoint'] === false) {
|
||||
$settings->endSessionEndpoint = null;
|
||||
} else if (is_string($config['end_session_endpoint'])) {
|
||||
$settings->endSessionEndpoint = $config['end_session_endpoint'];
|
||||
}
|
||||
|
||||
$settings->validate();
|
||||
|
||||
return $settings;
|
||||
@ -110,7 +128,10 @@ class OidcService
|
||||
*/
|
||||
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),
|
||||
'optionProvider' => new HttpBasicAuthOptionProvider(),
|
||||
]);
|
||||
@ -137,69 +158,6 @@ class OidcService
|
||||
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
|
||||
* they exist, optionally registering them automatically.
|
||||
@ -217,6 +175,8 @@ class OidcService
|
||||
$settings->keys,
|
||||
);
|
||||
|
||||
session()->put("oidc_id_token", $idTokenText);
|
||||
|
||||
$returnClaims = Theme::dispatch(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, $idToken->getAllClaims(), [
|
||||
'access_token' => $accessToken->getToken(),
|
||||
'expires_in' => $accessToken->getExpires(),
|
||||
@ -234,34 +194,35 @@ class OidcService
|
||||
try {
|
||||
$idToken->validate($settings->clientId);
|
||||
} 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);
|
||||
$isLoggedIn = auth()->check();
|
||||
|
||||
if (empty($userDetails['email'])) {
|
||||
$userDetails = $this->getUserDetailsFromToken($idToken, $accessToken, $settings);
|
||||
if (empty($userDetails->email)) {
|
||||
throw new OidcException(trans('errors.oidc_no_email_address'));
|
||||
}
|
||||
if (empty($userDetails->name)) {
|
||||
$userDetails->name = $userDetails->externalId;
|
||||
}
|
||||
|
||||
$isLoggedIn = auth()->check();
|
||||
if ($isLoggedIn) {
|
||||
throw new OidcException(trans('errors.oidc_already_logged_in'));
|
||||
}
|
||||
|
||||
try {
|
||||
$user = $this->registrationService->findOrRegister(
|
||||
$userDetails['name'],
|
||||
$userDetails['email'],
|
||||
$userDetails['external_id']
|
||||
$userDetails->name,
|
||||
$userDetails->email,
|
||||
$userDetails->externalId
|
||||
);
|
||||
} catch (UserRegistrationException $exception) {
|
||||
throw new OidcException($exception->getMessage());
|
||||
}
|
||||
|
||||
if ($this->shouldSyncGroups()) {
|
||||
$groups = $userDetails['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');
|
||||
@ -269,6 +230,45 @@ class OidcService
|
||||
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.
|
||||
*/
|
||||
@ -284,4 +284,30 @@ class OidcService
|
||||
{
|
||||
return $this->config()['user_to_groups'] !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the RP-initiated logout flow if active, otherwise start a standard logout flow.
|
||||
* Returns a post-app-logout redirect URL.
|
||||
* Reference: https://openid.net/specs/openid-connect-rpinitiated-1_0.html
|
||||
* @throws OidcException
|
||||
*/
|
||||
public function logout(): string
|
||||
{
|
||||
$oidcToken = session()->pull("oidc_id_token");
|
||||
$defaultLogoutUrl = url($this->loginService->logout());
|
||||
$oidcSettings = $this->getProviderSettings();
|
||||
|
||||
if (!$oidcSettings->endSessionEndpoint) {
|
||||
return $defaultLogoutUrl;
|
||||
}
|
||||
|
||||
$endpointParams = [
|
||||
'id_token_hint' => $oidcToken,
|
||||
'post_logout_redirect_uri' => $defaultLogoutUrl,
|
||||
];
|
||||
|
||||
$joiner = str_contains($oidcSettings->endSessionEndpoint, '?') ? '&' : '?';
|
||||
|
||||
return $oidcSettings->endSessionEndpoint . $joiner . http_build_query($endpointParams);
|
||||
}
|
||||
}
|
||||
|
75
app/Access/Oidc/OidcUserDetails.php
Normal file
75
app/Access/Oidc/OidcUserDetails.php
Normal file
@ -0,0 +1,75 @@
|
||||
<?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,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $token): string
|
||||
{
|
||||
$displayNameClaimParts = explode('|', $displayNameClaims);
|
||||
|
||||
$displayName = [];
|
||||
foreach ($displayNameClaimParts as $claim) {
|
||||
$component = $token->getClaim(trim($claim)) ?? '';
|
||||
if ($component !== '') {
|
||||
$displayName[] = $component;
|
||||
}
|
||||
}
|
||||
|
||||
return implode(' ', $displayName);
|
||||
}
|
||||
|
||||
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $token): ?array
|
||||
{
|
||||
if (empty($groupsClaim)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$groupsList = Arr::get($token->getAllClaims(), $groupsClaim);
|
||||
if (!is_array($groupsList)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_values(array_filter($groupsList, function ($val) {
|
||||
return is_string($val);
|
||||
}));
|
||||
}
|
||||
}
|
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
|
||||
{
|
||||
protected $userRepo;
|
||||
protected $emailConfirmationService;
|
||||
|
||||
/**
|
||||
* RegistrationService constructor.
|
||||
*/
|
||||
public function __construct(UserRepo $userRepo, EmailConfirmationService $emailConfirmationService)
|
||||
{
|
||||
$this->userRepo = $userRepo;
|
||||
$this->emailConfirmationService = $emailConfirmationService;
|
||||
public function __construct(
|
||||
protected UserRepo $userRepo,
|
||||
protected EmailConfirmationService $emailConfirmationService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether or not registrations are allowed in the app settings.
|
||||
* Check if registrations are allowed in the app settings.
|
||||
*
|
||||
* @throws UserRegistrationException
|
||||
*/
|
||||
@ -84,6 +78,7 @@ class RegistrationService
|
||||
public function registerUser(array $userData, ?SocialAccount $socialAccount = null, bool $emailConfirmed = false): User
|
||||
{
|
||||
$userEmail = $userData['email'];
|
||||
$authSystem = $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver();
|
||||
|
||||
// Email restriction
|
||||
$this->ensureEmailDomainAllowed($userEmail);
|
||||
@ -94,6 +89,12 @@ class RegistrationService
|
||||
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login');
|
||||
}
|
||||
|
||||
/** @var ?bool $shouldRegister */
|
||||
$shouldRegister = Theme::dispatch(ThemeEvents::AUTH_PRE_REGISTER, $authSystem, $userData);
|
||||
if ($shouldRegister === false) {
|
||||
throw new UserRegistrationException(trans('errors.auth_pre_register_theme_prevention'), '/login');
|
||||
}
|
||||
|
||||
// Create the user
|
||||
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
|
||||
$newUser->attachDefaultRole();
|
||||
@ -104,7 +105,7 @@ class RegistrationService
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
|
||||
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $socialAccount ? $socialAccount->driver : auth()->getDefaultDriver(), $newUser);
|
||||
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $authSystem, $newUser);
|
||||
|
||||
// Start email confirmation flow if required
|
||||
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
|
||||
@ -138,7 +139,7 @@ class RegistrationService
|
||||
}
|
||||
|
||||
$restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
|
||||
$userEmailDomain = $domain = mb_substr(mb_strrchr($userEmail, '@'), 1);
|
||||
$userEmailDomain = mb_substr(mb_strrchr($userEmail, '@'), 1);
|
||||
if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
|
||||
$redirect = $this->registrationAllowed() ? '/register' : '/login';
|
||||
|
||||
|
@ -21,19 +21,13 @@ use OneLogin\Saml2\ValidationError;
|
||||
class Saml2Service
|
||||
{
|
||||
protected array $config;
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
protected GroupSyncService $groupSyncService;
|
||||
|
||||
public function __construct(
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService,
|
||||
GroupSyncService $groupSyncService
|
||||
protected RegistrationService $registrationService,
|
||||
protected LoginService $loginService,
|
||||
protected GroupSyncService $groupSyncService
|
||||
) {
|
||||
$this->config = config('saml2');
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
$this->groupSyncService = $groupSyncService;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -54,20 +48,23 @@ class Saml2Service
|
||||
|
||||
/**
|
||||
* Initiate a logout flow.
|
||||
* Returns the SAML2 request ID, and the URL to redirect the user to.
|
||||
*
|
||||
* @throws Error
|
||||
* @returns array{url: string, id: ?string}
|
||||
*/
|
||||
public function logout(User $user): array
|
||||
{
|
||||
$toolKit = $this->getToolkit();
|
||||
$returnRoute = url('/');
|
||||
$sessionIndex = session()->get('saml2_session_index');
|
||||
$returnUrl = url($this->loginService->logout());
|
||||
|
||||
try {
|
||||
$url = $toolKit->logout(
|
||||
$returnRoute,
|
||||
$returnUrl,
|
||||
[],
|
||||
$user->email,
|
||||
session()->get('saml2_session_index'),
|
||||
$sessionIndex,
|
||||
true,
|
||||
Constants::NAMEID_EMAIL_ADDRESS
|
||||
);
|
||||
@ -77,8 +74,7 @@ class Saml2Service
|
||||
throw $error;
|
||||
}
|
||||
|
||||
$this->actionLogout();
|
||||
$url = '/';
|
||||
$url = $returnUrl;
|
||||
$id = null;
|
||||
}
|
||||
|
||||
@ -128,7 +124,7 @@ class Saml2Service
|
||||
*
|
||||
* @throws Error
|
||||
*/
|
||||
public function processSlsResponse(?string $requestId): ?string
|
||||
public function processSlsResponse(?string $requestId): string
|
||||
{
|
||||
$toolkit = $this->getToolkit();
|
||||
|
||||
@ -137,7 +133,8 @@ class Saml2Service
|
||||
// value so that the exact encoding format is matched when checking the signature.
|
||||
// This is primarily due to ADFS encoding query params with lowercase percent encoding while
|
||||
// PHP (And most other sensible providers) standardise on uppercase.
|
||||
$redirect = $toolkit->processSLO(true, $requestId, true, null, true);
|
||||
/** @var ?string $samlRedirect */
|
||||
$samlRedirect = $toolkit->processSLO(true, $requestId, true, null, true);
|
||||
$errors = $toolkit->getErrors();
|
||||
|
||||
if (!empty($errors)) {
|
||||
@ -146,18 +143,9 @@ class Saml2Service
|
||||
);
|
||||
}
|
||||
|
||||
$this->actionLogout();
|
||||
$defaultBookStackRedirect = $this->loginService->logout();
|
||||
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do the required actions to log a user out.
|
||||
*/
|
||||
protected function actionLogout()
|
||||
{
|
||||
auth()->logout();
|
||||
session()->invalidate();
|
||||
return $samlRedirect ?? $defaultBookStackRedirect;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -357,6 +345,10 @@ class Saml2Service
|
||||
$userDetails = $this->getUserDetails($samlID, $samlAttributes);
|
||||
$isLoggedIn = auth()->check();
|
||||
|
||||
if ($this->shouldSyncGroups()) {
|
||||
$userDetails['groups'] = $this->getUserGroups($samlAttributes);
|
||||
}
|
||||
|
||||
if ($this->config['dump_user_details']) {
|
||||
throw new JsonDebugException([
|
||||
'id_from_idp' => $samlID,
|
||||
@ -379,13 +371,8 @@ class Saml2Service
|
||||
$userDetails['external_id']
|
||||
);
|
||||
|
||||
if ($user === null) {
|
||||
throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
|
||||
}
|
||||
|
||||
if ($this->shouldSyncGroups()) {
|
||||
$groups = $this->getUserGroups($samlAttributes);
|
||||
$this->groupSyncService->syncUserWithFoundGroups($user, $groups, $this->config['remove_from_groups']);
|
||||
$this->groupSyncService->syncUserWithFoundGroups($user, $userDetails['groups'], $this->config['remove_from_groups']);
|
||||
}
|
||||
|
||||
$this->loginService->login($user, 'saml2');
|
||||
|
@ -2,69 +2,24 @@
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Auth\Access\handler;
|
||||
use BookStack\Exceptions\SocialDriverNotConfigured;
|
||||
use BookStack\Exceptions\SocialSignInAccountNotUsed;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Contracts\Factory as Socialite;
|
||||
use Laravel\Socialite\Contracts\Provider;
|
||||
use Laravel\Socialite\Contracts\User as SocialUser;
|
||||
use Laravel\Socialite\Two\GoogleProvider;
|
||||
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
|
||||
class SocialAuthService
|
||||
{
|
||||
/**
|
||||
* The core socialite library used.
|
||||
*
|
||||
* @var Socialite
|
||||
*/
|
||||
protected $socialite;
|
||||
|
||||
/**
|
||||
* @var LoginService
|
||||
*/
|
||||
protected $loginService;
|
||||
|
||||
/**
|
||||
* The default built-in social drivers we support.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected $validSocialDrivers = [
|
||||
'google',
|
||||
'github',
|
||||
'facebook',
|
||||
'slack',
|
||||
'twitter',
|
||||
'azure',
|
||||
'okta',
|
||||
'gitlab',
|
||||
'twitch',
|
||||
'discord',
|
||||
];
|
||||
|
||||
/**
|
||||
* Callbacks to run when configuring a social driver
|
||||
* for an initial redirect action.
|
||||
* Array is keyed by social driver name.
|
||||
* Callbacks are passed an instance of the driver.
|
||||
*
|
||||
* @var array<string, callable>
|
||||
*/
|
||||
protected $configureForRedirectCallbacks = [];
|
||||
|
||||
/**
|
||||
* SocialAuthService constructor.
|
||||
*/
|
||||
public function __construct(Socialite $socialite, LoginService $loginService)
|
||||
{
|
||||
$this->socialite = $socialite;
|
||||
$this->loginService = $loginService;
|
||||
public function __construct(
|
||||
protected Socialite $socialite,
|
||||
protected LoginService $loginService,
|
||||
protected SocialDriverManager $driverManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -74,9 +29,10 @@ class SocialAuthService
|
||||
*/
|
||||
public function startLogIn(string $socialDriver): RedirectResponse
|
||||
{
|
||||
$driver = $this->validateDriver($socialDriver);
|
||||
$socialDriver = trim(strtolower($socialDriver));
|
||||
$this->driverManager->ensureDriverActive($socialDriver);
|
||||
|
||||
return $this->getDriverForRedirect($driver)->redirect();
|
||||
return $this->getDriverForRedirect($socialDriver)->redirect();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -86,9 +42,10 @@ class SocialAuthService
|
||||
*/
|
||||
public function startRegister(string $socialDriver): RedirectResponse
|
||||
{
|
||||
$driver = $this->validateDriver($socialDriver);
|
||||
$socialDriver = trim(strtolower($socialDriver));
|
||||
$this->driverManager->ensureDriverActive($socialDriver);
|
||||
|
||||
return $this->getDriverForRedirect($driver)->redirect();
|
||||
return $this->getDriverForRedirect($socialDriver)->redirect();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -119,9 +76,10 @@ class SocialAuthService
|
||||
*/
|
||||
public function getSocialUser(string $socialDriver): SocialUser
|
||||
{
|
||||
$driver = $this->validateDriver($socialDriver);
|
||||
$socialDriver = trim(strtolower($socialDriver));
|
||||
$this->driverManager->ensureDriverActive($socialDriver);
|
||||
|
||||
return $this->socialite->driver($driver)->user();
|
||||
return $this->socialite->driver($socialDriver)->user();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -131,6 +89,7 @@ class SocialAuthService
|
||||
*/
|
||||
public function handleLoginCallback(string $socialDriver, SocialUser $socialUser)
|
||||
{
|
||||
$socialDriver = trim(strtolower($socialDriver));
|
||||
$socialId = $socialUser->getId();
|
||||
|
||||
// Get any attached social accounts or users
|
||||
@ -181,76 +140,11 @@ class SocialAuthService
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the social driver is correct and supported.
|
||||
*
|
||||
* @throws SocialDriverNotConfigured
|
||||
* Get the social driver manager used by this service.
|
||||
*/
|
||||
protected function validateDriver(string $socialDriver): string
|
||||
public function drivers(): SocialDriverManager
|
||||
{
|
||||
$driver = trim(strtolower($socialDriver));
|
||||
|
||||
if (!in_array($driver, $this->validSocialDrivers)) {
|
||||
abort(404, trans('errors.social_driver_not_found'));
|
||||
}
|
||||
|
||||
if (!$this->checkDriverConfigured($driver)) {
|
||||
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($socialDriver)]));
|
||||
}
|
||||
|
||||
return $driver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a social driver has been configured correctly.
|
||||
*/
|
||||
protected function checkDriverConfigured(string $driver): bool
|
||||
{
|
||||
$lowerName = strtolower($driver);
|
||||
$configPrefix = 'services.' . $lowerName . '.';
|
||||
$config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
|
||||
|
||||
return !in_array(false, $config) && !in_array(null, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the names of the active social drivers.
|
||||
* @returns array<string, string>
|
||||
*/
|
||||
public function getActiveDrivers(): array
|
||||
{
|
||||
$activeDrivers = [];
|
||||
|
||||
foreach ($this->validSocialDrivers as $driverKey) {
|
||||
if ($this->checkDriverConfigured($driverKey)) {
|
||||
$activeDrivers[$driverKey] = $this->getDriverName($driverKey);
|
||||
}
|
||||
}
|
||||
|
||||
return $activeDrivers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the presentational name for a driver.
|
||||
*/
|
||||
public function getDriverName(string $driver): string
|
||||
{
|
||||
return config('services.' . strtolower($driver) . '.name');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current config for the given driver allows auto-registration.
|
||||
*/
|
||||
public function driverAutoRegisterEnabled(string $driver): bool
|
||||
{
|
||||
return config('services.' . strtolower($driver) . '.auto_register') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current config for the given driver allow email address auto-confirmation.
|
||||
*/
|
||||
public function driverAutoConfirmEmailEnabled(string $driver): bool
|
||||
{
|
||||
return config('services.' . strtolower($driver) . '.auto_confirm') === true;
|
||||
return $this->driverManager;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -284,33 +178,8 @@ class SocialAuthService
|
||||
$driver->with(['prompt' => 'select_account']);
|
||||
}
|
||||
|
||||
if (isset($this->configureForRedirectCallbacks[$driverName])) {
|
||||
$this->configureForRedirectCallbacks[$driverName]($driver);
|
||||
}
|
||||
$this->driverManager->getConfigureForRedirectCallback($driverName)($driver);
|
||||
|
||||
return $driver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom socialite driver to be used.
|
||||
* Driver name should be lower_snake_case.
|
||||
* Config array should mirror the structure of a service
|
||||
* within the `Config/services.php` file.
|
||||
* Handler should be a Class@method handler to the SocialiteWasCalled event.
|
||||
*/
|
||||
public function addSocialDriver(
|
||||
string $driverName,
|
||||
array $config,
|
||||
string $socialiteHandler,
|
||||
callable $configureForRedirect = null
|
||||
) {
|
||||
$this->validSocialDrivers[] = $driverName;
|
||||
config()->set('services.' . $driverName, $config);
|
||||
config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
|
||||
config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
|
||||
Event::listen(SocialiteWasCalled::class, $socialiteHandler);
|
||||
if (!is_null($configureForRedirect)) {
|
||||
$this->configureForRedirectCallbacks[$driverName] = $configureForRedirect;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
147
app/Access/SocialDriverManager.php
Normal file
147
app/Access/SocialDriverManager.php
Normal file
@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use BookStack\Exceptions\SocialDriverNotConfigured;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Str;
|
||||
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||
|
||||
class SocialDriverManager
|
||||
{
|
||||
/**
|
||||
* The default built-in social drivers we support.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $validDrivers = [
|
||||
'google',
|
||||
'github',
|
||||
'facebook',
|
||||
'slack',
|
||||
'twitter',
|
||||
'azure',
|
||||
'okta',
|
||||
'gitlab',
|
||||
'twitch',
|
||||
'discord',
|
||||
];
|
||||
|
||||
/**
|
||||
* Callbacks to run when configuring a social driver
|
||||
* for an initial redirect action.
|
||||
* Array is keyed by social driver name.
|
||||
* Callbacks are passed an instance of the driver.
|
||||
*
|
||||
* @var array<string, callable>
|
||||
*/
|
||||
protected array $configureForRedirectCallbacks = [];
|
||||
|
||||
/**
|
||||
* Check if the current config for the given driver allows auto-registration.
|
||||
*/
|
||||
public function isAutoRegisterEnabled(string $driver): bool
|
||||
{
|
||||
return $this->getDriverConfigProperty($driver, 'auto_register') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current config for the given driver allow email address auto-confirmation.
|
||||
*/
|
||||
public function isAutoConfirmEmailEnabled(string $driver): bool
|
||||
{
|
||||
return $this->getDriverConfigProperty($driver, 'auto_confirm') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the names of the active social drivers, keyed by driver id.
|
||||
* @returns array<string, string>
|
||||
*/
|
||||
public function getActive(): array
|
||||
{
|
||||
$activeDrivers = [];
|
||||
|
||||
foreach ($this->validDrivers as $driverKey) {
|
||||
if ($this->checkDriverConfigured($driverKey)) {
|
||||
$activeDrivers[$driverKey] = $this->getName($driverKey);
|
||||
}
|
||||
}
|
||||
|
||||
return $activeDrivers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configure-for-redirect callback for the given driver.
|
||||
* This is a callable that allows modification of the driver at redirect time.
|
||||
* Commonly used to perform custom dynamic configuration where required.
|
||||
* The callback is passed a \Laravel\Socialite\Contracts\Provider instance.
|
||||
*/
|
||||
public function getConfigureForRedirectCallback(string $driver): callable
|
||||
{
|
||||
return $this->configureForRedirectCallbacks[$driver] ?? (fn() => true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a custom socialite driver to be used.
|
||||
* Driver name should be lower_snake_case.
|
||||
* Config array should mirror the structure of a service
|
||||
* within the `Config/services.php` file.
|
||||
* Handler should be a Class@method handler to the SocialiteWasCalled event.
|
||||
*/
|
||||
public function addSocialDriver(
|
||||
string $driverName,
|
||||
array $config,
|
||||
string $socialiteHandler,
|
||||
?callable $configureForRedirect = null
|
||||
) {
|
||||
$this->validDrivers[] = $driverName;
|
||||
config()->set('services.' . $driverName, $config);
|
||||
config()->set('services.' . $driverName . '.redirect', url('/login/service/' . $driverName . '/callback'));
|
||||
config()->set('services.' . $driverName . '.name', $config['name'] ?? $driverName);
|
||||
Event::listen(SocialiteWasCalled::class, $socialiteHandler);
|
||||
if (!is_null($configureForRedirect)) {
|
||||
$this->configureForRedirectCallbacks[$driverName] = $configureForRedirect;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the presentational name for a driver.
|
||||
*/
|
||||
protected function getName(string $driver): string
|
||||
{
|
||||
return $this->getDriverConfigProperty($driver, 'name') ?? '';
|
||||
}
|
||||
|
||||
protected function getDriverConfigProperty(string $driver, string $property): mixed
|
||||
{
|
||||
return config("services.{$driver}.{$property}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the social driver is correct and supported.
|
||||
*
|
||||
* @throws SocialDriverNotConfigured
|
||||
*/
|
||||
public function ensureDriverActive(string $driverName): void
|
||||
{
|
||||
if (!in_array($driverName, $this->validDrivers)) {
|
||||
abort(404, trans('errors.social_driver_not_found'));
|
||||
}
|
||||
|
||||
if (!$this->checkDriverConfigured($driverName)) {
|
||||
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($driverName)]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a social driver has been configured correctly.
|
||||
*/
|
||||
protected function checkDriverConfigured(string $driver): bool
|
||||
{
|
||||
$lowerName = strtolower($driver);
|
||||
$configPrefix = 'services.' . $lowerName . '.';
|
||||
$config = [config($configPrefix . 'client_id'), config($configPrefix . 'client_secret'), config('services.callback_url')];
|
||||
|
||||
return !in_array(false, $config) && !in_array(null, $config);
|
||||
}
|
||||
}
|
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
|
||||
* Removes existing invitation tokens.
|
||||
* @throws UserInviteException
|
||||
*/
|
||||
public function sendInvitation(User $user)
|
||||
{
|
||||
$this->deleteByUser($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,6 +7,7 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\MixedEntityListLoader;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@ -14,11 +15,10 @@ use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class ActivityQueries
|
||||
{
|
||||
protected PermissionApplicator $permissions;
|
||||
|
||||
public function __construct(PermissionApplicator $permissions)
|
||||
{
|
||||
$this->permissions = $permissions;
|
||||
public function __construct(
|
||||
protected PermissionApplicator $permissions,
|
||||
protected MixedEntityListLoader $listLoader,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -27,13 +27,15 @@ class ActivityQueries
|
||||
public function latest(int $count = 20, int $page = 0): array
|
||||
{
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->with(['user', 'entity'])
|
||||
->with(['user'])
|
||||
->skip($count * $page)
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
$this->listLoader->loadIntoRelations($activityList->all(), 'loggable', false);
|
||||
|
||||
return $this->filterSimilar($activityList);
|
||||
}
|
||||
|
||||
@ -57,14 +59,14 @@ class ActivityQueries
|
||||
$query->where(function (Builder $query) use ($queryIds) {
|
||||
foreach ($queryIds as $morphClass => $idArr) {
|
||||
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
|
||||
$innerQuery->where('entity_type', '=', $morphClass)
|
||||
->whereIn('entity_id', $idArr);
|
||||
$innerQuery->where('loggable_type', '=', $morphClass)
|
||||
->whereIn('loggable_id', $idArr);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$activity = $query->orderBy('created_at', 'desc')
|
||||
->with(['entity' => function (Relation $query) {
|
||||
->with(['loggable' => function (Relation $query) {
|
||||
$query->withTrashed();
|
||||
}, 'user.avatar'])
|
||||
->skip($count * ($page - 1))
|
||||
@ -80,7 +82,7 @@ class ActivityQueries
|
||||
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
||||
{
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->where('user_id', '=', $user->id)
|
||||
->skip($count * $page)
|
||||
|
@ -67,6 +67,14 @@ class ActivityType
|
||||
const WEBHOOK_UPDATE = 'webhook_update';
|
||||
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.
|
||||
*/
|
||||
|
@ -5,7 +5,7 @@ namespace BookStack\Activity;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Facades\Activity as ActivityService;
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
use BookStack\Util\HtmlDescriptionFilter;
|
||||
|
||||
class CommentRepo
|
||||
{
|
||||
@ -20,13 +20,12 @@ class CommentRepo
|
||||
/**
|
||||
* Create a new comment on an entity.
|
||||
*/
|
||||
public function create(Entity $entity, string $text, ?int $parent_id): Comment
|
||||
public function create(Entity $entity, string $html, ?int $parent_id): Comment
|
||||
{
|
||||
$userId = user()->id;
|
||||
$comment = new Comment();
|
||||
|
||||
$comment->text = $text;
|
||||
$comment->html = $this->commentToHtml($text);
|
||||
$comment->html = HtmlDescriptionFilter::filterFromString($html);
|
||||
$comment->created_by = $userId;
|
||||
$comment->updated_by = $userId;
|
||||
$comment->local_id = $this->getNextLocalId($entity);
|
||||
@ -42,11 +41,10 @@ class CommentRepo
|
||||
/**
|
||||
* Update an existing comment.
|
||||
*/
|
||||
public function update(Comment $comment, string $text): Comment
|
||||
public function update(Comment $comment, string $html): Comment
|
||||
{
|
||||
$comment->updated_by = user()->id;
|
||||
$comment->text = $text;
|
||||
$comment->html = $this->commentToHtml($text);
|
||||
$comment->html = HtmlDescriptionFilter::filterFromString($html);
|
||||
$comment->save();
|
||||
|
||||
ActivityService::add(ActivityType::COMMENT_UPDATE, $comment);
|
||||
@ -64,20 +62,6 @@ class CommentRepo
|
||||
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.
|
||||
*/
|
||||
|
28
app/Activity/Controllers/AuditLogApiController.php
Normal file
28
app/Activity/Controllers/AuditLogApiController.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Http\ApiController;
|
||||
|
||||
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('settings-manage');
|
||||
$this->checkPermission('users-manage');
|
||||
|
||||
$query = Activity::query()->with(['user']);
|
||||
|
||||
return $this->apiListingResponse($query, [
|
||||
'id', 'type', 'detail', 'user_id', 'loggable_id', 'loggable_type', 'ip', 'created_at',
|
||||
]);
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@ namespace BookStack\Activity\Controllers;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Sorting\SortUrl;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@ -32,7 +33,7 @@ class AuditLogController extends Controller
|
||||
|
||||
$query = Activity::query()
|
||||
->with([
|
||||
'entity' => fn ($query) => $query->withTrashed(),
|
||||
'loggable' => fn ($query) => $query->withTrashed(),
|
||||
'user',
|
||||
])
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder());
|
||||
@ -65,6 +66,7 @@ class AuditLogController extends Controller
|
||||
'filters' => $filters,
|
||||
'listOptions' => $listOptions,
|
||||
'activityTypes' => $types,
|
||||
'filterSortUrl' => new SortUrl('settings/audit', array_filter($request->except('page')))
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,7 @@
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\CommentRepo;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@ -11,7 +11,8 @@ use Illuminate\Validation\ValidationException;
|
||||
class CommentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected CommentRepo $commentRepo
|
||||
protected CommentRepo $commentRepo,
|
||||
protected PageQueries $pageQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -22,12 +23,12 @@ class CommentController extends Controller
|
||||
*/
|
||||
public function savePageComment(Request $request, int $pageId)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'text' => ['required', 'string'],
|
||||
$input = $this->validate($request, [
|
||||
'html' => ['required', 'string'],
|
||||
'parent_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$page = Page::visible()->find($pageId);
|
||||
$page = $this->pageQueries->findVisibleById($pageId);
|
||||
if ($page === null) {
|
||||
return response('Not found', 404);
|
||||
}
|
||||
@ -39,7 +40,7 @@ class CommentController extends Controller
|
||||
|
||||
// Create a new comment.
|
||||
$this->checkPermission('comment-create-all');
|
||||
$comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id'));
|
||||
$comment = $this->commentRepo->create($page, $input['html'], $input['parent_id'] ?? null);
|
||||
|
||||
return view('comments.comment-branch', [
|
||||
'readOnly' => false,
|
||||
@ -57,17 +58,20 @@ class CommentController extends Controller
|
||||
*/
|
||||
public function update(Request $request, int $commentId)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'text' => ['required', 'string'],
|
||||
$input = $this->validate($request, [
|
||||
'html' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$comment = $this->commentRepo->getById($commentId);
|
||||
$this->checkOwnablePermission('page-view', $comment->entity);
|
||||
$this->checkOwnablePermission('comment-update', $comment);
|
||||
|
||||
$comment = $this->commentRepo->update($comment, $request->get('text'));
|
||||
$comment = $this->commentRepo->update($comment, $input['html']);
|
||||
|
||||
return view('comments.comment', ['comment' => $comment, 'readOnly' => false]);
|
||||
return view('comments.comment', [
|
||||
'comment' => $comment,
|
||||
'readOnly' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -2,10 +2,7 @@
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\Models\Favouritable;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Queries\TopFavourites;
|
||||
use BookStack\Entities\Queries\QueryTopFavourites;
|
||||
use BookStack\Entities\Tools\MixedEntityRequestHelper;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
@ -20,11 +17,11 @@ class FavouriteController extends Controller
|
||||
/**
|
||||
* Show a listing of all favourite items for the current user.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
public function index(Request $request, QueryTopFavourites $topFavourites)
|
||||
{
|
||||
$viewCount = 20;
|
||||
$page = intval($request->get('page', 1));
|
||||
$favourites = (new TopFavourites())->run($viewCount + 1, (($page - 1) * $viewCount));
|
||||
$favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount));
|
||||
|
||||
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
|
||||
|
||||
@ -52,7 +49,7 @@ class FavouriteController extends Controller
|
||||
'name' => $entity->name,
|
||||
]));
|
||||
|
||||
return redirect()->back();
|
||||
return redirect($entity->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -70,6 +67,6 @@ class FavouriteController extends Controller
|
||||
'name' => $entity->name,
|
||||
]));
|
||||
|
||||
return redirect()->back();
|
||||
return redirect($entity->getUrl());
|
||||
}
|
||||
}
|
||||
|
@ -24,6 +24,6 @@ class WatchController extends Controller
|
||||
|
||||
$this->showSuccessNotification(trans('activities.watch_update_level_notification'));
|
||||
|
||||
return redirect()->back();
|
||||
return redirect($watchable->getUrl());
|
||||
}
|
||||
}
|
||||
|
@ -9,31 +9,30 @@ use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @property string $type
|
||||
* @property User $user
|
||||
* @property Entity $entity
|
||||
* @property Entity $loggable
|
||||
* @property string $detail
|
||||
* @property string $entity_type
|
||||
* @property int $entity_id
|
||||
* @property string $loggable_type
|
||||
* @property int $loggable_id
|
||||
* @property int $user_id
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class Activity extends Model
|
||||
{
|
||||
/**
|
||||
* Get the entity for this activity.
|
||||
* 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 entity(): MorphTo
|
||||
public function loggable(): MorphTo
|
||||
{
|
||||
if ($this->entity_type === '') {
|
||||
$this->entity_type = null;
|
||||
}
|
||||
|
||||
return $this->morphTo('entity');
|
||||
return $this->morphTo('loggable');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -46,8 +45,8 @@ class Activity extends Model
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
|
||||
->whereColumn('activities.entity_type', '=', 'joint_permissions.entity_type');
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'loggable_id')
|
||||
->whereColumn('activities.loggable_type', '=', 'joint_permissions.entity_type');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -73,6 +72,6 @@ class Activity extends Model
|
||||
*/
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
@ -4,13 +4,14 @@ namespace BookStack\Activity\Models;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $text
|
||||
* @property string $text - Deprecated & now unused (#4821)
|
||||
* @property string $html
|
||||
* @property int|null $parent_id - Relates to local_id, not id
|
||||
* @property int $local_id
|
||||
@ -24,8 +25,7 @@ class Comment extends Model implements Loggable
|
||||
use HasFactory;
|
||||
use HasCreatorAndUpdater;
|
||||
|
||||
protected $fillable = ['text', 'parent_id'];
|
||||
protected $appends = ['created', 'updated'];
|
||||
protected $fillable = ['parent_id'];
|
||||
|
||||
/**
|
||||
* Get the entity that this comment belongs to.
|
||||
@ -53,24 +53,13 @@ class Comment extends Model implements Loggable
|
||||
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
|
||||
{
|
||||
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";
|
||||
}
|
||||
|
||||
public function safeHtml(): string
|
||||
{
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
abstract class BaseNotificationHandler implements NotificationHandler
|
||||
{
|
||||
@ -36,7 +37,11 @@ abstract class BaseNotificationHandler implements NotificationHandler
|
||||
}
|
||||
|
||||
// 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()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\MessageParts;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* A link to a specific entity in the system, with the text showing its name.
|
||||
*/
|
||||
class EntityLinkMessageLine implements Htmlable, Stringable
|
||||
{
|
||||
public function __construct(
|
||||
protected Entity $entity,
|
||||
protected int $nameLength = 120,
|
||||
) {
|
||||
}
|
||||
|
||||
public function toHtml(): string
|
||||
{
|
||||
return '<a href="' . e($this->entity->getUrl()) . '">' . e($this->entity->getShortName($this->nameLength)) . '</a>';
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return "{$this->entity->getShortName($this->nameLength)} ({$this->entity->getUrl()})";
|
||||
}
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\MessageParts;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use Illuminate\Contracts\Support\Htmlable;
|
||||
use Stringable;
|
||||
|
||||
/**
|
||||
* A link to a specific entity in the system, with the text showing its name.
|
||||
*/
|
||||
class EntityPathMessageLine implements Htmlable, Stringable
|
||||
{
|
||||
/**
|
||||
* @var EntityLinkMessageLine[]
|
||||
*/
|
||||
protected array $entityLinks;
|
||||
|
||||
public function __construct(
|
||||
protected array $entities
|
||||
) {
|
||||
$this->entityLinks = array_map(fn (Entity $entity) => new EntityLinkMessageLine($entity, 24), $this->entities);
|
||||
}
|
||||
|
||||
public function toHtml(): string
|
||||
{
|
||||
$entityHtmls = array_map(fn (EntityLinkMessageLine $line) => $line->toHtml(), $this->entityLinks);
|
||||
return implode(' > ', $entityHtmls);
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return implode(' > ', $this->entityLinks);
|
||||
}
|
||||
}
|
@ -3,8 +3,12 @@
|
||||
namespace BookStack\Activity\Notifications\Messages;
|
||||
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Notifications\MessageParts\EntityPathMessageLine;
|
||||
use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine;
|
||||
use BookStack\App\MailNotification;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Translation\LocaleDefinition;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@ -39,9 +43,25 @@ abstract class BaseActivityNotification extends MailNotification
|
||||
protected function buildReasonFooterLine(LocaleDefinition $locale): LinkedMailMessageLine
|
||||
{
|
||||
return new LinkedMailMessageLine(
|
||||
url('/preferences/notifications'),
|
||||
url('/my-account/notifications'),
|
||||
$locale->trans('notifications.footer_reason'),
|
||||
$locale->trans('notifications.footer_reason_link'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a line which provides the book > chapter path to a page.
|
||||
* Takes into account visibility of these parent items.
|
||||
* Returns null if no path items can be used.
|
||||
*/
|
||||
protected function buildPagePathLine(Page $page, User $notifiable): ?EntityPathMessageLine
|
||||
{
|
||||
$permissions = new PermissionApplicator($notifiable);
|
||||
|
||||
$path = array_filter([$page->book, $page->chapter], function (?Entity $entity) use ($permissions) {
|
||||
return !is_null($entity) && $permissions->checkOwnableUserAccess($entity, 'view');
|
||||
});
|
||||
|
||||
return empty($path) ? null : new EntityPathMessageLine($path);
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@
|
||||
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;
|
||||
@ -19,14 +20,17 @@ class CommentCreationNotification extends BaseActivityNotification
|
||||
|
||||
$locale = $notifiable->getLocale();
|
||||
|
||||
$listLines = array_filter([
|
||||
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
||||
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
||||
$locale->trans('notifications.detail_commenter') => $this->user->name,
|
||||
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
|
||||
]);
|
||||
|
||||
return $this->newMailMessage($locale)
|
||||
->subject($locale->trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()]))
|
||||
->line($locale->trans('notifications.new_comment_intro', ['appName' => setting('app-name')]))
|
||||
->line(new ListMessageLine([
|
||||
$locale->trans('notifications.detail_page_name') => $page->name,
|
||||
$locale->trans('notifications.detail_commenter') => $this->user->name,
|
||||
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
|
||||
]))
|
||||
->line(new ListMessageLine($listLines))
|
||||
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
|
||||
->line($this->buildReasonFooterLine($locale));
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Activity\Notifications\Messages;
|
||||
|
||||
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
|
||||
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Users\Models\User;
|
||||
@ -16,13 +17,16 @@ class PageCreationNotification extends BaseActivityNotification
|
||||
|
||||
$locale = $notifiable->getLocale();
|
||||
|
||||
$listLines = array_filter([
|
||||
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
||||
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
||||
$locale->trans('notifications.detail_created_by') => $this->user->name,
|
||||
]);
|
||||
|
||||
return $this->newMailMessage($locale)
|
||||
->subject($locale->trans('notifications.new_page_subject', ['pageName' => $page->getShortName()]))
|
||||
->line($locale->trans('notifications.new_page_intro', ['appName' => setting('app-name')], $locale))
|
||||
->line(new ListMessageLine([
|
||||
$locale->trans('notifications.detail_page_name') => $page->name,
|
||||
$locale->trans('notifications.detail_created_by') => $this->user->name,
|
||||
]))
|
||||
->line($locale->trans('notifications.new_page_intro', ['appName' => setting('app-name')]))
|
||||
->line(new ListMessageLine($listLines))
|
||||
->action($locale->trans('notifications.action_view_page'), $page->getUrl())
|
||||
->line($this->buildReasonFooterLine($locale));
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Activity\Notifications\Messages;
|
||||
|
||||
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
|
||||
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Users\Models\User;
|
||||
@ -16,13 +17,16 @@ class PageUpdateNotification extends BaseActivityNotification
|
||||
|
||||
$locale = $notifiable->getLocale();
|
||||
|
||||
$listLines = array_filter([
|
||||
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
||||
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
||||
$locale->trans('notifications.detail_updated_by') => $this->user->name,
|
||||
]);
|
||||
|
||||
return $this->newMailMessage($locale)
|
||||
->subject($locale->trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()]))
|
||||
->line($locale->trans('notifications.updated_page_intro', ['appName' => setting('app-name')]))
|
||||
->line(new ListMessageLine([
|
||||
$locale->trans('notifications.detail_page_name') => $page->name,
|
||||
$locale->trans('notifications.detail_updated_by') => $this->user->name,
|
||||
]))
|
||||
->line(new ListMessageLine($listLines))
|
||||
->line($locale->trans('notifications.updated_page_debounce'))
|
||||
->action($locale->trans('notifications.action_view_page'), $page->getUrl())
|
||||
->line($this->buildReasonFooterLine($locale));
|
||||
|
@ -38,7 +38,8 @@ class TagRepo
|
||||
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
|
||||
])
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
->orderBy($sort, $listOptions->getOrder())
|
||||
->whereHas('entity');
|
||||
|
||||
if ($nameFilter) {
|
||||
$query->where('name', '=', $nameFilter);
|
||||
|
@ -32,8 +32,8 @@ class ActivityLogger
|
||||
$activity->detail = $detailToStore;
|
||||
|
||||
if ($detail instanceof Entity) {
|
||||
$activity->entity_id = $detail->id;
|
||||
$activity->entity_type = $detail->getMorphClass();
|
||||
$activity->loggable_id = $detail->id;
|
||||
$activity->loggable_type = $detail->getMorphClass();
|
||||
}
|
||||
|
||||
$activity->save();
|
||||
@ -64,9 +64,9 @@ class ActivityLogger
|
||||
public function removeEntity(Entity $entity): void
|
||||
{
|
||||
$entity->activity()->update([
|
||||
'detail' => $entity->name,
|
||||
'entity_id' => null,
|
||||
'entity_type' => null,
|
||||
'detail' => $entity->name,
|
||||
'loggable_id' => null,
|
||||
'loggable_type' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -41,6 +41,17 @@ class CommentTree
|
||||
return $this->tree;
|
||||
}
|
||||
|
||||
public function canUpdateAny(): bool
|
||||
{
|
||||
foreach ($this->comments as $comment) {
|
||||
if (userCan('comment-update', $comment)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Comment[] $comments
|
||||
*/
|
||||
|
@ -7,7 +7,6 @@ use Exception;
|
||||
use Illuminate\Contracts\Container\BindingResolutionException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
@ -2,7 +2,9 @@
|
||||
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
|
||||
class ApiEntityListFormatter
|
||||
{
|
||||
@ -20,8 +22,16 @@ class ApiEntityListFormatter
|
||||
* @var array<string|int, string|callable>
|
||||
*/
|
||||
protected array $fields = [
|
||||
'id', 'name', 'slug', 'book_id', 'chapter_id', 'draft',
|
||||
'template', 'priority', 'created_at', 'updated_at',
|
||||
'id',
|
||||
'name',
|
||||
'slug',
|
||||
'book_id',
|
||||
'chapter_id',
|
||||
'draft',
|
||||
'template',
|
||||
'priority',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
public function __construct(array $list)
|
||||
@ -62,6 +72,28 @@ class ApiEntityListFormatter
|
||||
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.
|
||||
* @return array[]
|
||||
|
@ -3,32 +3,36 @@
|
||||
namespace BookStack\App;
|
||||
|
||||
use BookStack\Activity\ActivityQueries;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\RecentlyViewed;
|
||||
use BookStack\Entities\Queries\TopFavourites;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Queries\QueryRecentlyViewed;
|
||||
use BookStack\Entities\Queries\QueryTopFavourites;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Uploads\FaviconHandler;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class HomeController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected EntityQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the homepage.
|
||||
*/
|
||||
public function index(Request $request, ActivityQueries $activities)
|
||||
{
|
||||
public function index(
|
||||
Request $request,
|
||||
ActivityQueries $activities,
|
||||
QueryRecentlyViewed $recentlyViewed,
|
||||
QueryTopFavourites $topFavourites,
|
||||
) {
|
||||
$activity = $activities->latest(10);
|
||||
$draftPages = [];
|
||||
|
||||
if ($this->isSignedIn()) {
|
||||
$draftPages = Page::visible()
|
||||
->where('draft', '=', true)
|
||||
->where('created_by', '=', user()->id)
|
||||
$draftPages = $this->queries->pages->currentUserDraftsForList()
|
||||
->orderBy('updated_at', 'desc')
|
||||
->with('book')
|
||||
->take(6)
|
||||
@ -37,14 +41,13 @@ class HomeController extends Controller
|
||||
|
||||
$recentFactor = count($draftPages) > 0 ? 0.5 : 1;
|
||||
$recents = $this->isSignedIn() ?
|
||||
(new RecentlyViewed())->run(12 * $recentFactor, 1)
|
||||
: Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
|
||||
$favourites = (new TopFavourites())->run(6);
|
||||
$recentlyUpdatedPages = Page::visible()->with('book')
|
||||
$recentlyViewed->run(12 * $recentFactor, 1)
|
||||
: $this->queries->books->visibleForList()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get();
|
||||
$favourites = $topFavourites->run(6);
|
||||
$recentlyUpdatedPages = $this->queries->pages->visibleForList()
|
||||
->where('draft', false)
|
||||
->orderBy('updated_at', 'desc')
|
||||
->take($favourites->count() > 0 ? 5 : 10)
|
||||
->select(Page::$listAttributes)
|
||||
->get();
|
||||
|
||||
$homepageOptions = ['default', 'books', 'bookshelves', 'page'];
|
||||
@ -78,14 +81,18 @@ class HomeController extends Controller
|
||||
}
|
||||
|
||||
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(18);
|
||||
$data = array_merge($commonData, ['shelves' => $shelves]);
|
||||
|
||||
return view('home.shelves', $data);
|
||||
}
|
||||
|
||||
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(18);
|
||||
$data = array_merge($commonData, ['books' => $books]);
|
||||
|
||||
return view('home.books', $data);
|
||||
@ -95,7 +102,7 @@ class HomeController extends Controller
|
||||
$homepageSetting = setting('app-homepage', '0:');
|
||||
$id = intval(explode(':', $homepageSetting)[0]);
|
||||
/** @var Page $customHomepage */
|
||||
$customHomepage = Page::query()->where('draft', '=', false)->findOrFail($id);
|
||||
$customHomepage = $this->queries->pages->start()->where('draft', '=', false)->findOrFail($id);
|
||||
$pageContent = new PageContent($customHomepage);
|
||||
$customHomepage->html = $pageContent->render(false);
|
||||
|
||||
@ -104,48 +111,4 @@ class HomeController extends Controller
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\App\Providers;
|
||||
|
||||
use BookStack\Access\SocialAuthService;
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Activity\Tools\ActivityLogger;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
@ -25,7 +25,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
* Custom container bindings to register.
|
||||
* @var string[]
|
||||
*/
|
||||
public $bindings = [
|
||||
public array $bindings = [
|
||||
ExceptionRenderer::class => BookStackExceptionHandlerPage::class,
|
||||
];
|
||||
|
||||
@ -33,20 +33,28 @@ class AppServiceProvider extends ServiceProvider
|
||||
* Custom singleton bindings to register.
|
||||
* @var string[]
|
||||
*/
|
||||
public $singletons = [
|
||||
public array $singletons = [
|
||||
'activity' => ActivityLogger::class,
|
||||
SettingService::class => SettingService::class,
|
||||
SocialAuthService::class => SocialAuthService::class,
|
||||
SocialDriverManager::class => SocialDriverManager::class,
|
||||
CspService::class => CspService::class,
|
||||
HttpRequestService::class => HttpRequestService::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*
|
||||
* @return void
|
||||
* Register any application services.
|
||||
*/
|
||||
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
|
||||
$appUrl = config('app.url');
|
||||
@ -67,16 +75,4 @@ class AppServiceProvider extends ServiceProvider
|
||||
'page' => Page::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.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot()
|
||||
public function boot(): void
|
||||
{
|
||||
// Password Configuration
|
||||
// Changes here must be reflected in ApiDocsGenerate@getValidationAsString.
|
||||
@ -58,10 +56,8 @@ class AuthServiceProvider extends ServiceProvider
|
||||
|
||||
/**
|
||||
* Register the application services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
public function register(): void
|
||||
{
|
||||
Auth::provider('external-users', function ($app, array $config) {
|
||||
return new ExternalBaseUserProvider($config['model']);
|
||||
|
@ -29,21 +29,25 @@ class EventServiceProvider extends ServiceProvider
|
||||
|
||||
/**
|
||||
* Register any events for your application.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot()
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if events and listeners should be automatically discovered.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function shouldDiscoverEvents()
|
||||
public function shouldDiscoverEvents(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the registration of Laravel's default email verification system
|
||||
*/
|
||||
protected function configureEmailVerification(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
|
@ -2,9 +2,12 @@
|
||||
|
||||
namespace BookStack\App\Providers;
|
||||
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Router;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@ -21,10 +24,8 @@ class RouteServiceProvider extends ServiceProvider
|
||||
|
||||
/**
|
||||
* Define your route model bindings, pattern filters, etc.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot()
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureRateLimiting();
|
||||
|
||||
@ -38,16 +39,21 @@ class RouteServiceProvider extends ServiceProvider
|
||||
* Define the "web" routes for the application.
|
||||
*
|
||||
* These routes all receive session state, CSRF protection, etc.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function mapWebRoutes()
|
||||
protected function mapWebRoutes(): void
|
||||
{
|
||||
Route::group([
|
||||
'middleware' => 'web',
|
||||
'namespace' => $this->namespace,
|
||||
], function ($router) {
|
||||
], function (Router $router) {
|
||||
require base_path('routes/web.php');
|
||||
Theme::dispatch(ThemeEvents::ROUTES_REGISTER_WEB, $router);
|
||||
});
|
||||
|
||||
Route::group([
|
||||
'middleware' => ['web', 'auth'],
|
||||
], function (Router $router) {
|
||||
Theme::dispatch(ThemeEvents::ROUTES_REGISTER_WEB_AUTH, $router);
|
||||
});
|
||||
}
|
||||
|
||||
@ -55,10 +61,8 @@ class RouteServiceProvider extends ServiceProvider
|
||||
* Define the "api" routes for the application.
|
||||
*
|
||||
* These routes are typically stateless.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function mapApiRoutes()
|
||||
protected function mapApiRoutes(): void
|
||||
{
|
||||
Route::group([
|
||||
'middleware' => 'api',
|
||||
@ -71,13 +75,22 @@ class RouteServiceProvider extends ServiceProvider
|
||||
|
||||
/**
|
||||
* Configure the rate limiters for the application.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function configureRateLimiting()
|
||||
protected function configureRateLimiting(): void
|
||||
{
|
||||
RateLimiter::for('api', function (Request $request) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -10,10 +10,8 @@ class ThemeServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
public function register(): void
|
||||
{
|
||||
// Register the ThemeService as a singleton
|
||||
$this->app->singleton(ThemeService::class, fn ($app) => new ThemeService());
|
||||
@ -21,10 +19,8 @@ class ThemeServiceProvider extends ServiceProvider
|
||||
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot()
|
||||
public function boot(): void
|
||||
{
|
||||
// Boot up the theme system
|
||||
$themeService = $this->app->make(ThemeService::class);
|
||||
|
@ -11,10 +11,8 @@ class TranslationServiceProvider extends BaseProvider
|
||||
{
|
||||
/**
|
||||
* Register the service provider.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
public function register(): void
|
||||
{
|
||||
$this->registerLoader();
|
||||
|
||||
@ -41,10 +39,8 @@ class TranslationServiceProvider extends BaseProvider
|
||||
/**
|
||||
* Register the translation line loader.
|
||||
* 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) {
|
||||
return new FileLoader($app['files'], $app['path.lang']);
|
||||
|
@ -12,10 +12,8 @@ class ViewTweaksServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap services.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function boot()
|
||||
public function boot(): void
|
||||
{
|
||||
// Set paginator to use bootstrap-style pagination
|
||||
Paginator::useBootstrap();
|
||||
|
@ -26,7 +26,7 @@ class PwaManifestBuilder
|
||||
"launch_handler" => [
|
||||
"client_mode" => "focus-existing"
|
||||
],
|
||||
"orientation" => "portrait",
|
||||
"orientation" => "any",
|
||||
"icons" => [
|
||||
[
|
||||
"src" => setting('app-icon-32') ?: url('/icon-32.png'),
|
||||
|
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Settings\SettingService;
|
||||
use BookStack\Users\Models\User;
|
||||
@ -42,9 +43,9 @@ function user(): User
|
||||
* Check if the current user has a permission. If an ownable element
|
||||
* is passed in the jointPermissions are checked against that particular item.
|
||||
*/
|
||||
function userCan(string $permission, Model $ownable = null): bool
|
||||
function userCan(string $permission, ?Model $ownable = null): bool
|
||||
{
|
||||
if ($ownable === null) {
|
||||
if (is_null($ownable)) {
|
||||
return user()->can($permission);
|
||||
}
|
||||
|
||||
@ -70,7 +71,7 @@ function userCanOnAny(string $action, string $entityClass = ''): bool
|
||||
*
|
||||
* @return mixed|SettingService
|
||||
*/
|
||||
function setting(string $key = null, $default = null)
|
||||
function setting(?string $key = null, mixed $default = null): mixed
|
||||
{
|
||||
$settingService = app()->make(SettingService::class);
|
||||
|
||||
@ -88,43 +89,10 @@ function setting(string $key = null, $default = null)
|
||||
*/
|
||||
function theme_path(string $path = ''): ?string
|
||||
{
|
||||
$theme = config('view.theme');
|
||||
|
||||
$theme = Theme::getTheme();
|
||||
if (!$theme) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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\ServiceProvider;
|
||||
|
||||
return [
|
||||
|
||||
@ -113,46 +114,20 @@ return [
|
||||
],
|
||||
|
||||
// Application Service Providers
|
||||
'providers' => [
|
||||
|
||||
// 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,
|
||||
|
||||
'providers' => ServiceProvider::defaultProviders()->merge([
|
||||
// Third party service providers
|
||||
Barryvdh\DomPDF\ServiceProvider::class,
|
||||
Barryvdh\Snappy\ServiceProvider::class,
|
||||
SocialiteProviders\Manager\ServiceProvider::class,
|
||||
|
||||
// BookStack custom service providers
|
||||
\BookStack\App\Providers\ThemeServiceProvider::class,
|
||||
\BookStack\App\Providers\AppServiceProvider::class,
|
||||
\BookStack\App\Providers\AuthServiceProvider::class,
|
||||
\BookStack\App\Providers\EventServiceProvider::class,
|
||||
\BookStack\App\Providers\RouteServiceProvider::class,
|
||||
\BookStack\App\Providers\TranslationServiceProvider::class,
|
||||
\BookStack\App\Providers\ValidationRuleServiceProvider::class,
|
||||
\BookStack\App\Providers\ViewTweaksServiceProvider::class,
|
||||
],
|
||||
BookStack\App\Providers\ThemeServiceProvider::class,
|
||||
BookStack\App\Providers\AppServiceProvider::class,
|
||||
BookStack\App\Providers\AuthServiceProvider::class,
|
||||
BookStack\App\Providers\EventServiceProvider::class,
|
||||
BookStack\App\Providers\RouteServiceProvider::class,
|
||||
BookStack\App\Providers\TranslationServiceProvider::class,
|
||||
BookStack\App\Providers\ValidationRuleServiceProvider::class,
|
||||
BookStack\App\Providers\ViewTweaksServiceProvider::class,
|
||||
])->toArray(),
|
||||
|
||||
// Class Aliases
|
||||
// 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',
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
@ -35,10 +35,6 @@ return [
|
||||
// Available caches stores
|
||||
'stores' => [
|
||||
|
||||
'apc' => [
|
||||
'driver' => 'apc',
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
@ -49,11 +45,13 @@ return [
|
||||
'table' => 'cache',
|
||||
'connection' => null,
|
||||
'lock_connection' => null,
|
||||
'lock_table' => null,
|
||||
],
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache'),
|
||||
'lock_path' => storage_path('framework/cache'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
|
@ -173,6 +173,8 @@ return [
|
||||
|
||||
// List of URIs that should not be collected
|
||||
'except' => [
|
||||
'/uploads/images/.*', // BookStack image requests
|
||||
|
||||
'/horizon/.*', // Laravel Horizon requests
|
||||
'/telescope/.*', // Laravel Telescope requests
|
||||
'/_debugbar/.*', // Laravel DebugBar requests
|
||||
|
@ -40,12 +40,16 @@ if (env('REDIS_SERVERS', false)) {
|
||||
|
||||
// MYSQL
|
||||
// Split out port from host if set
|
||||
$mysql_host = env('DB_HOST', 'localhost');
|
||||
$mysql_host_exploded = explode(':', $mysql_host);
|
||||
$mysql_port = env('DB_PORT', 3306);
|
||||
if (count($mysql_host_exploded) > 1) {
|
||||
$mysql_host = $mysql_host_exploded[0];
|
||||
$mysql_port = intval($mysql_host_exploded[1]);
|
||||
$mysqlHost = env('DB_HOST', 'localhost');
|
||||
$mysqlHostExploded = explode(':', $mysqlHost);
|
||||
$mysqlPort = env('DB_PORT', 3306);
|
||||
$mysqlHostIpv6 = str_starts_with($mysqlHost, '[');
|
||||
if ($mysqlHostIpv6 && str_contains($mysqlHost, ']:')) {
|
||||
$mysqlHost = implode(':', array_slice($mysqlHostExploded, 0, -1));
|
||||
$mysqlPort = intval(end($mysqlHostExploded));
|
||||
} else if (!$mysqlHostIpv6 && count($mysqlHostExploded) > 1) {
|
||||
$mysqlHost = $mysqlHostExploded[0];
|
||||
$mysqlPort = intval($mysqlHostExploded[1]);
|
||||
}
|
||||
|
||||
return [
|
||||
@ -61,12 +65,12 @@ return [
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DATABASE_URL'),
|
||||
'host' => $mysql_host,
|
||||
'host' => $mysqlHost,
|
||||
'database' => env('DB_DATABASE', 'forge'),
|
||||
'username' => env('DB_USERNAME', 'forge'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'port' => $mysql_port,
|
||||
'port' => $mysqlPort,
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
// Prefixes are only semi-supported and may be unstable
|
||||
@ -88,7 +92,7 @@ return [
|
||||
'database' => 'bookstack-test',
|
||||
'username' => env('MYSQL_USER', 'bookstack-test'),
|
||||
'password' => env('MYSQL_PASSWORD', 'bookstack-test'),
|
||||
'port' => $mysql_port,
|
||||
'port' => $mysqlPort,
|
||||
'charset' => 'utf8mb4',
|
||||
'collation' => 'utf8mb4_unicode_ci',
|
||||
'prefix' => '',
|
||||
|
@ -1,23 +1,49 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* DOMPDF configuration options.
|
||||
* Export 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.
|
||||
*/
|
||||
|
||||
$snappyPaperSizeMap = [
|
||||
'a4' => 'A4',
|
||||
'letter' => 'Letter',
|
||||
];
|
||||
|
||||
$dompdfPaperSizeMap = [
|
||||
'a4' => 'a4',
|
||||
'letter' => 'letter',
|
||||
];
|
||||
|
||||
$exportPageSize = env('EXPORT_PAGE_SIZE', 'a4');
|
||||
|
||||
return [
|
||||
|
||||
'show_warnings' => false, // Throw an Exception on warnings from dompdf
|
||||
// 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}"
|
||||
'pdf_command' => env('EXPORT_PDF_COMMAND', false),
|
||||
|
||||
'options' => [
|
||||
// The amount of time allowed for PDF generation command to run
|
||||
// before the process times out and is stopped.
|
||||
'pdf_command_timeout' => env('EXPORT_PDF_COMMAND_TIMEOUT', 15),
|
||||
|
||||
// 2024-04: Snappy/WKHTMLtoPDF now considered deprecated in regard to BookStack support.
|
||||
'snappy' => [
|
||||
'pdf_binary' => env('WKHTMLTOPDF', false),
|
||||
'options' => [
|
||||
'print-media-type' => true,
|
||||
'outline' => true,
|
||||
'page-size' => $snappyPaperSizeMap[$exportPageSize] ?? 'A4',
|
||||
],
|
||||
],
|
||||
|
||||
'dompdf' => [
|
||||
/**
|
||||
* The location of the DOMPDF font directory.
|
||||
*
|
||||
@ -88,6 +114,7 @@ return [
|
||||
* @var array
|
||||
*/
|
||||
'allowed_protocols' => [
|
||||
"data://" => ["rules" => []],
|
||||
'file://' => ['rules' => []],
|
||||
'http://' => ['rules' => []],
|
||||
'https://' => ['rules' => []],
|
||||
@ -101,7 +128,7 @@ return [
|
||||
/**
|
||||
* Whether to enable font subsetting or not.
|
||||
*/
|
||||
'enable_fontsubsetting' => false,
|
||||
'enable_font_subsetting' => false,
|
||||
|
||||
/**
|
||||
* The PDF rendering backend to use.
|
||||
@ -165,7 +192,7 @@ return [
|
||||
*
|
||||
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
|
||||
*/
|
||||
'default_paper_size' => $dompdfPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'a4',
|
||||
'default_paper_size' => $dompdfPaperSizeMap[$exportPageSize] ?? 'a4',
|
||||
|
||||
/**
|
||||
* The default paper orientation.
|
||||
@ -268,15 +295,6 @@ return [
|
||||
*/
|
||||
'font_height_ratio' => 1.1,
|
||||
|
||||
/**
|
||||
* Enable CSS float.
|
||||
*
|
||||
* Allows people to disabled CSS float support
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
'enable_css_float' => true,
|
||||
|
||||
/**
|
||||
* Use the HTML5 Lib parser.
|
||||
*
|
||||
@ -286,5 +304,4 @@ return [
|
||||
*/
|
||||
'enable_html5_parser' => true,
|
||||
],
|
||||
|
||||
];
|
@ -33,12 +33,14 @@ return [
|
||||
'driver' => 'local',
|
||||
'root' => public_path(),
|
||||
'visibility' => 'public',
|
||||
'serve' => false,
|
||||
'throw' => true,
|
||||
],
|
||||
|
||||
'local_secure_attachments' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('uploads/files/'),
|
||||
'serve' => false,
|
||||
'throw' => true,
|
||||
],
|
||||
|
||||
@ -46,6 +48,7 @@ return [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('uploads/images/'),
|
||||
'visibility' => 'public',
|
||||
'serve' => false,
|
||||
'throw' => true,
|
||||
],
|
||||
|
||||
@ -58,6 +61,7 @@ return [
|
||||
'endpoint' => env('STORAGE_S3_ENDPOINT', null),
|
||||
'use_path_style_endpoint' => env('STORAGE_S3_ENDPOINT', null) !== null,
|
||||
'throw' => true,
|
||||
'stream_reads' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
@ -21,7 +21,8 @@ return [
|
||||
// passwords are hashed using the Bcrypt algorithm. This will allow you
|
||||
// to control the amount of time it takes to hash the given password.
|
||||
'bcrypt' => [
|
||||
'rounds' => env('BCRYPT_ROUNDS', 10),
|
||||
'rounds' => env('BCRYPT_ROUNDS', 12),
|
||||
'verify' => true,
|
||||
],
|
||||
|
||||
// Argon Options
|
||||
|
@ -4,6 +4,7 @@ use Monolog\Formatter\LineFormatter;
|
||||
use Monolog\Handler\ErrorLogHandler;
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
|
||||
/**
|
||||
* Logging configuration options.
|
||||
@ -49,6 +50,7 @@ return [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => 'debug',
|
||||
'days' => 14,
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
@ -56,6 +58,7 @@ return [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => 'debug',
|
||||
'days' => 7,
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
@ -65,16 +68,20 @@ return [
|
||||
'with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'syslog' => [
|
||||
'driver' => 'syslog',
|
||||
'level' => 'debug',
|
||||
'facility' => LOG_USER,
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'errorlog' => [
|
||||
'driver' => 'errorlog',
|
||||
'level' => 'debug',
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
// Custom errorlog implementation that logs out a plain,
|
||||
@ -88,6 +95,7 @@ return [
|
||||
'formatter_with' => [
|
||||
'format' => '%message%',
|
||||
],
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'null' => [
|
||||
|
@ -38,7 +38,7 @@ return [
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'verify_peer' => env('MAIL_VERIFY_SSL', true),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN'),
|
||||
'local_domain' => null,
|
||||
'tls_required' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl'),
|
||||
],
|
||||
|
||||
@ -64,12 +64,4 @@ return [
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
// Email markdown configuration
|
||||
'markdown' => [
|
||||
'theme' => 'default',
|
||||
'paths' => [
|
||||
resource_path('views/vendor/mail'),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
@ -35,6 +35,13 @@ return [
|
||||
// OAuth2 endpoints.
|
||||
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
|
||||
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
|
||||
'userinfo_endpoint' => env('OIDC_USERINFO_ENDPOINT', null),
|
||||
|
||||
// OIDC RP-Initiated Logout endpoint URL.
|
||||
// A false value force-disables RP-Initiated Logout.
|
||||
// A true value gets the URL from discovery, if active.
|
||||
// A string value is used as the URL.
|
||||
'end_session_endpoint' => env('OIDC_END_SESSION_ENDPOINT', false),
|
||||
|
||||
// Add extra scopes, upon those required, to the OIDC authentication request
|
||||
// Multiple values can be provided comma seperated.
|
||||
@ -45,6 +52,6 @@ return [
|
||||
'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
|
||||
// Attribute, within a OIDC ID token, to find group names within
|
||||
'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'),
|
||||
// When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups.
|
||||
// When syncing groups, remove any groups that no longer match. Otherwise, sync only adds new groups.
|
||||
'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false),
|
||||
];
|
||||
|
@ -23,6 +23,7 @@ return [
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => null,
|
||||
'table' => 'jobs',
|
||||
'queue' => 'default',
|
||||
'retry_after' => 90,
|
||||
@ -40,6 +41,12 @@ return [
|
||||
|
||||
],
|
||||
|
||||
// Job batching
|
||||
'batching' => [
|
||||
'database' => 'mysql',
|
||||
'table' => 'job_batches',
|
||||
],
|
||||
|
||||
// Failed queue job logging
|
||||
'failed' => [
|
||||
'driver' => 'database-uuids',
|
||||
|
@ -123,7 +123,7 @@ return [
|
||||
'dn' => env('LDAP_DN', false),
|
||||
'pass' => env('LDAP_PASS', false),
|
||||
'base_dn' => env('LDAP_BASE_DN', false),
|
||||
'user_filter' => env('LDAP_USER_FILTER', '(&(uid=${user}))'),
|
||||
'user_filter' => env('LDAP_USER_FILTER', '(&(uid={user}))'),
|
||||
'version' => env('LDAP_VERSION', false),
|
||||
'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'),
|
||||
'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
|
||||
@ -133,6 +133,7 @@ return [
|
||||
'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
|
||||
'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false),
|
||||
'tls_insecure' => env('LDAP_TLS_INSECURE', false),
|
||||
'tls_ca_cert' => env('LDAP_TLS_CA_CERT', false),
|
||||
'start_tls' => env('LDAP_START_TLS', false),
|
||||
'thumbnail_attribute' => env('LDAP_THUMBNAIL_ATTRIBUTE', null),
|
||||
],
|
||||
|
@ -85,4 +85,11 @@ return [
|
||||
// do not enable this as other CSRF protection services are in place.
|
||||
// Options: lax, strict, none
|
||||
'same_site' => 'lax',
|
||||
|
||||
|
||||
// Partitioned Cookies
|
||||
// Setting this value to true will tie the cookie to the top-level site for
|
||||
// a cross-site context. Partitioned cookies are accepted by the browser
|
||||
// when flagged "secure" and the Same-Site attribute is set to "none".
|
||||
'partitioned' => false,
|
||||
];
|
||||
|
@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* SnappyPDF 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.
|
||||
*/
|
||||
|
||||
$snappyPaperSizeMap = [
|
||||
'a4' => 'A4',
|
||||
'letter' => 'Letter',
|
||||
];
|
||||
|
||||
return [
|
||||
'pdf' => [
|
||||
'enabled' => true,
|
||||
'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
|
||||
'timeout' => false,
|
||||
'options' => [
|
||||
'outline' => true,
|
||||
'page-size' => $snappyPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'A4',
|
||||
],
|
||||
'env' => [],
|
||||
],
|
||||
'image' => [
|
||||
'enabled' => false,
|
||||
'binary' => '/usr/local/bin/wkhtmltoimage',
|
||||
'timeout' => false,
|
||||
'options' => [],
|
||||
'env' => [],
|
||||
],
|
||||
];
|
99
app/Console/Commands/AssignSortRuleCommand.php
Normal file
99
app/Console/Commands/AssignSortRuleCommand.php
Normal file
@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Sorting\BookSorter;
|
||||
use BookStack\Sorting\SortRule;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class AssignSortRuleCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bookstack:assign-sort-rule
|
||||
{sort-rule=0: ID of the sort rule to apply}
|
||||
{--all-books : Apply to all books in the system}
|
||||
{--books-without-sort : Apply to only books without a sort rule already assigned}
|
||||
{--books-with-sort= : Apply to only books with the sort rule of given id}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Assign a sort rule to content in the system';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(BookSorter $sorter): int
|
||||
{
|
||||
$sortRuleId = intval($this->argument('sort-rule')) ?? 0;
|
||||
if ($sortRuleId === 0) {
|
||||
return $this->listSortRules();
|
||||
}
|
||||
|
||||
$rule = SortRule::query()->find($sortRuleId);
|
||||
if ($this->option('all-books')) {
|
||||
$query = Book::query();
|
||||
} else if ($this->option('books-without-sort')) {
|
||||
$query = Book::query()->whereNull('sort_rule_id');
|
||||
} else if ($this->option('books-with-sort')) {
|
||||
$sortId = intval($this->option('books-with-sort')) ?: 0;
|
||||
if (!$sortId) {
|
||||
$this->error("Provided --books-with-sort option value is invalid");
|
||||
return 1;
|
||||
}
|
||||
$query = Book::query()->where('sort_rule_id', $sortId);
|
||||
} else {
|
||||
$this->error("No option provided to specify target. Run with the -h option to see all available options.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!$rule) {
|
||||
$this->error("Sort rule of provided id {$sortRuleId} not found!");
|
||||
return 1;
|
||||
}
|
||||
|
||||
$count = $query->clone()->count();
|
||||
$this->warn("This will apply sort rule [{$rule->id}: {$rule->name}] to {$count} book(s) and run the sort on each.");
|
||||
$confirmed = $this->confirm("Are you sure you want to continue?");
|
||||
|
||||
if (!$confirmed) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
$query->chunkById(10, function ($books) use ($rule, $sorter, $count, &$processed) {
|
||||
$max = min($count, ($processed + 10));
|
||||
$this->info("Applying to {$processed}-{$max} of {$count} books");
|
||||
foreach ($books as $book) {
|
||||
$book->sort_rule_id = $rule->id;
|
||||
$book->save();
|
||||
$sorter->runBookAutoSort($book);
|
||||
}
|
||||
$processed = $max;
|
||||
});
|
||||
|
||||
$this->info("Sort applied to {$processed} book(s)!");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function listSortRules(): int
|
||||
{
|
||||
|
||||
$rules = SortRule::query()->orderBy('id', 'asc')->get();
|
||||
$this->error("Sort rule ID required!");
|
||||
$this->warn("\nAvailable sort rules:");
|
||||
foreach ($rules as $rule) {
|
||||
$this->info("{$rule->id}: {$rule->name}");
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
}
|
@ -19,7 +19,7 @@ class ClearActivityCommand extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Clear user activity from the system';
|
||||
protected $description = 'Clear user (audit-log) activity from the system';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@ -28,7 +28,7 @@ class CopyShelfPermissionsCommand extends Command
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(PermissionsUpdater $permissionsUpdater): int
|
||||
public function handle(PermissionsUpdater $permissionsUpdater, BookshelfQueries $queries): int
|
||||
{
|
||||
$shelfSlug = $this->option('slug');
|
||||
$cascadeAll = $this->option('all');
|
||||
@ -51,11 +51,11 @@ class CopyShelfPermissionsCommand extends Command
|
||||
return 0;
|
||||
}
|
||||
|
||||
$shelves = Bookshelf::query()->get(['id']);
|
||||
$shelves = $queries->start()->get(['id']);
|
||||
}
|
||||
|
||||
if ($shelfSlug) {
|
||||
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id']);
|
||||
$shelves = $queries->start()->where('slug', '=', $shelfSlug)->get(['id']);
|
||||
if ($shelves->count() === 0) {
|
||||
$this->info('No shelves found with the given slug.');
|
||||
}
|
||||
|
@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Activity\CommentRepo;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegenerateCommentContentCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bookstack:regenerate-comment-content
|
||||
{--database= : The database connection to use}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Regenerate the stored HTML of all comments';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(CommentRepo $commentRepo): int
|
||||
{
|
||||
$connection = DB::getDefaultConnection();
|
||||
if ($this->option('database') !== null) {
|
||||
DB::setDefaultConnection($this->option('database'));
|
||||
}
|
||||
|
||||
Comment::query()->chunk(100, function ($comments) use ($commentRepo) {
|
||||
foreach ($comments as $comment) {
|
||||
$comment->html = $commentRepo->commentToHtml($comment->text);
|
||||
$comment->save();
|
||||
}
|
||||
});
|
||||
|
||||
DB::setDefaultConnection($connection);
|
||||
$this->comment('Comment HTML content has been regenerated');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
@ -34,7 +34,7 @@ class RegenerateReferencesCommand extends Command
|
||||
DB::setDefaultConnection($this->option('database'));
|
||||
}
|
||||
|
||||
$references->updateForAllPages();
|
||||
$references->updateForAll();
|
||||
|
||||
DB::setDefaultConnection($connection);
|
||||
|
||||
|
@ -46,6 +46,10 @@ class UpdateUrlCommand extends Command
|
||||
$columnsToUpdateByTable = [
|
||||
'attachments' => ['path'],
|
||||
'pages' => ['html', 'text', 'markdown'],
|
||||
'chapters' => ['description_html'],
|
||||
'books' => ['description_html'],
|
||||
'bookshelves' => ['description_html'],
|
||||
'page_revisions' => ['html', 'text', 'markdown'],
|
||||
'images' => ['url'],
|
||||
'settings' => ['value'],
|
||||
'comments' => ['html', 'text'],
|
||||
@ -74,6 +78,12 @@ class UpdateUrlCommand extends Command
|
||||
$this->info('URL update procedure complete.');
|
||||
$this->info('============================================================================');
|
||||
$this->info('Be sure to run "php artisan cache:clear" to clear any old URLs in the cache.');
|
||||
|
||||
if (!str_starts_with($newUrl, url('/'))) {
|
||||
$this->warn('You still need to update your APP_URL env value. This is currently set to:');
|
||||
$this->warn(url('/'));
|
||||
}
|
||||
|
||||
$this->info('============================================================================');
|
||||
|
||||
return 0;
|
||||
|
@ -6,6 +6,8 @@ use BookStack\Api\ApiEntityListFormatter;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Http\ApiController;
|
||||
@ -14,11 +16,11 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BookApiController extends ApiController
|
||||
{
|
||||
protected BookRepo $bookRepo;
|
||||
|
||||
public function __construct(BookRepo $bookRepo)
|
||||
{
|
||||
$this->bookRepo = $bookRepo;
|
||||
public function __construct(
|
||||
protected BookRepo $bookRepo,
|
||||
protected BookQueries $queries,
|
||||
protected PageQueries $pageQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -26,7 +28,10 @@ class BookApiController extends ApiController
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$books = Book::visible();
|
||||
$books = $this->queries
|
||||
->visibleForList()
|
||||
->with(['cover:id,name,url'])
|
||||
->addSelect(['created_by', 'updated_by']);
|
||||
|
||||
return $this->apiListingResponse($books, [
|
||||
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by',
|
||||
@ -47,7 +52,7 @@ class BookApiController extends ApiController
|
||||
|
||||
$book = $this->bookRepo->create($requestData);
|
||||
|
||||
return response()->json($book);
|
||||
return response()->json($this->forJsonDisplay($book));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -58,14 +63,17 @@ class BookApiController extends ApiController
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy'])->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$book = $this->forJsonDisplay($book);
|
||||
$book->load(['createdBy', 'updatedBy', 'ownedBy']);
|
||||
|
||||
$contents = (new BookContents($book))->getTree(true, false)->all();
|
||||
$contentsApiData = (new ApiEntityListFormatter($contents))
|
||||
->withType()
|
||||
->withField('pages', function (Entity $entity) {
|
||||
if ($entity instanceof Chapter) {
|
||||
return (new ApiEntityListFormatter($entity->pages->all()))->format();
|
||||
$pages = $this->pageQueries->visibleForChapterList($entity->id)->get()->all();
|
||||
return (new ApiEntityListFormatter($pages))->format();
|
||||
}
|
||||
return null;
|
||||
})->format();
|
||||
@ -83,13 +91,13 @@ class BookApiController extends ApiController
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
|
||||
$requestData = $this->validate($request, $this->rules()['update']);
|
||||
$book = $this->bookRepo->update($book, $requestData);
|
||||
|
||||
return response()->json($book);
|
||||
return response()->json($this->forJsonDisplay($book));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -100,7 +108,7 @@ class BookApiController extends ApiController
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
$book = Book::visible()->findOrFail($id);
|
||||
$book = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission('book-delete', $book);
|
||||
|
||||
$this->bookRepo->destroy($book);
|
||||
@ -108,20 +116,36 @@ class BookApiController extends ApiController
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
protected function forJsonDisplay(Book $book): Book
|
||||
{
|
||||
$book = clone $book;
|
||||
$book->unsetRelations()->refresh();
|
||||
|
||||
$book->load(['tags', 'cover']);
|
||||
$book->makeVisible('description_html')
|
||||
->setAttribute('description_html', $book->descriptionHtml());
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'create' => [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1900'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
],
|
||||
'update' => [
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1900'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
@ -6,7 +6,8 @@ use BookStack\Activity\ActivityQueries;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
@ -24,15 +25,13 @@ use Throwable;
|
||||
|
||||
class BookController extends Controller
|
||||
{
|
||||
protected BookRepo $bookRepo;
|
||||
protected ShelfContext $shelfContext;
|
||||
protected ReferenceFetcher $referenceFetcher;
|
||||
|
||||
public function __construct(ShelfContext $entityContextManager, BookRepo $bookRepo, ReferenceFetcher $referenceFetcher)
|
||||
{
|
||||
$this->bookRepo = $bookRepo;
|
||||
$this->shelfContext = $entityContextManager;
|
||||
$this->referenceFetcher = $referenceFetcher;
|
||||
public function __construct(
|
||||
protected ShelfContext $shelfContext,
|
||||
protected BookRepo $bookRepo,
|
||||
protected BookQueries $queries,
|
||||
protected BookshelfQueries $shelfQueries,
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -47,10 +46,12 @@ class BookController extends Controller
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
]);
|
||||
|
||||
$books = $this->bookRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
|
||||
$recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed(4) : false;
|
||||
$popular = $this->bookRepo->getPopular(4);
|
||||
$new = $this->bookRepo->getRecentlyCreated(4);
|
||||
$books = $this->queries->visibleForListWithCover()
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
||||
->paginate(18);
|
||||
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
|
||||
$popular = $this->queries->popularForList()->take(4)->get();
|
||||
$new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get();
|
||||
|
||||
$this->shelfContext->clearShelfContext();
|
||||
|
||||
@ -69,13 +70,13 @@ class BookController extends Controller
|
||||
/**
|
||||
* Show the form for creating a new book.
|
||||
*/
|
||||
public function create(string $shelfSlug = null)
|
||||
public function create(?string $shelfSlug = null)
|
||||
{
|
||||
$this->checkPermission('book-create-all');
|
||||
|
||||
$bookshelf = null;
|
||||
if ($shelfSlug !== null) {
|
||||
$bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
|
||||
$bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
|
||||
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
|
||||
}
|
||||
|
||||
@ -92,19 +93,20 @@ class BookController extends Controller
|
||||
* @throws ImageUploadException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function store(Request $request, string $shelfSlug = null)
|
||||
public function store(Request $request, ?string $shelfSlug = null)
|
||||
{
|
||||
$this->checkPermission('book-create-all');
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$bookshelf = null;
|
||||
if ($shelfSlug !== null) {
|
||||
$bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
|
||||
$bookshelf = $this->shelfQueries->findVisibleBySlugOrFail($shelfSlug);
|
||||
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
|
||||
}
|
||||
|
||||
@ -123,7 +125,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($slug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$bookChildren = (new BookContents($book))->getTree(true);
|
||||
$bookParentShelves = $book->shelves()->scopes('visible')->get();
|
||||
|
||||
@ -141,7 +143,7 @@ class BookController extends Controller
|
||||
'bookParentShelves' => $bookParentShelves,
|
||||
'watchOptions' => new UserEntityWatchOptions(user(), $book),
|
||||
'activity' => $activities->entityActivity($book, 20, 1),
|
||||
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($book),
|
||||
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($book),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -150,7 +152,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function edit(string $slug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($slug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
$this->setPageTitle(trans('entities.books_edit_named', ['bookName' => $book->getShortName()]));
|
||||
|
||||
@ -166,14 +168,15 @@ class BookController extends Controller
|
||||
*/
|
||||
public function update(Request $request, string $slug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($slug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
if ($request->has('image_reset')) {
|
||||
@ -192,7 +195,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function showDelete(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-delete', $book);
|
||||
$this->setPageTitle(trans('entities.books_delete_named', ['bookName' => $book->getShortName()]));
|
||||
|
||||
@ -206,7 +209,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function destroy(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-delete', $book);
|
||||
|
||||
$this->bookRepo->destroy($book);
|
||||
@ -221,7 +224,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function showCopy(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-view', $book);
|
||||
|
||||
session()->flashInput(['name' => $book->name]);
|
||||
@ -238,7 +241,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function copy(Request $request, Cloner $cloner, string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-view', $book);
|
||||
$this->checkPermission('book-create-all');
|
||||
|
||||
@ -254,7 +257,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function convertToShelf(HierarchyTransformer $transformer, string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
$this->checkOwnablePermission('book-delete', $book);
|
||||
$this->checkPermission('bookshelf-create-all');
|
||||
|
@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\BookSortMap;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BookSortController extends Controller
|
||||
{
|
||||
protected $bookRepo;
|
||||
|
||||
public function __construct(BookRepo $bookRepo)
|
||||
{
|
||||
$this->bookRepo = $bookRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the view which allows pages to be re-ordered and sorted.
|
||||
*/
|
||||
public function show(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
|
||||
$bookChildren = (new BookContents($book))->getTree(false);
|
||||
|
||||
$this->setPageTitle(trans('entities.books_sort_named', ['bookName' => $book->getShortName()]));
|
||||
|
||||
return view('books.sort', ['book' => $book, 'current' => $book, 'bookChildren' => $bookChildren]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the sort box for a single book.
|
||||
* Used via AJAX when loading in extra books to a sort.
|
||||
*/
|
||||
public function showItem(string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$bookChildren = (new BookContents($book))->getTree();
|
||||
|
||||
return view('books.parts.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts a book using a given mapping array.
|
||||
*/
|
||||
public function update(Request $request, string $bookSlug)
|
||||
{
|
||||
$book = $this->bookRepo->getBySlug($bookSlug);
|
||||
$this->checkOwnablePermission('book-update', $book);
|
||||
|
||||
// Return if no map sent
|
||||
if (!$request->filled('sort-tree')) {
|
||||
return redirect($book->getUrl());
|
||||
}
|
||||
|
||||
$sortMap = BookSortMap::fromJson($request->get('sort-tree'));
|
||||
$bookContents = new BookContents($book);
|
||||
$booksInvolved = $bookContents->sortUsingMap($sortMap);
|
||||
|
||||
// Rebuild permissions and add activity for involved books.
|
||||
foreach ($booksInvolved as $bookInvolved) {
|
||||
Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
|
||||
}
|
||||
|
||||
return redirect($book->getUrl());
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Http\ApiController;
|
||||
use Exception;
|
||||
@ -12,11 +13,10 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BookshelfApiController extends ApiController
|
||||
{
|
||||
protected BookshelfRepo $bookshelfRepo;
|
||||
|
||||
public function __construct(BookshelfRepo $bookshelfRepo)
|
||||
{
|
||||
$this->bookshelfRepo = $bookshelfRepo;
|
||||
public function __construct(
|
||||
protected BookshelfRepo $bookshelfRepo,
|
||||
protected BookshelfQueries $queries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -24,7 +24,10 @@ class BookshelfApiController extends ApiController
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$shelves = Bookshelf::visible();
|
||||
$shelves = $this->queries
|
||||
->visibleForList()
|
||||
->with(['cover:id,name,url'])
|
||||
->addSelect(['created_by', 'updated_by']);
|
||||
|
||||
return $this->apiListingResponse($shelves, [
|
||||
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owned_by',
|
||||
@ -48,7 +51,7 @@ class BookshelfApiController extends ApiController
|
||||
$bookIds = $request->get('books', []);
|
||||
$shelf = $this->bookshelfRepo->create($requestData, $bookIds);
|
||||
|
||||
return response()->json($shelf);
|
||||
return response()->json($this->forJsonDisplay($shelf));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -56,12 +59,14 @@ class BookshelfApiController extends ApiController
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$shelf = Bookshelf::visible()->with([
|
||||
'tags', 'cover', 'createdBy', 'updatedBy', 'ownedBy',
|
||||
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$shelf = $this->forJsonDisplay($shelf);
|
||||
$shelf->load([
|
||||
'createdBy', 'updatedBy', 'ownedBy',
|
||||
'books' => function (BelongsToMany $query) {
|
||||
$query->scopes('visible')->get(['id', 'name', 'slug']);
|
||||
},
|
||||
])->findOrFail($id);
|
||||
]);
|
||||
|
||||
return response()->json($shelf);
|
||||
}
|
||||
@ -78,7 +83,7 @@ class BookshelfApiController extends ApiController
|
||||
*/
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$shelf = Bookshelf::visible()->findOrFail($id);
|
||||
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission('bookshelf-update', $shelf);
|
||||
|
||||
$requestData = $this->validate($request, $this->rules()['update']);
|
||||
@ -86,7 +91,7 @@ class BookshelfApiController extends ApiController
|
||||
|
||||
$shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
|
||||
|
||||
return response()->json($shelf);
|
||||
return response()->json($this->forJsonDisplay($shelf));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -97,7 +102,7 @@ class BookshelfApiController extends ApiController
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
$shelf = Bookshelf::visible()->findOrFail($id);
|
||||
$shelf = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission('bookshelf-delete', $shelf);
|
||||
|
||||
$this->bookshelfRepo->destroy($shelf);
|
||||
@ -105,22 +110,36 @@ class BookshelfApiController extends ApiController
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
protected function forJsonDisplay(Bookshelf $shelf): Bookshelf
|
||||
{
|
||||
$shelf = clone $shelf;
|
||||
$shelf->unsetRelations()->refresh();
|
||||
|
||||
$shelf->load(['tags', 'cover']);
|
||||
$shelf->makeVisible('description_html')
|
||||
->setAttribute('description_html', $shelf->descriptionHtml());
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'create' => [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'books' => ['array'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1900'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'books' => ['array'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
],
|
||||
'update' => [
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'books' => ['array'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1900'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'books' => ['array'],
|
||||
'tags' => ['array'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
@ -4,7 +4,8 @@ namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Activity\ActivityQueries;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Entities\Tools\ShelfContext;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
@ -18,15 +19,13 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BookshelfController extends Controller
|
||||
{
|
||||
protected BookshelfRepo $shelfRepo;
|
||||
protected ShelfContext $shelfContext;
|
||||
protected ReferenceFetcher $referenceFetcher;
|
||||
|
||||
public function __construct(BookshelfRepo $shelfRepo, ShelfContext $shelfContext, ReferenceFetcher $referenceFetcher)
|
||||
{
|
||||
$this->shelfRepo = $shelfRepo;
|
||||
$this->shelfContext = $shelfContext;
|
||||
$this->referenceFetcher = $referenceFetcher;
|
||||
public function __construct(
|
||||
protected BookshelfRepo $shelfRepo,
|
||||
protected BookshelfQueries $queries,
|
||||
protected BookQueries $bookQueries,
|
||||
protected ShelfContext $shelfContext,
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@ -41,10 +40,15 @@ class BookshelfController extends Controller
|
||||
'updated_at' => trans('common.sort_updated_at'),
|
||||
]);
|
||||
|
||||
$shelves = $this->shelfRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
|
||||
$recents = $this->isSignedIn() ? $this->shelfRepo->getRecentlyViewed(4) : false;
|
||||
$popular = $this->shelfRepo->getPopular(4);
|
||||
$new = $this->shelfRepo->getRecentlyCreated(4);
|
||||
$shelves = $this->queries->visibleForListWithCover()
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
||||
->paginate(18);
|
||||
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
|
||||
$popular = $this->queries->popularForList()->get();
|
||||
$new = $this->queries->visibleForList()
|
||||
->orderBy('created_at', 'desc')
|
||||
->take(4)
|
||||
->get();
|
||||
|
||||
$this->shelfContext->clearShelfContext();
|
||||
$this->setPageTitle(trans('entities.shelves'));
|
||||
@ -65,7 +69,7 @@ class BookshelfController extends Controller
|
||||
public function create()
|
||||
{
|
||||
$this->checkPermission('bookshelf-create-all');
|
||||
$books = Book::visible()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
|
||||
$books = $this->bookQueries->visibleForList()->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
|
||||
$this->setPageTitle(trans('entities.shelves_create'));
|
||||
|
||||
return view('shelves.create', ['books' => $books]);
|
||||
@ -81,10 +85,10 @@ class BookshelfController extends Controller
|
||||
{
|
||||
$this->checkPermission('bookshelf-create-all');
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
]);
|
||||
|
||||
$bookIds = explode(',', $request->get('books', ''));
|
||||
@ -100,7 +104,7 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('bookshelf-view', $shelf);
|
||||
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
|
||||
@ -129,7 +133,7 @@ class BookshelfController extends Controller
|
||||
'view' => $view,
|
||||
'activity' => $activities->entityActivity($shelf, 20, 1),
|
||||
'listOptions' => $listOptions,
|
||||
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($shelf),
|
||||
'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($shelf),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -138,11 +142,14 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function edit(string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('bookshelf-update', $shelf);
|
||||
|
||||
$shelfBookIds = $shelf->books()->get(['id'])->pluck('id');
|
||||
$books = Book::visible()->whereNotIn('id', $shelfBookIds)->orderBy('name')->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
|
||||
$books = $this->bookQueries->visibleForList()
|
||||
->whereNotIn('id', $shelfBookIds)
|
||||
->orderBy('name')
|
||||
->get(['name', 'id', 'slug', 'created_at', 'updated_at']);
|
||||
|
||||
$this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()]));
|
||||
|
||||
@ -161,13 +168,13 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function update(Request $request, string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('bookshelf-update', $shelf);
|
||||
$validated = $this->validate($request, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'image' => array_merge(['nullable'], $this->getImageValidationRules()),
|
||||
'tags' => ['array'],
|
||||
]);
|
||||
|
||||
if ($request->has('image_reset')) {
|
||||
@ -187,7 +194,7 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function showDelete(string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('bookshelf-delete', $shelf);
|
||||
|
||||
$this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()]));
|
||||
@ -202,7 +209,7 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function destroy(string $slug)
|
||||
{
|
||||
$shelf = $this->shelfRepo->getBySlug($slug);
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission('bookshelf-delete', $shelf);
|
||||
|
||||
$this->shelfRepo->destroy($shelf);
|
||||
|
@ -2,8 +2,9 @@
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Queries\ChapterQueries;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Http\ApiController;
|
||||
@ -15,23 +16,29 @@ class ChapterApiController extends ApiController
|
||||
{
|
||||
protected $rules = [
|
||||
'create' => [
|
||||
'book_id' => ['required', 'integer'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'tags' => ['array'],
|
||||
'priority' => ['integer'],
|
||||
'book_id' => ['required', 'integer'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['string', 'max:1900'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'tags' => ['array'],
|
||||
'priority' => ['integer'],
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
],
|
||||
'update' => [
|
||||
'book_id' => ['integer'],
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1000'],
|
||||
'tags' => ['array'],
|
||||
'priority' => ['integer'],
|
||||
'book_id' => ['integer'],
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'description' => ['string', 'max:1900'],
|
||||
'description_html' => ['string', 'max:2000'],
|
||||
'tags' => ['array'],
|
||||
'priority' => ['integer'],
|
||||
'default_template_id' => ['nullable', 'integer'],
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected ChapterRepo $chapterRepo
|
||||
protected ChapterRepo $chapterRepo,
|
||||
protected ChapterQueries $queries,
|
||||
protected EntityQueries $entityQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
@ -40,7 +47,8 @@ class ChapterApiController extends ApiController
|
||||
*/
|
||||
public function list()
|
||||
{
|
||||
$chapters = Chapter::visible();
|
||||
$chapters = $this->queries->visibleForList()
|
||||
->addSelect(['created_by', 'updated_by']);
|
||||
|
||||
return $this->apiListingResponse($chapters, [
|
||||
'id', 'book_id', 'name', 'slug', 'description', 'priority',
|
||||
@ -56,12 +64,12 @@ class ChapterApiController extends ApiController
|
||||
$requestData = $this->validate($request, $this->rules['create']);
|
||||
|
||||
$bookId = $request->get('book_id');
|
||||
$book = Book::visible()->findOrFail($bookId);
|
||||
$book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId));
|
||||
$this->checkOwnablePermission('chapter-create', $book);
|
||||
|
||||
$chapter = $this->chapterRepo->create($requestData, $book);
|
||||
|
||||
return response()->json($chapter->load(['tags']));
|
||||
return response()->json($this->forJsonDisplay($chapter));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -69,9 +77,17 @@ class ChapterApiController extends ApiController
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'ownedBy', 'pages' => function (HasMany $query) {
|
||||
$query->scopes('visible')->get(['id', 'name', 'slug']);
|
||||
}])->findOrFail($id);
|
||||
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$chapter = $this->forJsonDisplay($chapter);
|
||||
|
||||
$chapter->load(['createdBy', 'updatedBy', 'ownedBy']);
|
||||
|
||||
// Note: More fields than usual here, for backwards compatibility,
|
||||
// due to previously accidentally including more fields that desired.
|
||||
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)
|
||||
->addSelect(['created_by', 'updated_by', 'revision_count', 'editor'])
|
||||
->get();
|
||||
$chapter->setRelation('pages', $pages);
|
||||
|
||||
return response()->json($chapter);
|
||||
}
|
||||
@ -84,7 +100,7 @@ class ChapterApiController extends ApiController
|
||||
public function update(Request $request, string $id)
|
||||
{
|
||||
$requestData = $this->validate($request, $this->rules()['update']);
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission('chapter-update', $chapter);
|
||||
|
||||
if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) {
|
||||
@ -93,7 +109,7 @@ class ChapterApiController extends ApiController
|
||||
try {
|
||||
$this->chapterRepo->move($chapter, "book:{$requestData['book_id']}");
|
||||
} catch (Exception $exception) {
|
||||
if ($exception instanceof PermissionsException) {
|
||||
if ($exception instanceof PermissionsException) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
|
||||
@ -103,7 +119,7 @@ class ChapterApiController extends ApiController
|
||||
|
||||
$updatedChapter = $this->chapterRepo->update($chapter, $requestData);
|
||||
|
||||
return response()->json($updatedChapter->load(['tags']));
|
||||
return response()->json($this->forJsonDisplay($updatedChapter));
|
||||
}
|
||||
|
||||
/**
|
||||
@ -112,11 +128,24 @@ class ChapterApiController extends ApiController
|
||||
*/
|
||||
public function delete(string $id)
|
||||
{
|
||||
$chapter = Chapter::visible()->findOrFail($id);
|
||||
$chapter = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$this->checkOwnablePermission('chapter-delete', $chapter);
|
||||
|
||||
$this->chapterRepo->destroy($chapter);
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
protected function forJsonDisplay(Chapter $chapter): Chapter
|
||||
{
|
||||
$chapter = clone $chapter;
|
||||
$chapter->unsetRelations()->refresh();
|
||||
|
||||
$chapter->load(['tags']);
|
||||
$chapter->makeVisible('description_html');
|
||||
$chapter->setAttribute('description_html', $chapter->descriptionHtml());
|
||||
$chapter->setAttribute('book_slug', $chapter->book()->first()->slug);
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user