mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-05-04 18:08:46 +03:00
Compare commits
438 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad8fc95521 | ||
|
|
cca066a258 | ||
|
|
bbda5fd468 | ||
|
|
8429cc93eb | ||
|
|
fef61f054a | ||
|
|
8082c95ec3 | ||
|
|
fcabf478de | ||
|
|
8de2c28497 | ||
|
|
0838d5ea16 | ||
|
|
449ac40114 | ||
|
|
3131050acd | ||
|
|
c0d2874892 | ||
|
|
5940a91809 | ||
|
|
9a4651badb | ||
|
|
92d15d9cf2 | ||
|
|
b06147fef7 | ||
|
|
841350a937 | ||
|
|
12183bac07 | ||
|
|
e65b4b63a2 | ||
|
|
7cac3f4780 | ||
|
|
92cd11d105 | ||
|
|
13115ace84 | ||
|
|
73f9834e6f | ||
|
|
3afe855156 | ||
|
|
bfde896f0b | ||
|
|
1cdc0a7a3d | ||
|
|
d19b86640b | ||
|
|
2936ba609b | ||
|
|
573a2dd22a | ||
|
|
b55cc803d3 | ||
|
|
304ade418e | ||
|
|
997931c42f | ||
|
|
268e353431 | ||
|
|
b491b5fbca | ||
|
|
387c786768 | ||
|
|
2641586a6f | ||
|
|
6d2cd20e80 | ||
|
|
b0c574356a | ||
|
|
07e45a20e5 | ||
|
|
14056c69e6 | ||
|
|
fb9c840c46 | ||
|
|
5fba4a5399 | ||
|
|
c0b377050e | ||
|
|
f3efb6441d | ||
|
|
0cf313a21e | ||
|
|
26aadffb20 | ||
|
|
a5f48e3202 | ||
|
|
b0dda6e6a7 | ||
|
|
d4025d95e7 | ||
|
|
d6021f4d22 | ||
|
|
b9a3290731 | ||
|
|
48f235ea5a | ||
|
|
047771b9f4 | ||
|
|
b5375114d3 | ||
|
|
fc13e56cea | ||
|
|
77fc37ac25 | ||
|
|
3424351e84 | ||
|
|
606f9d92d0 | ||
|
|
a5e25abb9c | ||
|
|
b310e87e4c | ||
|
|
425baf9d6e | ||
|
|
825c369ad9 | ||
|
|
10bab70438 | ||
|
|
350e0b281b | ||
|
|
08805ea3c8 | ||
|
|
9441e32c69 | ||
|
|
530fc37067 | ||
|
|
369e499dce | ||
|
|
655815de6d | ||
|
|
457adc1fee | ||
|
|
e86a90967e | ||
|
|
5d08f7cf14 | ||
|
|
8744eb2d62 | ||
|
|
d8383cfa80 | ||
|
|
4626278447 | ||
|
|
c61af9c22b | ||
|
|
72521d0906 | ||
|
|
7e44b195c5 | ||
|
|
5b45eac5e1 | ||
|
|
c1d30341e7 | ||
|
|
80d2b4913b | ||
|
|
3f473528b1 | ||
|
|
d0dcd4f61b | ||
|
|
bde66a1396 | ||
|
|
4de5a2d9bf | ||
|
|
27bf4299cf | ||
|
|
164f01bb25 | ||
|
|
f563a005f5 | ||
|
|
a14d8e30cc | ||
|
|
a9194ffb63 | ||
|
|
2f9c1b7127 | ||
|
|
bbea76668b | ||
|
|
becc630acf | ||
|
|
4ac8ecad6b | ||
|
|
903e88c700 | ||
|
|
ed96aa820e | ||
|
|
63ec079b7b | ||
|
|
d485fcb3db | ||
|
|
0f895668a4 | ||
|
|
6c577ac3bf | ||
|
|
31cc2423d2 | ||
|
|
c9ed32e518 | ||
|
|
6b4c3a0969 | ||
|
|
2dad92d1bd | ||
|
|
c1fb7ab7dc | ||
|
|
98315f3899 | ||
|
|
8c82aaabd6 | ||
|
|
ce9b536b78 | ||
|
|
d9c50e5bc1 | ||
|
|
bf075f7dd8 | ||
|
|
a4fd673285 | ||
|
|
e794c977bc | ||
|
|
0b088ef1d3 | ||
|
|
bf6a6af683 | ||
|
|
914790fd99 | ||
|
|
edb0c6a9e8 | ||
|
|
84049de696 | ||
|
|
da0531e63b | ||
|
|
421dc75f4e | ||
|
|
8ae91df038 | ||
|
|
64b41dd626 | ||
|
|
ebd6e4d3a2 | ||
|
|
80374aea5c | ||
|
|
2ac9efae7d | ||
|
|
a11d565ba4 | ||
|
|
1fdf854ea7 | ||
|
|
e9c9792cb9 | ||
|
|
5ae524c25a | ||
|
|
0d7287fc8b | ||
|
|
e77c96f6b7 | ||
|
|
9b8a10dd3a | ||
|
|
49200ca5ce | ||
|
|
34aa4dbf10 | ||
|
|
5ee79d16c9 | ||
|
|
a1ea4006e0 | ||
|
|
9078188939 | ||
|
|
ed0aad1a7a | ||
|
|
5c59cfb020 | ||
|
|
3ca15ad68a | ||
|
|
60014989f5 | ||
|
|
57b10f195e | ||
|
|
b1e95eb39f | ||
|
|
b3da77b8f9 | ||
|
|
1a345b74bb | ||
|
|
8ffc3a4abf | ||
|
|
7233c1c7b2 | ||
|
|
1309a01131 | ||
|
|
0333185b6d | ||
|
|
83f89f64e8 | ||
|
|
11a1a6fb16 | ||
|
|
882c609296 | ||
|
|
176a0dcd59 | ||
|
|
94b0f70bfa | ||
|
|
08b2a77d41 | ||
|
|
3e8e9a23cf | ||
|
|
58b83b64c8 | ||
|
|
dfe4cde6ee | ||
|
|
d11144d9e2 | ||
|
|
f96b0ea5f3 | ||
|
|
815f8d79ed | ||
|
|
b62dab32e0 | ||
|
|
262f863981 | ||
|
|
a4c94390a1 | ||
|
|
53f3cca85d | ||
|
|
ed08bbcecc | ||
|
|
de97ebf9b7 | ||
|
|
f492a660a8 | ||
|
|
09436836a5 | ||
|
|
bb455d7788 | ||
|
|
009212ab80 | ||
|
|
ba9cb591c8 | ||
|
|
d00ac2f34e | ||
|
|
bd4dc6d463 | ||
|
|
d91180a909 | ||
|
|
bc2913a5cb | ||
|
|
4802394562 | ||
|
|
1755556468 | ||
|
|
01cdbdb7ae | ||
|
|
fc8bbf3eab | ||
|
|
3cdab19319 | ||
|
|
5661d20e87 | ||
|
|
91f80123e8 | ||
|
|
7a0636d0f8 | ||
|
|
0fe5bdfbac | ||
|
|
f88687e977 | ||
|
|
68d437d05b | ||
|
|
1e56aaea04 | ||
|
|
dab170a6fe | ||
|
|
a8de717d9b | ||
|
|
78fe95b6fc | ||
|
|
e0c24e41aa | ||
|
|
fa8553839b | ||
|
|
b8fcefc794 | ||
|
|
88bcb68fcb | ||
|
|
7c000553ae | ||
|
|
391fa35c80 | ||
|
|
c6773a8c9f | ||
|
|
9b226e7d39 | ||
|
|
9865446267 | ||
|
|
926abbe776 | ||
|
|
4fabef3a57 | ||
|
|
5ef4cd80c3 | ||
|
|
e01f23583f | ||
|
|
7792cb3915 | ||
|
|
be26253a18 | ||
|
|
1bdd1f8189 | ||
|
|
fa62c79b17 | ||
|
|
d7d8fa1e5b | ||
|
|
18562f1e10 | ||
|
|
86090a694f | ||
|
|
1ee8287c73 | ||
|
|
8eb98cd591 | ||
|
|
0f9ba21b05 | ||
|
|
834f8e7046 | ||
|
|
32e3399334 | ||
|
|
2d8698a218 | ||
|
|
454fb883a2 | ||
|
|
6f4a6ab8ea | ||
|
|
9c4b6f36f1 | ||
|
|
78886b1e67 | ||
|
|
d9debaf032 | ||
|
|
d4360d6347 | ||
|
|
175b1785c0 | ||
|
|
c8740c0171 | ||
|
|
91ee895a74 | ||
|
|
a045e46571 | ||
|
|
44eaa65c3b | ||
|
|
0a22af7b14 | ||
|
|
b54702ab08 | ||
|
|
c4fdcfc5d1 | ||
|
|
cb8117e8df | ||
|
|
5a218d5056 | ||
|
|
8dbc5cf9c6 | ||
|
|
71e81615a3 | ||
|
|
611d37da04 | ||
|
|
0e799a3857 | ||
|
|
b91d6e2bfa | ||
|
|
ea16ad7e94 | ||
|
|
ba6eb54552 | ||
|
|
f705e7683b | ||
|
|
dc996adb20 | ||
|
|
a64c638ccc | ||
|
|
359c067279 | ||
|
|
66a746e297 | ||
|
|
a4d43ee24b | ||
|
|
f7793a70a9 | ||
|
|
ceba3d31fb | ||
|
|
eecc08edde | ||
|
|
eb19aadc75 | ||
|
|
06c81e69b9 | ||
|
|
3dc3d4a639 | ||
|
|
94c59c1e3d | ||
|
|
4d2205853a | ||
|
|
751772b87a | ||
|
|
76e30869e1 | ||
|
|
3edc9fe9eb | ||
|
|
616c62703e | ||
|
|
ecd56917e7 | ||
|
|
e22c9cae91 | ||
|
|
29ddb6e1b9 | ||
|
|
2ff90e2ff0 | ||
|
|
04ecc128a2 | ||
|
|
87d1d3423b | ||
|
|
4818192a2a | ||
|
|
965dd97f54 | ||
|
|
195b74926c | ||
|
|
2120db12b2 | ||
|
|
ed563fef28 | ||
|
|
0d31a8e3f1 | ||
|
|
b8354b974b | ||
|
|
034c1e289d | ||
|
|
f31605a3de | ||
|
|
e7cc75c74d | ||
|
|
4b79d5e4e8 | ||
|
|
34854915b3 | ||
|
|
af6f34b529 | ||
|
|
fb82a2b896 | ||
|
|
5b464938b6 | ||
|
|
81f954890d | ||
|
|
0e2bbcec62 | ||
|
|
fdd339f525 | ||
|
|
8cf7d6a83d | ||
|
|
58a5008718 | ||
|
|
c44a8df55d | ||
|
|
ff1494c519 | ||
|
|
b8ce8fd852 | ||
|
|
75e7454a5f | ||
|
|
2558ea8931 | ||
|
|
ac0f47a4b2 | ||
|
|
4f16129869 | ||
|
|
64a8037fdd | ||
|
|
7502ba1bc8 | ||
|
|
33a04697ef | ||
|
|
b70a5c0cdb | ||
|
|
9443ae9f40 | ||
|
|
220c2a4102 | ||
|
|
e9914eb301 | ||
|
|
934512d09c | ||
|
|
9102c90986 | ||
|
|
c3e74219c4 | ||
|
|
13c9d7bc2d | ||
|
|
119b539586 | ||
|
|
29a5c180f0 | ||
|
|
7906602291 | ||
|
|
6dafe773ff | ||
|
|
25bc28a1be | ||
|
|
4c561c7fa0 | ||
|
|
95b3e78573 | ||
|
|
63a345bc93 | ||
|
|
e093a172cb | ||
|
|
4b01f8934b | ||
|
|
bc116b45b5 | ||
|
|
a059960b9e | ||
|
|
7770966fed | ||
|
|
d7adcf6c69 | ||
|
|
04a364dcc3 | ||
|
|
db83ac7eaa | ||
|
|
3ca9dddf61 | ||
|
|
bf74f53ca7 | ||
|
|
9d67efb4a4 | ||
|
|
3a39b9f440 | ||
|
|
27f7aab375 | ||
|
|
337da0c467 | ||
|
|
f56b3560c4 | ||
|
|
02dfe11ce6 | ||
|
|
83d06beb70 | ||
|
|
a8cfc059c8 | ||
|
|
1614b2bab0 | ||
|
|
4bdec0d214 | ||
|
|
6a7d7e7c2b | ||
|
|
30d4674657 | ||
|
|
9f961f95f8 | ||
|
|
bab99a26ec | ||
|
|
9a7fecd269 | ||
|
|
a8dc0d449b | ||
|
|
a0381f76bf | ||
|
|
6102f66daa | ||
|
|
c6134d162d | ||
|
|
2046f9b9de | ||
|
|
ac3ba594a4 | ||
|
|
22df25a480 | ||
|
|
8b30c7f02e | ||
|
|
757cdddc7c | ||
|
|
df95e99680 | ||
|
|
5a6d544db7 | ||
|
|
16117d329c | ||
|
|
e90da18ada | ||
|
|
a08d80e1cc | ||
|
|
6258175922 | ||
|
|
15736777a0 | ||
|
|
75915e8a94 | ||
|
|
9bde0ae4ea | ||
|
|
0c802d1f86 | ||
|
|
b7a96c6466 | ||
|
|
4b645a82c7 | ||
|
|
d599b77b6f | ||
|
|
26e93dc8c1 | ||
|
|
a4c9a8491b | ||
|
|
70ee636d87 | ||
|
|
b35f6dbb03 | ||
|
|
67d9e24d8f | ||
|
|
3903fda6ca | ||
|
|
441e46ebaa | ||
|
|
1f4260f359 | ||
|
|
dc0bf8ad4e | ||
|
|
102e326e6a | ||
|
|
2b25bf6f3b | ||
|
|
f93280696d | ||
|
|
1787391b07 | ||
|
|
a74a8ee483 | ||
|
|
7fa5405cb7 | ||
|
|
6725ddcc41 | ||
|
|
bce941db3f | ||
|
|
6d926048ec | ||
|
|
5335c973b4 | ||
|
|
15c3e5c96e | ||
|
|
a5d5904969 | ||
|
|
598758b991 | ||
|
|
9926e23bc8 | ||
|
|
5d3264bc63 | ||
|
|
d71f819f95 | ||
|
|
ee13509760 | ||
|
|
82d7bb1f32 | ||
|
|
cdfda508d8 | ||
|
|
da941e584f | ||
|
|
65874d7b96 | ||
|
|
ac9b8f405c | ||
|
|
8d1419a12e | ||
|
|
04f7a7d301 | ||
|
|
c10d2a1493 | ||
|
|
97bbf79ffd | ||
|
|
f7b01ae53d | ||
|
|
d704e1dbba | ||
|
|
ef2ff5e093 | ||
|
|
7caed3b0db | ||
|
|
45641d0754 | ||
|
|
4b1d08ba99 | ||
|
|
160fa99ba4 | ||
|
|
d2a5ab49ed | ||
|
|
c6404d8917 | ||
|
|
7113807f12 | ||
|
|
be711215e8 | ||
|
|
7e3b404240 | ||
|
|
e86901ca20 | ||
|
|
bdfa61c8b2 | ||
|
|
2cc36787f5 | ||
|
|
448ac61b48 | ||
|
|
753f6394f7 | ||
|
|
b1faf65934 | ||
|
|
09f478bd74 | ||
|
|
a0497feddd | ||
|
|
789693bde9 | ||
|
|
1fe933e4ea | ||
|
|
724b4b5a70 | ||
|
|
1778a56146 | ||
|
|
744865fcb2 | ||
|
|
7f8c8b448d | ||
|
|
a67c53826d | ||
|
|
14b131e850 | ||
|
|
9b55a52b85 | ||
|
|
db1d10e80f | ||
|
|
1be576966f | ||
|
|
b97e792c5f | ||
|
|
8dec674cc3 | ||
|
|
f784c03746 | ||
|
|
148e172fe8 | ||
|
|
56ae86646f | ||
|
|
1d2b6fdfa2 | ||
|
|
4fc75beed4 | ||
|
|
3b3bc0c4bf | ||
|
|
910faab88e | ||
|
|
f184d763ad | ||
|
|
a91d42634d | ||
|
|
f517ef3616 | ||
|
|
e99507ddcf | ||
|
|
d2cacf1945 | ||
|
|
448ac1405b | ||
|
|
6ad21ce885 |
@@ -351,25 +351,10 @@ EXPORT_PDF_COMMAND_TIMEOUT=15
|
||||
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
|
||||
WKHTMLTOPDF=false
|
||||
|
||||
# Allow JavaScript, and other potentiall dangerous content in page content.
|
||||
# This also removes CSP-level JavaScript control.
|
||||
# Allow <script> tags in page content
|
||||
# Note, if set to 'true' the page editor may still escape scripts.
|
||||
# DEPRECATED: Use 'APP_CONTENT_FILTERING' instead as detailed below. Activiting this option
|
||||
# effectively sets APP_CONTENT_FILTERING='' (No filtering)
|
||||
ALLOW_CONTENT_SCRIPTS=false
|
||||
|
||||
# Control the behaviour of content filtering, primarily used for page content.
|
||||
# This setting is a string of characters which represent different available filters:
|
||||
# - j - Filter out JavaScript and unknown binary data based content
|
||||
# - h - Filter out unexpected, and potentially dangerous, HTML elements
|
||||
# - f - Filter out unexpected form elements
|
||||
# - a - Run content through a more complex allowlist filter
|
||||
# This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
|
||||
# Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
|
||||
# Note: The default value will always be the most-strict, so it's advised to leave this unset in your own configuration
|
||||
# to ensure you are always using the full range of filters.
|
||||
APP_CONTENT_FILTERING="jfha"
|
||||
|
||||
# Indicate if robots/crawlers should crawl your instance.
|
||||
# Can be 'true', 'false' or 'null'.
|
||||
# The behaviour of the default 'null' option will depend on the 'app-public' admin setting.
|
||||
|
||||
27
.github/translators.txt
vendored
27
.github/translators.txt
vendored
@@ -444,7 +444,7 @@ Irjan Olsen (Irch) :: Norwegian Bokmal
|
||||
Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic)
|
||||
Red (RedVortex) :: Hebrew
|
||||
xgrug :: Chinese Simplified
|
||||
Calle Calmar (HrCalmar) :: Danish
|
||||
HrCalmar :: Danish
|
||||
Avishay Rapp (AvishayRapp) :: Hebrew
|
||||
matthias4217 :: French
|
||||
Berke BOYLU2 (berkeboylu2) :: Turkish
|
||||
@@ -512,28 +512,3 @@ David Olsen (dawin) :: Danish
|
||||
ltnzr :: French
|
||||
Frank Holler (holler.frank) :: German; German Informal
|
||||
Korab Arifi (korabidev) :: Albanian
|
||||
Petr Husák (petrhusak) :: Czech
|
||||
Bernardo Maia (bernardo.bmaia2) :: Portuguese, Brazilian
|
||||
Amr (amr3k) :: Arabic
|
||||
Tahsin Ahmed (tahsinahmed2012) :: Bengali
|
||||
bojan_che :: Serbian (Cyrillic)
|
||||
setiawan setiawan (culture.setiawan) :: Indonesian
|
||||
Donald Mac Kenzie (kiuman) :: Norwegian Bokmal
|
||||
Gabriel Silver (GabrielBSilver) :: Hebrew
|
||||
Tomas Darius Davainis (Tomasdd) :: Lithuanian
|
||||
CriedHero :: Chinese Simplified
|
||||
Henrik (henrik2105) :: Norwegian Bokmal
|
||||
FoW (fofwisdom) :: Korean
|
||||
serinf-lauza :: French
|
||||
Diyan Nikolaev (nikolaev.diyan) :: Bulgarian
|
||||
Shadluk Avan (quldosh) :: Uzbek
|
||||
Marci (MartonPoto) :: Hungarian
|
||||
Michał Sadurski (wheeskeey) :: Polish
|
||||
JanDziaslo :: Polish
|
||||
Charllys Fernandes (CharllysFernandes) :: Portuguese, Brazilian
|
||||
Ilgiz Zigangirov (inov8) :: Russian
|
||||
Max Israelsson (Blezie) :: Swedish
|
||||
Skiddybison5924 (chris-devel0per) :: German
|
||||
Veyilla Nightwhisper (Veyilla) :: German
|
||||
João Barbosa (hypeedd) :: Portuguese
|
||||
Abcdefg Hijklmn (collatek) :: Korean
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,10 +8,10 @@ Homestead.yaml
|
||||
.idea
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/public/dist
|
||||
/public/dist/*.map
|
||||
/public/plugins
|
||||
/public/css
|
||||
/public/js
|
||||
/public/css/*.map
|
||||
/public/js/*.map
|
||||
/public/bower
|
||||
/public/build/
|
||||
/public/favicon.ico
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-2026, 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
|
||||
|
||||
@@ -9,9 +9,11 @@ use Illuminate\Http\Request;
|
||||
|
||||
class OidcController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected OidcService $oidcService
|
||||
) {
|
||||
protected OidcService $oidcService;
|
||||
|
||||
public function __construct(OidcService $oidcService)
|
||||
{
|
||||
$this->oidcService = $oidcService;
|
||||
$this->middleware('guard:oidc');
|
||||
}
|
||||
|
||||
@@ -28,7 +30,7 @@ class OidcController extends Controller
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
session()->put('oidc_state', time() . ':' . $loginDetails['state']);
|
||||
session()->flash('oidc_state', $loginDetails['state']);
|
||||
|
||||
return redirect($loginDetails['url']);
|
||||
}
|
||||
@@ -39,16 +41,10 @@ class OidcController extends Controller
|
||||
*/
|
||||
public function callback(Request $request)
|
||||
{
|
||||
$storedState = session()->pull('oidc_state');
|
||||
$responseState = $request->query('state');
|
||||
$splitState = explode(':', session()->pull('oidc_state', ':'), 2);
|
||||
if (count($splitState) !== 2) {
|
||||
$splitState = [null, null];
|
||||
}
|
||||
|
||||
[$storedStateTime, $storedState] = $splitState;
|
||||
$threeMinutesAgo = time() - 3 * 60;
|
||||
|
||||
if (!$storedState || $storedState !== $responseState || intval($storedStateTime) < $threeMinutesAgo) {
|
||||
if ($storedState !== $responseState) {
|
||||
$this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
|
||||
|
||||
return redirect('/login');
|
||||
@@ -66,7 +62,7 @@ class OidcController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out, then start the OIDC RP-initiated logout process.
|
||||
* Log the user out then start the OIDC RP-initiated logout process.
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
|
||||
@@ -48,7 +48,8 @@ class RegisterController extends Controller
|
||||
public function postRegister(Request $request)
|
||||
{
|
||||
$this->registrationService->ensureRegistrationAllowed();
|
||||
$userData = $this->validator($request->all())->validate();
|
||||
$this->validator($request->all())->validate();
|
||||
$userData = $request->all();
|
||||
|
||||
try {
|
||||
$user = $this->registrationService->registerUser($userData);
|
||||
|
||||
@@ -14,9 +14,10 @@ use PragmaRX\Google2FA\Support\Constants;
|
||||
|
||||
class TotpService
|
||||
{
|
||||
public function __construct(
|
||||
protected Google2FA $google2fa
|
||||
) {
|
||||
protected $google2fa;
|
||||
|
||||
public function __construct(Google2FA $google2fa)
|
||||
{
|
||||
$this->google2fa = $google2fa;
|
||||
// Use SHA1 as a default, Personal testing of other options in 2021 found
|
||||
// many apps lack support for other algorithms yet still will scan
|
||||
@@ -34,7 +35,7 @@ class TotpService
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a TOTP URL from a secret key.
|
||||
* Generate a TOTP URL from secret key.
|
||||
*/
|
||||
public function generateUrl(string $secret, User $user): string
|
||||
{
|
||||
|
||||
@@ -49,11 +49,6 @@ class OidcService
|
||||
$url = $provider->getAuthorizationUrl();
|
||||
session()->put('oidc_pkce_code', $provider->getPkceCode() ?? '');
|
||||
|
||||
$returnUrl = Theme::dispatch(ThemeEvents::OIDC_AUTH_PRE_REDIRECT, $url);
|
||||
if (is_string($returnUrl)) {
|
||||
$url = $returnUrl;
|
||||
}
|
||||
|
||||
return [
|
||||
'url' => $url,
|
||||
'state' => $provider->getState(),
|
||||
|
||||
@@ -83,7 +83,7 @@ class RegistrationService
|
||||
// Email restriction
|
||||
$this->ensureEmailDomainAllowed($userEmail);
|
||||
|
||||
// Ensure the user does not already exist
|
||||
// Ensure user does not already exist
|
||||
$alreadyUser = !is_null($this->userRepo->getByEmail($userEmail));
|
||||
if ($alreadyUser) {
|
||||
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login');
|
||||
@@ -99,7 +99,7 @@ class RegistrationService
|
||||
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
|
||||
$newUser->attachDefaultRole();
|
||||
|
||||
// Assign a social account if given
|
||||
// Assign social account if given
|
||||
if ($socialAccount) {
|
||||
$newUser->socialAccounts()->save($socialAccount);
|
||||
}
|
||||
@@ -107,7 +107,7 @@ class RegistrationService
|
||||
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
|
||||
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $authSystem, $newUser);
|
||||
|
||||
// Start the email confirmation flow if required
|
||||
// Start email confirmation flow if required
|
||||
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
|
||||
$newUser->save();
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||
use BookStack\Users\Models\OwnableInterface;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -42,19 +41,7 @@ class Comment extends Model implements Loggable, OwnableInterface
|
||||
*/
|
||||
public function entity(): MorphTo
|
||||
{
|
||||
// We specifically define null here to avoid the different name (commentable)
|
||||
// being used by Laravel eager loading instead of the method name, which it was doing
|
||||
// in some scenarios like when deserialized when going through the queue system.
|
||||
// So we instead specify the type and id column names to use.
|
||||
// Related to:
|
||||
// https://github.com/laravel/framework/pull/24815
|
||||
// https://github.com/laravel/framework/issues/27342
|
||||
// https://github.com/laravel/framework/issues/47953
|
||||
// (and probably more)
|
||||
|
||||
// Ultimately, we could just align the method name to 'commentable' but that would be a potential
|
||||
// breaking change and not really worthwhile in a patch due to the risk of creating extra problems.
|
||||
return $this->morphTo(null, 'commentable_type', 'commentable_id');
|
||||
return $this->morphTo('commentable');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,8 +70,7 @@ class Comment extends Model implements Loggable, OwnableInterface
|
||||
|
||||
public function safeHtml(): string
|
||||
{
|
||||
$filter = new HtmlContentFilter(new HtmlContentFilterConfig());
|
||||
return $filter->filterString($this->html ?? '');
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $mentionable_type
|
||||
* @property int $mentionable_id
|
||||
* @property int $from_user_id
|
||||
* @property int $to_user_id
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class MentionHistory extends Model
|
||||
{
|
||||
protected $table = 'mention_history';
|
||||
}
|
||||
@@ -20,7 +20,6 @@ abstract class BaseNotificationHandler implements NotificationHandler
|
||||
{
|
||||
$users = User::query()->whereIn('id', array_unique($userIds))->get();
|
||||
|
||||
/** @var User $user */
|
||||
foreach ($users as $user) {
|
||||
// Prevent sending to the user that initiated the activity
|
||||
if ($user->id === $initiator->id) {
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Handlers;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Models\MentionHistory;
|
||||
use BookStack\Activity\Notifications\Messages\CommentMentionNotification;
|
||||
use BookStack\Activity\Tools\MentionParser;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Settings\UserNotificationPreferences;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class CommentMentionNotificationHandler extends BaseNotificationHandler
|
||||
{
|
||||
public function handle(Activity $activity, Loggable|string $detail, User $user): void
|
||||
{
|
||||
if (!($detail instanceof Comment) || !($detail->entity instanceof Page)) {
|
||||
throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page");
|
||||
}
|
||||
|
||||
/** @var Page $page */
|
||||
$page = $detail->entity;
|
||||
|
||||
$parser = new MentionParser();
|
||||
$mentionedUserIds = $parser->parseUserIdsFromHtml($detail->html);
|
||||
$realMentionedUsers = User::whereIn('id', $mentionedUserIds)->get();
|
||||
|
||||
$receivingNotifications = $realMentionedUsers->filter(function (User $user) {
|
||||
$prefs = new UserNotificationPreferences($user);
|
||||
return $prefs->notifyOnCommentMentions();
|
||||
});
|
||||
$receivingNotificationsUserIds = $receivingNotifications->pluck('id')->toArray();
|
||||
|
||||
$userMentionsToLog = $realMentionedUsers;
|
||||
|
||||
// When an edit, we check our history to see if we've already notified the user about this comment before
|
||||
// so that we can filter them out to avoid double notifications.
|
||||
if ($activity->type === ActivityType::COMMENT_UPDATE) {
|
||||
$previouslyNotifiedUserIds = $this->getPreviouslyNotifiedUserIds($detail);
|
||||
$receivingNotificationsUserIds = array_values(array_diff($receivingNotificationsUserIds, $previouslyNotifiedUserIds));
|
||||
$userMentionsToLog = $userMentionsToLog->filter(function (User $user) use ($previouslyNotifiedUserIds) {
|
||||
return !in_array($user->id, $previouslyNotifiedUserIds);
|
||||
});
|
||||
}
|
||||
|
||||
$this->logMentions($userMentionsToLog, $detail, $user);
|
||||
$this->sendNotificationToUserIds(CommentMentionNotification::class, $receivingNotificationsUserIds, $user, $detail, $page);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<User> $mentionedUsers
|
||||
*/
|
||||
protected function logMentions(Collection $mentionedUsers, Comment $comment, User $fromUser): void
|
||||
{
|
||||
$mentions = [];
|
||||
$now = Carbon::now();
|
||||
|
||||
foreach ($mentionedUsers as $mentionedUser) {
|
||||
$mentions[] = [
|
||||
'mentionable_type' => $comment->getMorphClass(),
|
||||
'mentionable_id' => $comment->id,
|
||||
'from_user_id' => $fromUser->id,
|
||||
'to_user_id' => $mentionedUser->id,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
MentionHistory::query()->insert($mentions);
|
||||
}
|
||||
|
||||
protected function getPreviouslyNotifiedUserIds(Comment $comment): array
|
||||
{
|
||||
return MentionHistory::query()
|
||||
->where('mentionable_id', $comment->id)
|
||||
->where('mentionable_type', $comment->getMorphClass())
|
||||
->pluck('to_user_id')
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Messages;
|
||||
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
|
||||
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class CommentMentionNotification extends BaseActivityNotification
|
||||
{
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
/** @var Comment $comment */
|
||||
$comment = $this->detail;
|
||||
/** @var Page $page */
|
||||
$page = $comment->entity;
|
||||
|
||||
$locale = $notifiable->getLocale();
|
||||
|
||||
$listLines = array_filter([
|
||||
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
||||
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
||||
$locale->trans('notifications.detail_commenter') => $this->user->name,
|
||||
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
|
||||
]);
|
||||
|
||||
return $this->newMailMessage($locale)
|
||||
->subject($locale->trans('notifications.comment_mention_subject', ['pageName' => $page->getShortName()]))
|
||||
->line($locale->trans('notifications.comment_mention_intro', ['appName' => setting('app-name')]))
|
||||
->line(new ListMessageLine($listLines))
|
||||
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
|
||||
->line($this->buildReasonFooterLine($locale));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
|
||||
use BookStack\Activity\Notifications\Handlers\CommentMentionNotificationHandler;
|
||||
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
|
||||
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
|
||||
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
|
||||
@@ -49,7 +48,5 @@ class NotificationManager
|
||||
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
|
||||
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
|
||||
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
|
||||
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class);
|
||||
$this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Tools;
|
||||
|
||||
use BookStack\Util\HtmlDocument;
|
||||
use DOMElement;
|
||||
|
||||
class MentionParser
|
||||
{
|
||||
public function parseUserIdsFromHtml(string $html): array
|
||||
{
|
||||
$doc = new HtmlDocument($html);
|
||||
|
||||
$ids = [];
|
||||
$mentionLinks = $doc->queryXPath('//a[@data-mention-user-id]');
|
||||
|
||||
foreach ($mentionLinks as $link) {
|
||||
if ($link instanceof DOMElement) {
|
||||
$id = intval($link->getAttribute('data-mention-user-id'));
|
||||
if ($id > 0) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ class HomeController extends Controller
|
||||
if ($homepageOption === 'bookshelves') {
|
||||
$shelves = $this->queries->shelves->visibleForListWithCover()
|
||||
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
||||
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
|
||||
->paginate(18);
|
||||
$data = array_merge($commonData, ['shelves' => $shelves]);
|
||||
|
||||
return view('home.shelves', $data);
|
||||
@@ -92,7 +92,7 @@ class HomeController extends Controller
|
||||
if ($homepageOption === 'books') {
|
||||
$books = $this->queries->books->visibleForListWithCover()
|
||||
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
||||
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
|
||||
->paginate(18);
|
||||
$data = array_merge($commonData, ['books' => $books]);
|
||||
|
||||
return view('home.books', $data);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace BookStack\App\Providers;
|
||||
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Activity\Tools\ActivityLogger;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
@@ -65,13 +64,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
URL::forceScheme($isHttps ? 'https' : 'http');
|
||||
}
|
||||
|
||||
// Set SMTP mail driver to use a local domain matching the app domain,
|
||||
// which helps avoid defaulting to a 127.0.0.1 domain
|
||||
if ($appUrl) {
|
||||
$hostName = parse_url($appUrl, PHP_URL_HOST) ?: null;
|
||||
config()->set('mail.mailers.smtp.local_domain', $hostName);
|
||||
}
|
||||
|
||||
// Allow longer string lengths after upgrade to utf8mb4
|
||||
Schema::defaultStringLength(191);
|
||||
|
||||
@@ -81,7 +73,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
'book' => Book::class,
|
||||
'chapter' => Chapter::class,
|
||||
'page' => Page::class,
|
||||
'comment' => Comment::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ namespace BookStack\App\Providers;
|
||||
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Theming\ThemeService;
|
||||
use BookStack\Theming\ThemeViews;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class ThemeServiceProvider extends ServiceProvider
|
||||
@@ -26,26 +24,7 @@ class ThemeServiceProvider extends ServiceProvider
|
||||
{
|
||||
// Boot up the theme system
|
||||
$themeService = $this->app->make(ThemeService::class);
|
||||
$viewFactory = $this->app->make('view');
|
||||
$themeViews = new ThemeViews($viewFactory->getFinder());
|
||||
|
||||
// Use a custom include so that we can insert theme views before/after includes.
|
||||
// This is done, even if no theme is active, so that view caching does not create problems
|
||||
// when switching between themes or when switching a theme on/off.
|
||||
$viewFactory->share('__themeViews', $themeViews);
|
||||
Blade::directive('include', function ($expression) {
|
||||
return "<?php echo \$__themeViews->handleViewInclude({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>";
|
||||
});
|
||||
|
||||
if (!$themeService->getTheme()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$themeService->loadModules();
|
||||
$themeService->readThemeActions();
|
||||
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
|
||||
|
||||
$themeViews->registerViewPathsForTheme($themeService->getModules());
|
||||
$themeService->dispatch(ThemeEvents::THEME_REGISTER_VIEWS, $themeViews);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ namespace BookStack\App;
|
||||
/**
|
||||
* Assigned to models that can have slugs.
|
||||
* Must have the below properties.
|
||||
*
|
||||
* @property string $slug
|
||||
*/
|
||||
interface SluggableInterface
|
||||
{
|
||||
/**
|
||||
* Regenerate the slug for this model.
|
||||
*/
|
||||
public function refreshSlug(): string;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@ function setting(?string $key = null, mixed $default = null): mixed
|
||||
|
||||
/**
|
||||
* Get a path to a theme resource.
|
||||
* Returns null if a theme is not configured, and therefore a full path is not available for use.
|
||||
* Returns null if a theme is not configured and
|
||||
* therefore a full path is not available for use.
|
||||
*/
|
||||
function theme_path(string $path = ''): ?string
|
||||
{
|
||||
|
||||
@@ -37,15 +37,10 @@ return [
|
||||
// The limit for all uploaded files, including images and attachments in MB.
|
||||
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
|
||||
|
||||
// Control the behaviour of content filtering, primarily used for page content.
|
||||
// This setting is a string of characters which represent different available filters:
|
||||
// - j - Filter out JavaScript and unknown binary data based content
|
||||
// - h - Filter out unexpected, and potentially dangerous, HTML elements
|
||||
// - f - Filter out unexpected form elements
|
||||
// - a - Run content through a more complex allowlist filter
|
||||
// This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
|
||||
// Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
|
||||
'content_filtering' => env('APP_CONTENT_FILTERING', env('ALLOW_CONTENT_SCRIPTS', false) === true ? '' : 'jhfa'),
|
||||
// Allow <script> tags to entered within page content.
|
||||
// <script> tags are escaped by default.
|
||||
// Even when overridden the WYSIWYG editor may still escape script content.
|
||||
'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false),
|
||||
|
||||
// Allow server-side fetches to be performed to potentially unknown
|
||||
// and user-provided locations. Primarily used in exports when loading
|
||||
@@ -53,8 +48,8 @@ return [
|
||||
'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
|
||||
|
||||
// Override the default behaviour for allowing crawlers to crawl the instance.
|
||||
// May be ignored if the underlying view has been overridden or modified.
|
||||
// Defaults to null in which case the 'app-public' status is used instead.
|
||||
// May be ignored if view has be overridden or modified.
|
||||
// Defaults to null since, if not set, 'app-public' status used instead.
|
||||
'allow_robots' => env('ALLOW_ROBOTS', null),
|
||||
|
||||
// Application Base URL, Used by laravel in development commands
|
||||
|
||||
@@ -81,8 +81,7 @@ return [
|
||||
'strict' => false,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
// @phpstan-ignore class.notFound
|
||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ return [
|
||||
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
|
||||
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
|
||||
'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'),
|
||||
'notifications#comment-mentions' => true,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||
*/
|
||||
|
||||
// Join up possible view locations
|
||||
$viewPaths = [realpath(base_path('resources/views'))];
|
||||
if ($theme = env('APP_THEME', false)) {
|
||||
array_unshift($viewPaths, base_path('themes/' . $theme));
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
// App theme
|
||||
@@ -20,7 +26,7 @@ return [
|
||||
// Most templating systems load templates from disk. Here you may specify
|
||||
// an array of paths that should be checked for your views. Of course
|
||||
// the usual Laravel view path has already been registered for you.
|
||||
'paths' => [realpath(base_path('resources/views'))],
|
||||
'paths' => $viewPaths,
|
||||
|
||||
// Compiled View Path
|
||||
// This option determines where all the compiled Blade templates will be
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Http\HttpRequestService;
|
||||
use BookStack\Theming\ThemeModule;
|
||||
use BookStack\Theming\ThemeModuleException;
|
||||
use BookStack\Theming\ThemeModuleManager;
|
||||
use BookStack\Theming\ThemeModuleZip;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class InstallModuleCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bookstack:install-module
|
||||
{location : The URL or path of the module file}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Install a module to the currently configured theme';
|
||||
|
||||
protected array $cleanupActions = [];
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$location = $this->argument('location');
|
||||
|
||||
// Get the ZIP file containing the module files
|
||||
$zipPath = $this->getPathToZip($location);
|
||||
if (!$zipPath) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Validate module zip file (metadata, size, etc...) and get module instance
|
||||
$zip = new ThemeModuleZip($zipPath);
|
||||
$themeModule = $this->validateAndGetModuleInfoFromZip($zip);
|
||||
if (!$themeModule) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get the theme folder in use, attempting to create one if no active theme in use
|
||||
$themeFolder = $this->getThemeFolder();
|
||||
if (!$themeFolder) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get the modules folder of the theme, attempting to create it if not existing,
|
||||
// and create a new module manager instance.
|
||||
$moduleFolder = $this->getModuleFolder($themeFolder);
|
||||
if (!$moduleFolder) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
$manager = new ThemeModuleManager($moduleFolder);
|
||||
|
||||
// Handle existing modules with the same name
|
||||
$exitingModulesWithName = $manager->getByName($themeModule->name);
|
||||
$shouldContinue = $this->handleExistingModulesWithSameName($exitingModulesWithName, $manager);
|
||||
if (!$shouldContinue) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Extract module ZIP into the theme modules folder
|
||||
try {
|
||||
$newModule = $manager->addFromZip($themeModule->name, $zip);
|
||||
} catch (ThemeModuleException $exception) {
|
||||
$this->error("ERROR: Failed to install module with error: {$exception->getMessage()}");
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Module \"{$newModule->name}\" ({$newModule->getVersion()}) successfully installed!");
|
||||
$this->info("Install location: {$moduleFolder}/{$newModule->folderName}");
|
||||
$this->cleanup();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ThemeModule[] $existingModules
|
||||
*/
|
||||
protected function handleExistingModulesWithSameName(array $existingModules, ThemeModuleManager $manager): bool
|
||||
{
|
||||
if (count($existingModules) === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->warn("The following modules already exist with the same name:");
|
||||
foreach ($existingModules as $folder => $module) {
|
||||
$this->line("{$module->name} ({$folder}:{$module->getVersion()}) - {$module->description}");
|
||||
}
|
||||
$this->line('');
|
||||
|
||||
$choices = ['Cancel module install', 'Add alongside existing module'];
|
||||
if (count($existingModules) === 1) {
|
||||
$choices[] = 'Replace existing module';
|
||||
}
|
||||
$choice = $this->choice("What would you like to do?", $choices, 0, null, false);
|
||||
if ($choice === 'Cancel module install') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($choice === 'Replace existing module') {
|
||||
$existingModuleFolder = array_key_first($existingModules);
|
||||
$this->info("Replacing existing module in {$existingModuleFolder} folder");
|
||||
$manager->deleteModuleFolder($existingModuleFolder);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getModuleFolder(string $themeFolder): string|null
|
||||
{
|
||||
$path = $themeFolder . DIRECTORY_SEPARATOR . 'modules';
|
||||
|
||||
if (file_exists($path) && !is_dir($path)) {
|
||||
$this->error("ERROR: Cannot create a modules folder, file already exists at {$path}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!file_exists($path)) {
|
||||
$created = mkdir($path, 0755, true);
|
||||
if (!$created) {
|
||||
$this->error("ERROR: Failed to create a modules folder at {$path}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
protected function getThemeFolder(): string|null
|
||||
{
|
||||
$path = theme_path('');
|
||||
if (!$path || !is_dir($path)) {
|
||||
$shouldCreate = $this->confirm('No active theme folder found, would you like to create one?');
|
||||
if (!$shouldCreate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$folder = 'custom';
|
||||
while (file_exists(base_path("themes" . DIRECTORY_SEPARATOR . $folder))) {
|
||||
$folder = 'custom-' . Str::random(4);
|
||||
}
|
||||
|
||||
$path = base_path("themes/{$folder}");
|
||||
$created = mkdir($path, 0755, true);
|
||||
if (!$created) {
|
||||
$this->error('Failed to create a theme folder to use. This may be a permissions issue. Try manually configuring an active theme');
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->info("Created theme folder at {$path}");
|
||||
$this->warn("You will need to set APP_THEME={$folder} in your BookStack env configuration to enable this theme!");
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
protected function validateAndGetModuleInfoFromZip(ThemeModuleZip $zip): ThemeModule|null
|
||||
{
|
||||
if (!$zip->exists()) {
|
||||
$this->error("ERROR: Cannot open ZIP file at {$zip->getPath()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($zip->getContentsSize() > (50 * 1024 * 1024)) {
|
||||
$this->error("ERROR: Module ZIP file contents are too large. Maximum size is 50MB");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$themeModule = $zip->getModuleInstance();
|
||||
} catch (ThemeModuleException $exception) {
|
||||
$this->error("ERROR: Failed to read module metadata with error: {$exception->getMessage()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return $themeModule;
|
||||
}
|
||||
|
||||
protected function downloadModuleFile(string $location): string|null
|
||||
{
|
||||
$httpRequests = app()->make(HttpRequestService::class);
|
||||
$client = $httpRequests->buildClient(30, ['stream' => true]);
|
||||
$originalUrl = parse_url($location);
|
||||
$currentLocation = $location;
|
||||
$maxRedirects = 3;
|
||||
$redirectCount = 0;
|
||||
|
||||
// Follow redirects up to 3 times for the same hostname
|
||||
do {
|
||||
$resp = $client->sendRequest(new Request('GET', $currentLocation));
|
||||
$statusCode = $resp->getStatusCode();
|
||||
|
||||
if ($statusCode >= 300 && $statusCode < 400 && $redirectCount < $maxRedirects) {
|
||||
$redirectLocation = $resp->getHeaderLine('Location');
|
||||
if ($redirectLocation) {
|
||||
$redirectUrl = parse_url($redirectLocation);
|
||||
if (
|
||||
($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '')
|
||||
&& ($originalUrl['scheme'] ?? '') === ($redirectUrl['scheme'] ?? '')
|
||||
&& ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? '')
|
||||
) {
|
||||
$currentLocation = $redirectLocation;
|
||||
$redirectCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
} while (true);
|
||||
|
||||
if ($resp->getStatusCode() >= 300) {
|
||||
$this->error("ERROR: Failed to download module from {$location}");
|
||||
$this->error("Download failed with status code {$resp->getStatusCode()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'bookstack_module_');
|
||||
$fileHandle = fopen($tempFile, 'w');
|
||||
$respBody = $resp->getBody();
|
||||
$size = 0;
|
||||
$maxSize = 50 * 1024 * 1024;
|
||||
|
||||
while (!$respBody->eof()) {
|
||||
fwrite($fileHandle, $respBody->read(1024));
|
||||
$size += 1024;
|
||||
if ($size > $maxSize) {
|
||||
fclose($fileHandle);
|
||||
unlink($tempFile);
|
||||
$this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB");
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
fclose($fileHandle);
|
||||
|
||||
$this->cleanupActions[] = function () use ($tempFile) {
|
||||
unlink($tempFile);
|
||||
};
|
||||
|
||||
return $tempFile;
|
||||
}
|
||||
|
||||
protected function getPathToZip(string $location): string|null
|
||||
{
|
||||
$lowerLocation = strtolower($location);
|
||||
$isRemote = str_starts_with($lowerLocation, 'http://') || str_starts_with($lowerLocation, 'https://');
|
||||
|
||||
if ($isRemote) {
|
||||
// Warning about fetching from source
|
||||
$host = parse_url($location, PHP_URL_HOST);
|
||||
$this->warn("\nThis will download a module from: {$host}\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.");
|
||||
$trustHost = $this->confirm('Are you sure you trust this source?');
|
||||
if (!$trustHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if the connection is http. If so, warn the user.
|
||||
if (str_starts_with($lowerLocation, 'http://')) {
|
||||
$this->warn("You are downloading a module from an insecure HTTP source.\nWe recommend only using HTTPS sources to avoid various security risks.");
|
||||
if (!$this->confirm('Are you sure you want to continue without HTTPS?')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Download ZIP and get its location
|
||||
return $this->downloadModuleFile($location);
|
||||
}
|
||||
|
||||
// Validate the file and get the full location
|
||||
$zipPath = realpath($location);
|
||||
|
||||
if (!$zipPath || !is_file($zipPath)) {
|
||||
$this->error("ERROR: Module file not found at {$location}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->warn("\nThis will install a module from: {$zipPath}\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.");
|
||||
$trustHost = $this->confirm('Are you sure you want to install this module?');
|
||||
if (!$trustHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $zipPath;
|
||||
}
|
||||
|
||||
protected function cleanup(): void
|
||||
{
|
||||
foreach ($this->cleanupActions as $action) {
|
||||
$action();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,11 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Http\ApiController;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@@ -24,7 +21,6 @@ class BookApiController extends ApiController
|
||||
protected BookRepo $bookRepo,
|
||||
protected BookQueries $queries,
|
||||
protected PageQueries $pageQueries,
|
||||
protected BookshelfQueries $shelfQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -64,20 +60,13 @@ class BookApiController extends ApiController
|
||||
* View the details of a single book.
|
||||
* The response data will contain a 'content' property listing the chapter and pages directly within, in
|
||||
* the same structure as you'd see within the BookStack interface when viewing a book. Top-level
|
||||
* contents will have a 'type' property to distinguish between pages and chapters.
|
||||
* contents will have a 'type' property to distinguish between pages & chapters.
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$book = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$book = $this->forJsonDisplay($book);
|
||||
$book->load([
|
||||
'createdBy',
|
||||
'updatedBy',
|
||||
'ownedBy',
|
||||
'shelves' => function (BelongsToMany $query) {
|
||||
$query->select(['id', 'name', 'slug'])->scopes('visible');
|
||||
}
|
||||
]);
|
||||
$book->load(['createdBy', 'updatedBy', 'ownedBy']);
|
||||
|
||||
$contents = (new BookContents($book))->getTree(true, false)->all();
|
||||
$contentsApiData = (new ApiEntityListFormatter($contents))
|
||||
|
||||
@@ -8,7 +8,6 @@ use BookStack\Activity\Models\View;
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
@@ -32,7 +31,6 @@ class BookController extends Controller
|
||||
protected ShelfContext $shelfContext,
|
||||
protected BookRepo $bookRepo,
|
||||
protected BookQueries $queries,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected BookshelfQueries $shelfQueries,
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
) {
|
||||
@@ -52,7 +50,7 @@ class BookController extends Controller
|
||||
|
||||
$books = $this->queries->visibleForListWithCover()
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
||||
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
|
||||
->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();
|
||||
@@ -129,16 +127,7 @@ class BookController extends Controller
|
||||
*/
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
try {
|
||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
} catch (NotFoundException $exception) {
|
||||
$book = $this->entityQueries->findVisibleByOldSlugs('book', $slug);
|
||||
if (is_null($book)) {
|
||||
throw $exception;
|
||||
}
|
||||
return redirect($book->getUrl());
|
||||
}
|
||||
|
||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$bookChildren = (new BookContents($book))->getTree(true);
|
||||
$bookParentShelves = $book->shelves()->scopes('visible')->get();
|
||||
|
||||
@@ -224,14 +213,9 @@ class BookController extends Controller
|
||||
{
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission(Permission::BookDelete, $book);
|
||||
$contextShelf = $this->shelfContext->getContextualShelfForBook($book);
|
||||
|
||||
$this->bookRepo->destroy($book);
|
||||
|
||||
if ($contextShelf) {
|
||||
return redirect($contextShelf->getUrl());
|
||||
}
|
||||
|
||||
return redirect('/books');
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ use BookStack\Activity\ActivityQueries;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Entities\Tools\ShelfContext;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
@@ -24,7 +23,6 @@ class BookshelfController extends Controller
|
||||
public function __construct(
|
||||
protected BookshelfRepo $shelfRepo,
|
||||
protected BookshelfQueries $queries,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected BookQueries $bookQueries,
|
||||
protected ShelfContext $shelfContext,
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
@@ -45,7 +43,7 @@ class BookshelfController extends Controller
|
||||
|
||||
$shelves = $this->queries->visibleForListWithCover()
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
||||
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
|
||||
->paginate(18);
|
||||
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
|
||||
$popular = $this->queries->popularForList()->get();
|
||||
$new = $this->queries->visibleForList()
|
||||
@@ -107,16 +105,7 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
try {
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
} catch (NotFoundException $exception) {
|
||||
$shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug);
|
||||
if (is_null($shelf)) {
|
||||
throw $exception;
|
||||
}
|
||||
return redirect($shelf->getUrl());
|
||||
}
|
||||
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission(Permission::BookshelfView, $shelf);
|
||||
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
|
||||
|
||||
@@ -77,15 +77,7 @@ class ChapterController extends Controller
|
||||
*/
|
||||
public function show(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
try {
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
} catch (NotFoundException $exception) {
|
||||
$chapter = $this->entityQueries->findVisibleByOldSlugs('chapter', $chapterSlug, $bookSlug);
|
||||
if (is_null($chapter)) {
|
||||
throw $exception;
|
||||
}
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
|
||||
$sidebarTree = (new BookContents($chapter->book))->getTree();
|
||||
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
|
||||
|
||||
@@ -17,12 +17,11 @@ use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditActivity;
|
||||
use BookStack\Entities\Tools\PageEditorData;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -141,7 +140,9 @@ class PageController extends Controller
|
||||
try {
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
} catch (NotFoundException $e) {
|
||||
$page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug);
|
||||
$revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
|
||||
$page = $revision->page ?? null;
|
||||
|
||||
if (is_null($page)) {
|
||||
throw $e;
|
||||
}
|
||||
@@ -175,7 +176,7 @@ class PageController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a page from an ajax request.
|
||||
* Get page from an ajax request.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
@@ -185,10 +186,6 @@ class PageController extends Controller
|
||||
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
|
||||
$page->makeHidden(['book']);
|
||||
|
||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||
$filter = new HtmlContentFilter($filterConfig);
|
||||
$page->html = $filter->filterString($page->html);
|
||||
|
||||
return response()->json($page);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
use Ssddanbrown\HtmlDiff\Diff;
|
||||
@@ -103,15 +101,12 @@ class PageRevisionController extends Controller
|
||||
|
||||
$prev = $revision->getPreviousRevision();
|
||||
$prevContent = $prev->html ?? '';
|
||||
|
||||
// TODO - Refactor PageContent so we can de-dupe these steps
|
||||
$rawDiff = Diff::excecute($prevContent, $revision->html);
|
||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||
$filter = new HtmlContentFilter($filterConfig);
|
||||
$diff = $filter->filterString($rawDiff);
|
||||
$diff = Diff::excecute($prevContent, $revision->html);
|
||||
|
||||
$page->fill($revision->toArray());
|
||||
$page->html = '';
|
||||
// TODO - Refactor PageContent so we don't need to juggle this
|
||||
$page->html = $revision->html;
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
|
||||
|
||||
return view('pages.revision', [
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
@@ -16,10 +17,34 @@ abstract class BookChild extends Entity
|
||||
{
|
||||
/**
|
||||
* Get the book this page sits in.
|
||||
* @return BelongsTo<Book, $this>
|
||||
*/
|
||||
public function book(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Book::class)->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the book that this entity belongs to.
|
||||
*/
|
||||
public function changeBook(int $newBookId): self
|
||||
{
|
||||
$oldUrl = $this->getUrl();
|
||||
$this->book_id = $newBookId;
|
||||
$this->unsetRelation('book');
|
||||
$this->refreshSlug();
|
||||
$this->save();
|
||||
|
||||
if ($oldUrl !== $this->getUrl()) {
|
||||
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
|
||||
}
|
||||
|
||||
// Update all child pages if a chapter
|
||||
if ($this instanceof Chapter) {
|
||||
foreach ($this->pages()->withTrashed()->get() as $page) {
|
||||
$page->changeBook($newBookId);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInter
|
||||
|
||||
public float $searchFactor = 1.2;
|
||||
|
||||
protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
|
||||
protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
|
||||
protected $fillable = ['name'];
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ use BookStack\Activity\Models\Viewable;
|
||||
use BookStack\Activity\Models\Watch;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\App\SluggableInterface;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Permissions\JointPermissionBuilder;
|
||||
use BookStack\Permissions\Models\EntityPermission;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
@@ -404,6 +405,16 @@ abstract class Entity extends Model implements
|
||||
app()->make(SearchIndex::class)->indexEntity(clone $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function refreshSlug(): string
|
||||
{
|
||||
$this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
|
||||
|
||||
return $this->slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -430,14 +441,6 @@ abstract class Entity extends Model implements
|
||||
return $this->morphMany(Watch::class, 'watchable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the related slug history for this entity.
|
||||
*/
|
||||
public function slugHistory(): MorphMany
|
||||
{
|
||||
return $this->morphMany(SlugHistory::class, 'sluggable');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
|
||||
@@ -124,14 +124,6 @@ class Page extends BookChild
|
||||
return url('/' . implode('/', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID-based permalink for this page.
|
||||
*/
|
||||
public function getPermalink(): string
|
||||
{
|
||||
return url("/link/{$this->id}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this page for JSON display.
|
||||
*/
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $sluggable_id
|
||||
* @property string $sluggable_type
|
||||
* @property string $slug
|
||||
* @property ?string $parent_slug
|
||||
*/
|
||||
class SlugHistory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'slug_history';
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'sluggable_id')
|
||||
->whereColumn('joint_permissions.entity_type', '=', 'slug_history.sluggable_type');
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\EntityTable;
|
||||
use BookStack\Entities\Tools\SlugHistory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
@@ -19,7 +18,6 @@ class EntityQueries
|
||||
public ChapterQueries $chapters,
|
||||
public PageQueries $pages,
|
||||
public PageRevisionQueries $revisions,
|
||||
protected SlugHistory $slugHistory,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -33,30 +31,9 @@ class EntityQueries
|
||||
$explodedId = explode(':', $identifier);
|
||||
$entityType = $explodedId[0];
|
||||
$entityId = intval($explodedId[1]);
|
||||
$queries = $this->getQueriesForType($entityType);
|
||||
|
||||
return $this->findVisibleById($entityType, $entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an entity by its ID.
|
||||
*/
|
||||
public function findVisibleById(string $type, int $id): ?Entity
|
||||
{
|
||||
$queries = $this->getQueriesForType($type);
|
||||
return $queries->findVisibleById($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an entity by looking up old slugs in the slug history.
|
||||
*/
|
||||
public function findVisibleByOldSlugs(string $type, string $slug, string $parentSlug = ''): ?Entity
|
||||
{
|
||||
$id = $this->slugHistory->lookupEntityIdUsingSlugs($type, $slug, $parentSlug);
|
||||
if ($id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->findVisibleById($type, $id);
|
||||
return $queries->findVisibleById($entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,8 +8,6 @@ use BookStack\Entities\Models\HasCoverInterface;
|
||||
use BookStack\Entities\Models\HasDescriptionInterface;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Entities\Tools\SlugHistory;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\References\ReferenceStore;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
@@ -27,8 +25,6 @@ class BaseRepo
|
||||
protected ReferenceStore $referenceStore,
|
||||
protected PageQueries $pageQueries,
|
||||
protected BookSorter $bookSorter,
|
||||
protected SlugGenerator $slugGenerator,
|
||||
protected SlugHistory $slugHistory,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -47,7 +43,7 @@ class BaseRepo
|
||||
'updated_by' => user()->id,
|
||||
'owned_by' => user()->id,
|
||||
]);
|
||||
$this->refreshSlug($entity);
|
||||
$entity->refreshSlug();
|
||||
|
||||
if ($entity instanceof HasDescriptionInterface) {
|
||||
$this->updateDescription($entity, $input);
|
||||
@@ -82,7 +78,7 @@ class BaseRepo
|
||||
$entity->updated_by = user()->id;
|
||||
|
||||
if ($entity->isDirty('name') || empty($entity->slug)) {
|
||||
$this->refreshSlug($entity);
|
||||
$entity->refreshSlug();
|
||||
}
|
||||
|
||||
if ($entity instanceof HasDescriptionInterface) {
|
||||
@@ -159,13 +155,4 @@ class BaseRepo
|
||||
$entity->descriptionInfo()->set('', $input['description']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the slug for the given entity.
|
||||
*/
|
||||
public function refreshSlug(Entity $entity): void
|
||||
{
|
||||
$this->slugHistory->recordForEntity($entity);
|
||||
$this->slugGenerator->regenerateForEntity($entity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\ParentChanger;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
@@ -22,7 +21,6 @@ class ChapterRepo
|
||||
protected BaseRepo $baseRepo,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected TrashCan $trashCan,
|
||||
protected ParentChanger $parentChanger,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -99,7 +97,7 @@ class ChapterRepo
|
||||
}
|
||||
|
||||
return (new DatabaseTransaction(function () use ($chapter, $parent) {
|
||||
$this->parentChanger->changeBook($chapter, $parent->id);
|
||||
$chapter = $chapter->changeBook($parent->id);
|
||||
$chapter->rebuildPermissions();
|
||||
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditorType;
|
||||
use BookStack\Entities\Tools\ParentChanger;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
@@ -32,7 +31,6 @@ class PageRepo
|
||||
protected ReferenceStore $referenceStore,
|
||||
protected ReferenceUpdater $referenceUpdater,
|
||||
protected TrashCan $trashCan,
|
||||
protected ParentChanger $parentChanger,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -244,7 +242,7 @@ class PageRepo
|
||||
}
|
||||
|
||||
$page->updated_by = user()->id;
|
||||
$this->baseRepo->refreshSlug($page);
|
||||
$page->refreshSlug();
|
||||
$page->save();
|
||||
$page->indexForSearch();
|
||||
$this->referenceStore->updateForEntity($page);
|
||||
@@ -286,7 +284,7 @@ class PageRepo
|
||||
return (new DatabaseTransaction(function () use ($page, $parent) {
|
||||
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
|
||||
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
|
||||
$this->parentChanger->changeBook($page, $newBookId);
|
||||
$page = $page->changeBook($newBookId);
|
||||
$page->rebuildPermissions();
|
||||
|
||||
Activity::add(ActivityType::PAGE_MOVE, $page);
|
||||
|
||||
@@ -13,47 +13,30 @@ use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\References\ReferenceChangeContext;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class Cloner
|
||||
{
|
||||
protected ReferenceChangeContext $referenceChangeContext;
|
||||
|
||||
public function __construct(
|
||||
protected PageRepo $pageRepo,
|
||||
protected ChapterRepo $chapterRepo,
|
||||
protected BookRepo $bookRepo,
|
||||
protected ImageService $imageService,
|
||||
protected ReferenceUpdater $referenceUpdater,
|
||||
) {
|
||||
$this->referenceChangeContext = new ReferenceChangeContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the given page into the given parent using the provided name.
|
||||
*/
|
||||
public function clonePage(Page $original, Entity $parent, string $newName): Page
|
||||
{
|
||||
$context = $this->newReferenceChangeContext();
|
||||
$page = $this->createPageClone($original, $parent, $newName);
|
||||
$this->referenceUpdater->changeReferencesUsingContext($context);
|
||||
return $page;
|
||||
}
|
||||
|
||||
protected function createPageClone(Page $original, Entity $parent, string $newName): Page
|
||||
{
|
||||
$copyPage = $this->pageRepo->getNewDraftPage($parent);
|
||||
$pageData = $this->entityToInputData($original);
|
||||
$pageData['name'] = $newName;
|
||||
|
||||
$newPage = $this->pageRepo->publishDraft($copyPage, $pageData);
|
||||
$this->referenceChangeContext->add($original, $newPage);
|
||||
|
||||
return $newPage;
|
||||
return $this->pageRepo->publishDraft($copyPage, $pageData);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,14 +44,6 @@ class Cloner
|
||||
* Clones all child pages.
|
||||
*/
|
||||
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
|
||||
{
|
||||
$context = $this->newReferenceChangeContext();
|
||||
$chapter = $this->createChapterClone($original, $parent, $newName);
|
||||
$this->referenceUpdater->changeReferencesUsingContext($context);
|
||||
return $chapter;
|
||||
}
|
||||
|
||||
protected function createChapterClone(Chapter $original, Book $parent, string $newName): Chapter
|
||||
{
|
||||
$chapterDetails = $this->entityToInputData($original);
|
||||
$chapterDetails['name'] = $newName;
|
||||
@@ -78,12 +53,10 @@ class Cloner
|
||||
if (userCan(Permission::PageCreate, $copyChapter)) {
|
||||
/** @var Page $page */
|
||||
foreach ($original->getVisiblePages() as $page) {
|
||||
$this->createPageClone($page, $copyChapter, $page->name);
|
||||
$this->clonePage($page, $copyChapter, $page->name);
|
||||
}
|
||||
}
|
||||
|
||||
$this->referenceChangeContext->add($original, $copyChapter);
|
||||
|
||||
return $copyChapter;
|
||||
}
|
||||
|
||||
@@ -92,14 +65,6 @@ class Cloner
|
||||
* Clones all child chapters and pages.
|
||||
*/
|
||||
public function cloneBook(Book $original, string $newName): Book
|
||||
{
|
||||
$context = $this->newReferenceChangeContext();
|
||||
$book = $this->createBookClone($original, $newName);
|
||||
$this->referenceUpdater->changeReferencesUsingContext($context);
|
||||
return $book;
|
||||
}
|
||||
|
||||
protected function createBookClone(Book $original, string $newName): Book
|
||||
{
|
||||
$bookDetails = $this->entityToInputData($original);
|
||||
$bookDetails['name'] = $newName;
|
||||
@@ -111,11 +76,11 @@ class Cloner
|
||||
$directChildren = $original->getDirectVisibleChildren();
|
||||
foreach ($directChildren as $child) {
|
||||
if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) {
|
||||
$this->createChapterClone($child, $copyBook, $child->name);
|
||||
$this->cloneChapter($child, $copyBook, $child->name);
|
||||
}
|
||||
|
||||
if ($child instanceof Page && !$child->draft && userCan(Permission::PageCreate, $copyBook)) {
|
||||
$this->createPageClone($child, $copyBook, $child->name);
|
||||
$this->clonePage($child, $copyBook, $child->name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,8 +92,6 @@ class Cloner
|
||||
}
|
||||
}
|
||||
|
||||
$this->referenceChangeContext->add($original, $copyBook);
|
||||
|
||||
return $copyBook;
|
||||
}
|
||||
|
||||
@@ -192,10 +155,4 @@ class Cloner
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
protected function newReferenceChangeContext(): ReferenceChangeContext
|
||||
{
|
||||
$this->referenceChangeContext = new ReferenceChangeContext();
|
||||
return $this->referenceChangeContext;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
|
||||
class EntityHtmlDescription
|
||||
{
|
||||
@@ -51,13 +50,7 @@ class EntityHtmlDescription
|
||||
return $html;
|
||||
}
|
||||
|
||||
$isEmpty = empty(trim(strip_tags($html)));
|
||||
if ($isEmpty) {
|
||||
return '<p></p>';
|
||||
}
|
||||
|
||||
$filter = new HtmlContentFilter(new HtmlContentFilterConfig());
|
||||
return $filter->filterString($html);
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($html);
|
||||
}
|
||||
|
||||
public function getPlain(): string
|
||||
|
||||
@@ -17,8 +17,7 @@ class HierarchyTransformer
|
||||
protected BookRepo $bookRepo,
|
||||
protected BookshelfRepo $shelfRepo,
|
||||
protected Cloner $cloner,
|
||||
protected TrashCan $trashCan,
|
||||
protected ParentChanger $parentChanger,
|
||||
protected TrashCan $trashCan
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -36,7 +35,7 @@ class HierarchyTransformer
|
||||
foreach ($chapter->pages as $page) {
|
||||
$page->chapter_id = 0;
|
||||
$page->save();
|
||||
$this->parentChanger->changeBook($page, $book->id);
|
||||
$page->changeBook($book->id);
|
||||
}
|
||||
|
||||
$this->trashCan->destroyEntity($chapter);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\App\AppVersion;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||
@@ -14,7 +13,6 @@ use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Users\Models\User;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use BookStack\Util\HtmlDocument;
|
||||
use BookStack\Util\WebSafeMimeSniffer;
|
||||
use Closure;
|
||||
@@ -39,14 +37,7 @@ class PageContent
|
||||
public function setNewHTML(string $html, User $updater): void
|
||||
{
|
||||
$html = $this->extractBase64ImagesFromHtml($html, $updater);
|
||||
$html = $this->formatHtml($html);
|
||||
|
||||
$themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_PRE_STORE, $html, $this->page);
|
||||
if (is_string($themeResult)) {
|
||||
$html = $themeResult;
|
||||
}
|
||||
|
||||
$this->page->html = $html;
|
||||
$this->page->html = $this->formatHtml($html);
|
||||
$this->page->text = $this->toPlainText();
|
||||
$this->page->markdown = '';
|
||||
}
|
||||
@@ -59,14 +50,7 @@ class PageContent
|
||||
$markdown = $this->extractBase64ImagesFromMarkdown($markdown, $updater);
|
||||
$this->page->markdown = $markdown;
|
||||
$html = (new MarkdownToHtml($markdown))->convert();
|
||||
$html = $this->formatHtml($html);
|
||||
|
||||
$themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_PRE_STORE, $html, $this->page);
|
||||
if (is_string($themeResult)) {
|
||||
$html = $themeResult;
|
||||
}
|
||||
|
||||
$this->page->html = $html;
|
||||
$this->page->html = $this->formatHtml($html);
|
||||
$this->page->text = $this->toPlainText();
|
||||
}
|
||||
|
||||
@@ -95,7 +79,7 @@ class PageContent
|
||||
|
||||
/**
|
||||
* Convert all inline base64 content to uploaded image files.
|
||||
* Regex is used to locate the start of data-uri definitions, then
|
||||
* Regex is used to locate the start of data-uri definitions then
|
||||
* manual looping over content is done to parse the whole data uri.
|
||||
* Attempting to capture the whole data uri using regex can cause PHP
|
||||
* PCRE limits to be hit with larger, multi-MB, files.
|
||||
@@ -315,7 +299,7 @@ class PageContent
|
||||
$html = $this->page->html ?? '';
|
||||
|
||||
if (empty($html)) {
|
||||
return $this->handlePostRender('');
|
||||
return $html;
|
||||
}
|
||||
|
||||
$doc = new HtmlDocument($html);
|
||||
@@ -333,36 +317,11 @@ class PageContent
|
||||
$this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
|
||||
}
|
||||
|
||||
$cacheKey = $this->getContentCacheKey($doc->getBodyInnerHtml());
|
||||
$cached = cache()->get($cacheKey, null);
|
||||
if ($cached !== null) {
|
||||
return $this->handlePostRender($cached);
|
||||
if (!config('app.allow_content_scripts')) {
|
||||
HtmlContentFilter::removeScriptsFromDocument($doc);
|
||||
}
|
||||
|
||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||
$filter = new HtmlContentFilter($filterConfig);
|
||||
$filtered = $filter->filterDocument($doc);
|
||||
|
||||
$cacheTime = 86400 * 7; // 1 week
|
||||
cache()->put($cacheKey, $filtered, $cacheTime);
|
||||
|
||||
return $this->handlePostRender($filtered);
|
||||
}
|
||||
|
||||
protected function handlePostRender(string $html): string
|
||||
{
|
||||
$themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_POST_RENDER, $html, $this->page);
|
||||
return is_string($themeResult) ? $themeResult : $html;
|
||||
}
|
||||
|
||||
protected function getContentCacheKey(string $html): string
|
||||
{
|
||||
$contentHash = md5($html);
|
||||
$contentId = $this->page->id;
|
||||
$contentTime = $this->page->updated_at?->timestamp ?? time();
|
||||
$appVersion = AppVersion::get();
|
||||
$filterConfig = config('app.content_filtering') ?? '';
|
||||
return "page-content-cache::{$filterConfig}::{$appVersion}::{$contentId}::{$contentTime}::{$contentHash}";
|
||||
return $doc->getBodyInnerHtml();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,8 +8,6 @@ use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
|
||||
class PageEditorData
|
||||
{
|
||||
@@ -49,7 +47,6 @@ class PageEditorData
|
||||
$isDraftRevision = false;
|
||||
$this->warnings = [];
|
||||
$editActivity = new PageEditActivity($page);
|
||||
$lastEditorId = $page->updated_by ?? user()->id;
|
||||
|
||||
if ($editActivity->hasActiveEditing()) {
|
||||
$this->warnings[] = $editActivity->activeEditingMessage();
|
||||
@@ -61,20 +58,11 @@ class PageEditorData
|
||||
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
|
||||
$isDraftRevision = true;
|
||||
$this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
|
||||
$lastEditorId = $userDraft->created_by;
|
||||
}
|
||||
|
||||
// Get editor type and handle changes
|
||||
$editorType = $this->getEditorType($page);
|
||||
$this->updateContentForEditor($page, $editorType);
|
||||
|
||||
// Filter HTML content if required
|
||||
if ($editorType->isHtmlBased() && !old('html') && $lastEditorId !== user()->id) {
|
||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||
$filter = new HtmlContentFilter($filterConfig);
|
||||
$page->html = $filter->filterString($page->html);
|
||||
}
|
||||
|
||||
return [
|
||||
'page' => $page,
|
||||
'book' => $page->book,
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
|
||||
class ParentChanger
|
||||
{
|
||||
public function __construct(
|
||||
protected SlugGenerator $slugGenerator,
|
||||
protected ReferenceUpdater $referenceUpdater
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the parent book of a chapter or page.
|
||||
*/
|
||||
public function changeBook(BookChild $child, int $newBookId): void
|
||||
{
|
||||
$oldUrl = $child->getUrl();
|
||||
|
||||
$child->book_id = $newBookId;
|
||||
$child->unsetRelation('book');
|
||||
$this->slugGenerator->regenerateForEntity($child);
|
||||
$child->save();
|
||||
|
||||
if ($oldUrl !== $child->getUrl()) {
|
||||
$this->referenceUpdater->updateEntityReferences($child, $oldUrl);
|
||||
}
|
||||
|
||||
// Update all child pages if a chapter
|
||||
if ($child instanceof Chapter) {
|
||||
foreach ($child->pages()->withTrashed()->get() as $page) {
|
||||
$this->changeBook($page, $newBookId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,12 @@ namespace BookStack\Entities\Tools;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\App\SluggableInterface;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SlugGenerator
|
||||
{
|
||||
/**
|
||||
* Generate a fresh slug for the given item.
|
||||
* Generate a fresh slug for the given entity.
|
||||
* The slug will be generated so that it doesn't conflict within the same parent item.
|
||||
*/
|
||||
public function generate(SluggableInterface&Model $model, string $slugSource): string
|
||||
@@ -25,26 +23,6 @@ class SlugGenerator
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate the slug for the given entity.
|
||||
*/
|
||||
public function regenerateForEntity(Entity $entity): string
|
||||
{
|
||||
$entity->slug = $this->generate($entity, $entity->name);
|
||||
|
||||
return $entity->slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate the slug for a user.
|
||||
*/
|
||||
public function regenerateForUser(User $user): string
|
||||
{
|
||||
$user->slug = $this->generate($user, $user->name);
|
||||
|
||||
return $user->slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a name as a URL slug.
|
||||
*/
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\EntityTable;
|
||||
use BookStack\Entities\Models\SlugHistory as SlugHistoryModel;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SlugHistory
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionApplicator $permissions,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the current slugs for the given entity.
|
||||
*/
|
||||
public function recordForEntity(Entity $entity): void
|
||||
{
|
||||
if (!$entity->id || !$entity->slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parentSlug = null;
|
||||
if ($entity instanceof BookChild) {
|
||||
$parentSlug = $entity->book()->first()?->slug;
|
||||
}
|
||||
|
||||
$latest = $this->getLatestEntryForEntity($entity);
|
||||
if ($latest && $latest->slug === $entity->slug && $latest->parent_slug === $parentSlug) {
|
||||
return;
|
||||
}
|
||||
|
||||
$info = [
|
||||
'sluggable_type' => $entity->getMorphClass(),
|
||||
'sluggable_id' => $entity->id,
|
||||
'slug' => $entity->slug,
|
||||
'parent_slug' => $parentSlug,
|
||||
];
|
||||
|
||||
$entry = new SlugHistoryModel();
|
||||
$entry->forceFill($info);
|
||||
$entry->save();
|
||||
|
||||
if ($entity instanceof Book) {
|
||||
$this->recordForBookChildren($entity);
|
||||
}
|
||||
}
|
||||
|
||||
protected function recordForBookChildren(Book $book): void
|
||||
{
|
||||
$query = EntityTable::query()
|
||||
->select(['type', 'id', 'slug', DB::raw("'{$book->slug}' as parent_slug"), DB::raw('now() as created_at'), DB::raw('now() as updated_at')])
|
||||
->where('book_id', '=', $book->id)
|
||||
->whereNotNull('book_id');
|
||||
|
||||
SlugHistoryModel::query()->insertUsing(
|
||||
['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'],
|
||||
$query
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the latest visible entry for an entity which uses the given slug(s) in the history.
|
||||
*/
|
||||
public function lookupEntityIdUsingSlugs(string $type, string $slug, string $parentSlug = ''): ?int
|
||||
{
|
||||
$query = SlugHistoryModel::query()
|
||||
->where('sluggable_type', '=', $type)
|
||||
->where('slug', '=', $slug);
|
||||
|
||||
if ($parentSlug) {
|
||||
$query->where('parent_slug', '=', $parentSlug);
|
||||
}
|
||||
|
||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'slug_history', 'sluggable_id', 'sluggable_type');
|
||||
|
||||
/** @var SlugHistoryModel|null $result */
|
||||
$result = $query->orderBy('created_at', 'desc')->first();
|
||||
|
||||
return $result?->sluggable_id;
|
||||
}
|
||||
|
||||
protected function getLatestEntryForEntity(Entity $entity): SlugHistoryModel|null
|
||||
{
|
||||
return SlugHistoryModel::query()
|
||||
->where('sluggable_type', '=', $entity->getMorphClass())
|
||||
->where('sluggable_id', '=', $entity->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -388,7 +388,7 @@ class TrashCan
|
||||
/**
|
||||
* Update entity relations to remove or update outstanding connections.
|
||||
*/
|
||||
protected function destroyCommonRelations(Entity $entity): void
|
||||
protected function destroyCommonRelations(Entity $entity)
|
||||
{
|
||||
Activity::removeEntity($entity);
|
||||
$entity->views()->delete();
|
||||
@@ -402,7 +402,6 @@ class TrashCan
|
||||
$entity->watches()->delete();
|
||||
$entity->referencesTo()->delete();
|
||||
$entity->referencesFrom()->delete();
|
||||
$entity->slugHistory()->delete();
|
||||
|
||||
if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
|
||||
$imageService = app()->make(ImageService::class);
|
||||
|
||||
@@ -323,7 +323,7 @@ class ExportFormatter
|
||||
$text .= $description . "\n\n";
|
||||
}
|
||||
|
||||
foreach ($chapter->getVisiblePages() as $page) {
|
||||
foreach ($chapter->pages as $page) {
|
||||
$text .= $this->pageToMarkdown($page) . "\n\n";
|
||||
}
|
||||
|
||||
|
||||
@@ -58,16 +58,6 @@ class ZipExportReader
|
||||
{
|
||||
$this->open();
|
||||
|
||||
$info = $this->zip->statName('data.json');
|
||||
if ($info === false) {
|
||||
throw new ZipExportException(trans('errors.import_zip_cant_decode_data'));
|
||||
}
|
||||
|
||||
$maxSize = max(intval(config()->get('app.upload_limit')), 1) * 1000000;
|
||||
if ($info['size'] > $maxSize) {
|
||||
throw new ZipExportException(trans('errors.import_zip_data_too_large'));
|
||||
}
|
||||
|
||||
// Validate json data exists, including metadata
|
||||
$jsonData = $this->zip->getFromName('data.json') ?: '';
|
||||
$importData = json_decode($jsonData, true);
|
||||
@@ -83,17 +73,6 @@ class ZipExportReader
|
||||
return $this->zip->statName("files/{$fileName}") !== false;
|
||||
}
|
||||
|
||||
public function fileWithinSizeLimit(string $fileName): bool
|
||||
{
|
||||
$fileInfo = $this->zip->statName("files/{$fileName}");
|
||||
if ($fileInfo === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxSize = max(intval(config()->get('app.upload_limit')), 1) * 1000000;
|
||||
return $fileInfo['size'] <= $maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|resource
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,7 @@ class ZipFileReferenceRule implements ValidationRule
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
@@ -22,13 +23,6 @@ class ZipFileReferenceRule implements ValidationRule
|
||||
$fail('validation.zip_file')->translate();
|
||||
}
|
||||
|
||||
if (!$this->context->zipReader->fileWithinSizeLimit($value)) {
|
||||
$fail('validation.zip_file_size')->translate([
|
||||
'attribute' => $value,
|
||||
'size' => config('app.upload_limit'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!empty($this->acceptedMimes)) {
|
||||
$fileMime = $this->context->zipReader->sniffFileMime($value);
|
||||
if (!in_array($fileMime, $this->acceptedMimes)) {
|
||||
|
||||
@@ -265,12 +265,6 @@ class ZipImportRunner
|
||||
|
||||
protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile
|
||||
{
|
||||
if (!$reader->fileWithinSizeLimit($fileName)) {
|
||||
throw new ZipImportException([
|
||||
"File $fileName exceeds app upload limit."
|
||||
]);
|
||||
}
|
||||
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'bszipextract');
|
||||
$fileStream = $reader->streamFile($fileName);
|
||||
$tempStream = fopen($tempPath, 'wb');
|
||||
|
||||
@@ -167,26 +167,14 @@ abstract class Controller extends BaseController
|
||||
|
||||
/**
|
||||
* Redirect to the URL provided in the request as a '_return' parameter.
|
||||
* Will check that the parameter leads to a URL under the same origin as the application.
|
||||
* Will check that the parameter leads to a URL under the root path of the system.
|
||||
*/
|
||||
protected function redirectToRequest(Request $request): RedirectResponse
|
||||
{
|
||||
$basePath = url('/');
|
||||
$returnUrl = $request->input('_return') ?? $basePath;
|
||||
|
||||
// Only allow use of _return on requests where we expect CSRF to be active
|
||||
// to prevent it potentially being used as an open redirect
|
||||
$allowedMethods = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
||||
if (!in_array($request->getMethod(), $allowedMethods)) {
|
||||
return redirect($basePath);
|
||||
}
|
||||
|
||||
$intendedUrl = parse_url($returnUrl);
|
||||
$baseUrl = parse_url($basePath);
|
||||
$isSameOrigin = ($intendedUrl['host'] ?? '') === ($baseUrl['host'] ?? '')
|
||||
&& ($intendedUrl['scheme'] ?? '') === ($baseUrl['scheme'] ?? '')
|
||||
&& ($intendedUrl['port'] ?? 0) === ($baseUrl['port'] ?? 0);
|
||||
if (!$isSameOrigin) {
|
||||
if (!str_starts_with($returnUrl, $basePath)) {
|
||||
return redirect($basePath);
|
||||
}
|
||||
|
||||
|
||||
@@ -102,15 +102,12 @@ class DownloadResponseFactory
|
||||
protected function getHeaders(string $fileName, int $fileSize, string $mime = 'application/octet-stream'): array
|
||||
{
|
||||
$disposition = ($mime === 'application/octet-stream') ? 'attachment' : 'inline';
|
||||
|
||||
$downloadName = str_replace(['"', '/', '\\', '$'], '', $fileName);
|
||||
$downloadName = preg_replace('/[\x00-\x1F\x7F]/', '', $downloadName);
|
||||
$encodedDownloadName = rawurlencode($downloadName);
|
||||
$downloadName = str_replace('"', '', $fileName);
|
||||
|
||||
return [
|
||||
'Content-Type' => $mime,
|
||||
'Content-Length' => $fileSize,
|
||||
'Content-Disposition' => "{$disposition}; filename*=UTF-8''{$encodedDownloadName}",
|
||||
'Content-Disposition' => "{$disposition}; filename=\"{$downloadName}\"",
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class ApiAuthenticate
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
// Validate the token and it's users API access
|
||||
$this->ensureAuthorizedBySessionOrToken($request);
|
||||
$this->ensureAuthorizedBySessionOrToken();
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
@@ -28,28 +28,22 @@ class ApiAuthenticate
|
||||
*
|
||||
* @throws ApiAuthException
|
||||
*/
|
||||
protected function ensureAuthorizedBySessionOrToken(Request $request): void
|
||||
protected function ensureAuthorizedBySessionOrToken(): void
|
||||
{
|
||||
// Use the active user session already exists.
|
||||
// This is to make it easy to explore API endpoints via the UI.
|
||||
if (session()->isStarted()) {
|
||||
// Ensure the user has API access permission
|
||||
// Return if the user is already found to be signed in via session-based auth.
|
||||
// This is to make it easy to browser the API via browser after just logging into the system.
|
||||
if (!user()->isGuest() || session()->isStarted()) {
|
||||
if (!$this->sessionUserHasApiAccess()) {
|
||||
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
|
||||
}
|
||||
|
||||
// Only allow GET requests for cookie-based API usage
|
||||
if ($request->method() !== 'GET') {
|
||||
throw new ApiAuthException(trans('errors.api_cookie_auth_only_get'), 403);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Set our api guard to be the default for this request lifecycle.
|
||||
auth()->shouldUse('api');
|
||||
|
||||
// Validate the token and its users API access
|
||||
// Validate the token and it's users API access
|
||||
auth()->authenticate();
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,7 @@ use Illuminate\Session\Middleware\StartSession as Middleware;
|
||||
class StartSessionExtended extends Middleware
|
||||
{
|
||||
protected static array $pathPrefixesExcludedFromHistory = [
|
||||
'uploads/images/',
|
||||
'dist/',
|
||||
'manifest.json',
|
||||
'opensearch.xml',
|
||||
'uploads/images/'
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\References;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
|
||||
class ReferenceChangeContext
|
||||
{
|
||||
/**
|
||||
* Entity pairs where the first is the old entity and the second is the new entity.
|
||||
* @var array<array{0: Entity, 1: Entity}>
|
||||
*/
|
||||
protected array $changes = [];
|
||||
|
||||
public function add(Entity $oldEntity, Entity $newEntity): void
|
||||
{
|
||||
$this->changes[] = [$oldEntity, $newEntity];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the new entities from the changes.
|
||||
*/
|
||||
public function getNewEntities(): array
|
||||
{
|
||||
return array_column($this->changes, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the old entities from the changes.
|
||||
*/
|
||||
public function getOldEntities(): array
|
||||
{
|
||||
return array_column($this->changes, 0);
|
||||
}
|
||||
|
||||
public function getNewForOld(Entity $oldEntity): ?Entity
|
||||
{
|
||||
foreach ($this->changes as [$old, $new]) {
|
||||
if ($old->id === $oldEntity->id && $old->type === $oldEntity->type) {
|
||||
return $new;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\References;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\HasDescriptionInterface;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\EntityContainerData;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\RevisionRepo;
|
||||
use BookStack\Util\HtmlDocument;
|
||||
@@ -29,47 +30,6 @@ class ReferenceUpdater
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change existing references for a range of entities using the given context.
|
||||
*/
|
||||
public function changeReferencesUsingContext(ReferenceChangeContext $context): void
|
||||
{
|
||||
$bindings = [];
|
||||
foreach ($context->getOldEntities() as $old) {
|
||||
$bindings[] = $old->getMorphClass();
|
||||
$bindings[] = $old->id;
|
||||
}
|
||||
|
||||
// No targets to update within the context, so no need to continue.
|
||||
if (count($bindings) < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
$toReferenceQuery = '(to_type, to_id) IN (' . rtrim(str_repeat('(?,?),', count($bindings) / 2), ',') . ')';
|
||||
|
||||
// Cycle each new entity in the context
|
||||
foreach ($context->getNewEntities() as $new) {
|
||||
// For each, get all references from it which lead to other items within the context of the change
|
||||
$newReferencesInContext = $new->referencesFrom()->whereRaw($toReferenceQuery, $bindings)->get();
|
||||
// For each reference, update the URL and the reference entry
|
||||
foreach ($newReferencesInContext as $reference) {
|
||||
$oldToEntity = $reference->to;
|
||||
$newToEntity = $context->getNewForOld($oldToEntity);
|
||||
if ($newToEntity === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->updateReferencesWithinEntity($new, $oldToEntity->getUrl(), $newToEntity->getUrl());
|
||||
if ($newToEntity instanceof Page && $oldToEntity instanceof Page) {
|
||||
$this->updateReferencesWithinEntity($new, $oldToEntity->getPermalink(), $newToEntity->getPermalink());
|
||||
}
|
||||
$reference->to_id = $newToEntity->id;
|
||||
$reference->to_type = $newToEntity->getMorphClass();
|
||||
$reference->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Reference[]
|
||||
*/
|
||||
|
||||
@@ -25,12 +25,11 @@ class SearchController extends Controller
|
||||
$searchOpts = SearchOptions::fromRequest($request);
|
||||
$fullSearchString = $searchOpts->toString();
|
||||
$page = intval($request->get('page', '0')) ?: 1;
|
||||
$count = setting()->getInteger('lists-page-count-search', 18, 1, 1000);
|
||||
|
||||
$results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, $count);
|
||||
$results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
|
||||
$formatter->format($results['results']->all(), $searchOpts);
|
||||
$paginator = new LengthAwarePaginator($results['results'], $results['total'], $count, $page);
|
||||
$paginator->setPath(url('/search'));
|
||||
$paginator = new LengthAwarePaginator($results['results'], $results['total'], 20, $page);
|
||||
$paginator->setPath('/search');
|
||||
$paginator->appends($request->except('page'));
|
||||
|
||||
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
|
||||
@@ -78,9 +77,8 @@ class SearchController extends Controller
|
||||
|
||||
// Search for entities otherwise show most popular
|
||||
if ($searchTerm !== false) {
|
||||
$options = SearchOptions::fromString($searchTerm);
|
||||
$options->setFilter('type', implode('|', $entityTypes));
|
||||
$entities = $this->searchRunner->searchEntities($options, 'all', 1, 20)['results'];
|
||||
$searchTerm .= ' {type:' . implode('|', $entityTypes) . '}';
|
||||
$entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20)['results'];
|
||||
} else {
|
||||
$entities = $queryPopular->run(20, 0, $entityTypes);
|
||||
}
|
||||
|
||||
@@ -82,12 +82,4 @@ class SearchOptionSet
|
||||
$values = array_values(array_filter($this->options, fn (SearchOption $option) => !$option->negated));
|
||||
return new self($values);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return self<T>
|
||||
*/
|
||||
public function limit(int $limit): self
|
||||
{
|
||||
return new self(array_slice(array_values($this->options), 0, $limit));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ class SearchOptions
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->addOptionsFromString($search);
|
||||
$instance->limitOptions();
|
||||
return $instance;
|
||||
}
|
||||
|
||||
@@ -88,8 +87,6 @@ class SearchOptions
|
||||
$instance->filters = $instance->filters->merge($extras->filters);
|
||||
}
|
||||
|
||||
$instance->limitOptions();
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
@@ -150,25 +147,6 @@ class SearchOptions
|
||||
$this->filters = $this->filters->merge(new SearchOptionSet($terms['filters']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit the amount of search options to reasonable levels.
|
||||
* Provides higher limits to logged-in users since that signals a slightly
|
||||
* higher level of trust.
|
||||
*/
|
||||
protected function limitOptions(): void
|
||||
{
|
||||
$userLoggedIn = !user()->isGuest();
|
||||
$searchLimit = $userLoggedIn ? 10 : 5;
|
||||
$exactLimit = $userLoggedIn ? 4 : 2;
|
||||
$tagLimit = $userLoggedIn ? 8 : 4;
|
||||
$filterLimit = $userLoggedIn ? 10 : 5;
|
||||
|
||||
$this->searches = $this->searches->limit($searchLimit);
|
||||
$this->exacts = $this->exacts->limit($exactLimit);
|
||||
$this->tags = $this->tags->limit($tagLimit);
|
||||
$this->filters = $this->filters->limit($filterLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode backslash escaping within the input string.
|
||||
*/
|
||||
|
||||
@@ -120,14 +120,8 @@ class SearchRunner
|
||||
$filter = function (EloquentBuilder $query) use ($exact) {
|
||||
$inputTerm = str_replace('\\', '\\\\', $exact->value);
|
||||
$query->where('name', 'like', '%' . $inputTerm . '%')
|
||||
->orWhere(function (EloquentBuilder $query) use ($inputTerm) {
|
||||
$query->whereNotNull('description')
|
||||
->where('description', 'like', '%' . $inputTerm . '%');
|
||||
})
|
||||
->orWhere(function (EloquentBuilder $query) use ($inputTerm) {
|
||||
$query->whereNotNull('text')
|
||||
->where('text', 'like', '%' . $inputTerm . '%');
|
||||
});
|
||||
->orWhere('description', 'like', '%' . $inputTerm . '%')
|
||||
->orWhere('text', 'like', '%' . $inputTerm . '%');
|
||||
};
|
||||
|
||||
$exact->negated ? $entityQuery->whereNot($filter) : $entityQuery->where($filter);
|
||||
|
||||
@@ -14,7 +14,7 @@ class AppSettingsStore
|
||||
) {
|
||||
}
|
||||
|
||||
public function storeFromUpdateRequest(Request $request, string $category): void
|
||||
public function storeFromUpdateRequest(Request $request, string $category)
|
||||
{
|
||||
$this->storeSimpleSettings($request);
|
||||
if ($category === 'customization') {
|
||||
@@ -76,7 +76,7 @@ class AppSettingsStore
|
||||
protected function storeSimpleSettings(Request $request): void
|
||||
{
|
||||
foreach ($request->all() as $name => $value) {
|
||||
if (!str_starts_with($name, 'setting-')) {
|
||||
if (strpos($name, 'setting-') !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ class AppSettingsStore
|
||||
}
|
||||
}
|
||||
|
||||
protected function destroyExistingSettingImage(string $settingKey): void
|
||||
protected function destroyExistingSettingImage(string $settingKey)
|
||||
{
|
||||
$existingVal = setting()->get($settingKey);
|
||||
if ($existingVal) {
|
||||
|
||||
@@ -28,21 +28,6 @@ class SettingService
|
||||
return $this->formatValue($value, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a setting from the database as an integer.
|
||||
* Returns the default value if not found or not an integer, and clamps the value to the given min/max range.
|
||||
*/
|
||||
public function getInteger(string $key, int $default, int $min = 0, int $max = PHP_INT_MAX): int
|
||||
{
|
||||
$value = $this->get($key, $default);
|
||||
if (!is_numeric($value)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$int = intval($value);
|
||||
return max($min, min($max, $int));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from the session instead of the main store option.
|
||||
*/
|
||||
|
||||
@@ -26,14 +26,9 @@ class UserNotificationPreferences
|
||||
return $this->getNotificationSetting('comment-replies');
|
||||
}
|
||||
|
||||
public function notifyOnCommentMentions(): bool
|
||||
{
|
||||
return $this->getNotificationSetting('comment-mentions');
|
||||
}
|
||||
|
||||
public function updateFromSettingsArray(array $settings)
|
||||
{
|
||||
$allowList = ['own-page-changes', 'own-page-comments', 'comment-replies', 'comment-mentions'];
|
||||
$allowList = ['own-page-changes', 'own-page-comments', 'comment-replies'];
|
||||
foreach ($settings as $setting => $status) {
|
||||
if (!in_array($setting, $allowList)) {
|
||||
continue;
|
||||
|
||||
@@ -8,14 +8,12 @@ use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\ParentChanger;
|
||||
use BookStack\Permissions\Permission;
|
||||
|
||||
class BookSorter
|
||||
{
|
||||
public function __construct(
|
||||
protected EntityQueries $queries,
|
||||
protected ParentChanger $parentChanger,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -157,7 +155,7 @@ class BookSorter
|
||||
|
||||
// Action the required changes
|
||||
if ($bookChanged) {
|
||||
$this->parentChanger->changeBook($model, $newBook->id);
|
||||
$model = $model->changeBook($newBook->id);
|
||||
}
|
||||
|
||||
if ($model instanceof Page && $chapterChanged) {
|
||||
|
||||
@@ -4,17 +4,25 @@ namespace BookStack\Theming;
|
||||
|
||||
use BookStack\Util\CspService;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use BookStack\Util\HtmlNonceApplicator;
|
||||
use Illuminate\Contracts\Cache\Repository as Cache;
|
||||
|
||||
class CustomHtmlHeadContentProvider
|
||||
{
|
||||
public function __construct(
|
||||
protected CspService $cspService,
|
||||
protected Cache $cache,
|
||||
protected ThemeService $themeService,
|
||||
) {
|
||||
/**
|
||||
* @var CspService
|
||||
*/
|
||||
protected $cspService;
|
||||
|
||||
/**
|
||||
* @var Cache
|
||||
*/
|
||||
protected $cache;
|
||||
|
||||
public function __construct(CspService $cspService, Cache $cache)
|
||||
{
|
||||
$this->cspService = $cspService;
|
||||
$this->cache = $cache;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,9 +32,8 @@ class CustomHtmlHeadContentProvider
|
||||
public function forWeb(): string
|
||||
{
|
||||
$content = $this->getSourceContent();
|
||||
$hash = md5($content) . ':' . $this->themeService->getModulesHash();
|
||||
$hash = md5($content);
|
||||
$html = $this->cache->remember('custom-head-web:' . $hash, 86400, function () use ($content) {
|
||||
$content .= "\n" . $this->getModuleHeadContent();
|
||||
return HtmlNonceApplicator::prepare($content);
|
||||
});
|
||||
|
||||
@@ -43,8 +50,7 @@ class CustomHtmlHeadContentProvider
|
||||
$hash = md5($content);
|
||||
|
||||
return $this->cache->remember('custom-head-export:' . $hash, 86400, function () use ($content) {
|
||||
$config = new HtmlContentFilterConfig(filterOutNonContentElements: false, useAllowListFilter: false);
|
||||
return (new HtmlContentFilter($config))->filterString($content);
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($content);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,23 +61,4 @@ class CustomHtmlHeadContentProvider
|
||||
{
|
||||
return setting('app-custom-head', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get any custom head content from installed modules.
|
||||
*/
|
||||
protected function getModuleHeadContent(): string
|
||||
{
|
||||
$content = '';
|
||||
foreach ($this->themeService->getModules() as $module) {
|
||||
$headContentPath = $module->path('head');
|
||||
if (file_exists($headContentPath) && is_dir($headContentPath)) {
|
||||
$htmlFiles = glob($headContentPath . '/*.html');
|
||||
foreach ($htmlFiles as $file) {
|
||||
$content .= file_get_contents($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,22 +5,21 @@ namespace BookStack\Theming;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Util\FilePathNormalizer;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ThemeController extends Controller
|
||||
{
|
||||
/**
|
||||
* Serve a public file from the configured theme.
|
||||
*/
|
||||
public function publicFile(string $theme, string $path): StreamedResponse
|
||||
public function publicFile(string $theme, string $path)
|
||||
{
|
||||
$cleanPath = FilePathNormalizer::normalize($path);
|
||||
if ($theme !== Theme::getTheme() || !$cleanPath) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$filePath = Theme::findFirstFile("public/{$cleanPath}");
|
||||
if (!$filePath) {
|
||||
$filePath = theme_path("public/{$cleanPath}");
|
||||
if (!file_exists($filePath)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
|
||||
@@ -87,17 +87,6 @@ class ThemeEvents
|
||||
*/
|
||||
const COMMONMARK_ENVIRONMENT_CONFIGURE = 'commonmark_environment_configure';
|
||||
|
||||
/**
|
||||
* OIDC auth pre-redirect event.
|
||||
* Runs just before BookStack redirects the user to the identity provider for authentication.
|
||||
* Provides the redirect URL that will be used.
|
||||
* If the listener returns a string value, that will be used as the redirect URL instead.
|
||||
*
|
||||
* @param string $redirectUrl
|
||||
* @return string|null
|
||||
*/
|
||||
const OIDC_AUTH_PRE_REDIRECT = 'oidc_auth_pre_redirect';
|
||||
|
||||
/**
|
||||
* OIDC ID token pre-validate event.
|
||||
* Runs just before BookStack validates the user ID token data upon login.
|
||||
@@ -111,31 +100,6 @@ class ThemeEvents
|
||||
*/
|
||||
const OIDC_ID_TOKEN_PRE_VALIDATE = 'oidc_id_token_pre_validate';
|
||||
|
||||
/**
|
||||
* Page content post-render event.
|
||||
* Runs after any display rendering of page content, typically when page content is being processed for viewing.
|
||||
* Rendering typically includes parsing of page includes, and content filtering.
|
||||
* Provides the HTML content about to be shown, along with the related page instance.
|
||||
* If the listener returns a string value, that will be used as the HTML content instead.
|
||||
*
|
||||
* @param string $html
|
||||
* @param \BookStack\Entities\Models\Page $page
|
||||
* @return string|null
|
||||
*/
|
||||
const PAGE_CONTENT_POST_RENDER = 'page_content_post_render';
|
||||
|
||||
/**
|
||||
* Page content pre-store event.
|
||||
* Runs just before page HTML is stored in the database, after BookStack's own processing.
|
||||
* Provides the HTML content about to be stored, along with the related page instance.
|
||||
* If the listener returns a string value, that will be used as the HTML content instead.
|
||||
*
|
||||
* @param string $html
|
||||
* @param \BookStack\Entities\Models\Page $page
|
||||
* @return string|null
|
||||
*/
|
||||
const PAGE_CONTENT_PRE_STORE = 'page_content_pre_store';
|
||||
|
||||
/**
|
||||
* Page include parse event.
|
||||
* Runs when a page include tag is being parsed, typically when page content is being processed for viewing.
|
||||
@@ -170,16 +134,6 @@ class ThemeEvents
|
||||
*/
|
||||
const ROUTES_REGISTER_WEB_AUTH = 'routes_register_web_auth';
|
||||
|
||||
|
||||
/**
|
||||
* Theme register views event.
|
||||
* Called by the theme system when a theme is active, so that custom view templates can be registered
|
||||
* to be rendered in addition to existing app views.
|
||||
*
|
||||
* @param \BookStack\Theming\ThemeViews $themeViews
|
||||
*/
|
||||
const THEME_REGISTER_VIEWS = 'theme_register_views';
|
||||
|
||||
/**
|
||||
* Web before middleware action.
|
||||
* Runs before the request is handled but after all other middleware apart from those
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Theming;
|
||||
|
||||
readonly class ThemeModule
|
||||
{
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public string $description,
|
||||
public string $version,
|
||||
public string $folderName,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a ThemeModule instance from JSON data.
|
||||
*
|
||||
* @throws ThemeModuleException
|
||||
*/
|
||||
public static function fromJson(array $data, string $folderName): self
|
||||
{
|
||||
if (empty($data['name']) || !is_string($data['name'])) {
|
||||
throw new ThemeModuleException("Module in folder \"{$folderName}\" is missing a valid 'name' property");
|
||||
}
|
||||
|
||||
if (!isset($data['description']) || !is_string($data['description'])) {
|
||||
throw new ThemeModuleException("Module in folder \"{$folderName}\" is missing a valid 'description' property");
|
||||
}
|
||||
|
||||
if (!isset($data['version']) || !is_string($data['version'])) {
|
||||
throw new ThemeModuleException("Module in folder \"{$folderName}\" is missing a valid 'version' property");
|
||||
}
|
||||
|
||||
if (!preg_match('/^v?\d+\.\d+\.\d+(-.*)?$/', $data['version'])) {
|
||||
throw new ThemeModuleException("Module in folder \"{$folderName}\" has an invalid 'version' format. Expected semantic version format like '1.0.0' or 'v1.0.0'");
|
||||
}
|
||||
|
||||
return new self(
|
||||
name: $data['name'],
|
||||
description: $data['description'],
|
||||
version: $data['version'],
|
||||
folderName: $folderName,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a path for a file within this module.
|
||||
*/
|
||||
public function path($path = ''): string
|
||||
{
|
||||
$component = trim($path, '/');
|
||||
return theme_path("modules/{$this->folderName}/{$component}");
|
||||
}
|
||||
|
||||
public function getVersion(): string
|
||||
{
|
||||
return str_starts_with($this->version, 'v') ? $this->version : 'v' . $this->version;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Theming;
|
||||
|
||||
class ThemeModuleException extends \Exception
|
||||
{
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Theming;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ThemeModuleManager
|
||||
{
|
||||
/** @var array<string, ThemeModule>|null */
|
||||
protected array|null $loadedModules = null;
|
||||
|
||||
public function __construct(
|
||||
protected string $modulesFolderPath
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ThemeModule>
|
||||
*/
|
||||
public function getByName(string $name): array
|
||||
{
|
||||
return array_filter($this->load(), fn(ThemeModule $module) => $module->name === $name);
|
||||
}
|
||||
|
||||
public function deleteModuleFolder(string $moduleFolderName): void
|
||||
{
|
||||
$modules = $this->load();
|
||||
$module = $modules[$moduleFolderName] ?? null;
|
||||
if (!$module) {
|
||||
return;
|
||||
}
|
||||
|
||||
$moduleFolderPath = $module->path('');
|
||||
if (!file_exists($moduleFolderPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->deleteDirectoryRecursively($moduleFolderPath);
|
||||
unset($this->loadedModules[$moduleFolderName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ThemeModuleException
|
||||
*/
|
||||
public function addFromZip(string $name, ThemeModuleZip $zip): ThemeModule
|
||||
{
|
||||
$baseFolderName = Str::limit(Str::slug($name), 40, '');
|
||||
$folderName = $baseFolderName;
|
||||
while (!$baseFolderName || file_exists($this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName)) {
|
||||
$folderName = ($baseFolderName ?: 'mod') . '-' . Str::random(4);
|
||||
}
|
||||
|
||||
$folderPath = $this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName;
|
||||
$zip->extractTo($folderPath);
|
||||
|
||||
$module = $this->loadFromFolder($folderName);
|
||||
if (!$module) {
|
||||
throw new ThemeModuleException("Failed to load module from zip file after extraction");
|
||||
}
|
||||
|
||||
return $module;
|
||||
}
|
||||
|
||||
protected function deleteDirectoryRecursively(string $path): void
|
||||
{
|
||||
$items = array_diff(scandir($path), ['.', '..']);
|
||||
foreach ($items as $item) {
|
||||
$itemPath = $path . DIRECTORY_SEPARATOR . $item;
|
||||
if (is_dir($itemPath)) {
|
||||
$this->deleteDirectoryRecursively($itemPath);
|
||||
} else {
|
||||
$deleted = unlink($itemPath);
|
||||
if (!$deleted) {
|
||||
throw new ThemeModuleException("Failed to delete file at \"{$itemPath}\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
rmdir($path);
|
||||
}
|
||||
|
||||
public function load(): array
|
||||
{
|
||||
if ($this->loadedModules !== null) {
|
||||
return $this->loadedModules;
|
||||
}
|
||||
|
||||
if (!is_dir($this->modulesFolderPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$subFolders = array_filter(scandir($this->modulesFolderPath), function ($item) {
|
||||
return $item !== '.' && $item !== '..' && is_dir($this->modulesFolderPath . DIRECTORY_SEPARATOR . $item);
|
||||
});
|
||||
|
||||
$modules = [];
|
||||
|
||||
foreach ($subFolders as $folderName) {
|
||||
$module = $this->loadFromFolder($folderName);
|
||||
if ($module) {
|
||||
$modules[$folderName] = $module;
|
||||
}
|
||||
}
|
||||
|
||||
$this->loadedModules = $modules;
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
protected function loadFromFolder(string $folderName): ThemeModule|null
|
||||
{
|
||||
$moduleJsonFile = $this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName . DIRECTORY_SEPARATOR . 'bookstack-module.json';
|
||||
if (!file_exists($moduleJsonFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$jsonContent = file_get_contents($moduleJsonFile);
|
||||
$jsonData = json_decode($jsonContent, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
throw new ThemeModuleException("Invalid JSON in module file at \"{$moduleJsonFile}\": " . json_last_error_msg());
|
||||
}
|
||||
|
||||
$module = ThemeModule::fromJson($jsonData, $folderName);
|
||||
} catch (ThemeModuleException $exception) {
|
||||
throw $exception;
|
||||
} catch (\Exception $exception) {
|
||||
throw new ThemeModuleException("Failed loading module from \"{$moduleJsonFile}\" with error: {$exception->getMessage()}");
|
||||
}
|
||||
|
||||
return $module;
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Theming;
|
||||
|
||||
use ZipArchive;
|
||||
|
||||
readonly class ThemeModuleZip
|
||||
{
|
||||
public function __construct(
|
||||
protected string $path
|
||||
) {
|
||||
}
|
||||
|
||||
public function extractTo(string $destinationPath): void
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($this->path);
|
||||
$zip->extractTo($destinationPath);
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the module's JSON metadata to read it into a ThemeModule instance.
|
||||
* @throws ThemeModuleException
|
||||
*/
|
||||
public function getModuleInstance(): ThemeModule
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
$open = $zip->open($this->path);
|
||||
if ($open !== true) {
|
||||
throw new ThemeModuleException("Unable to open zip file at {$this->path}");
|
||||
}
|
||||
|
||||
$moduleJsonText = $zip->getFromName('bookstack-module.json');
|
||||
$zip->close();
|
||||
|
||||
if ($moduleJsonText === false) {
|
||||
throw new ThemeModuleException("bookstack-module.json not found within module ZIP at {$this->path}");
|
||||
}
|
||||
|
||||
$moduleJson = json_decode($moduleJsonText, true);
|
||||
if ($moduleJson === null) {
|
||||
throw new ThemeModuleException("Could not read JSON from bookstack-module.json within module ZIP at {$this->path}");
|
||||
}
|
||||
|
||||
return ThemeModule::fromJson($moduleJson, '_temp');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the zip file.
|
||||
*/
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the zip file exists and that it appears to be a valid zip file.
|
||||
*/
|
||||
public function exists(): bool
|
||||
{
|
||||
if (!file_exists($this->path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$open = $zip->open($this->path, ZipArchive::RDONLY);
|
||||
if ($open === true) {
|
||||
$zip->close();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total size of the zip file contents when uncompressed.
|
||||
*/
|
||||
public function getContentsSize(): int
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
|
||||
if ($zip->open($this->path) !== true) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$totalSize = 0;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
if ($stat !== false) {
|
||||
$totalSize += $stat['size'];
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $totalSize;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Exceptions\ThemeException;
|
||||
use Illuminate\Console\Application;
|
||||
use Illuminate\Console\Application as Artisan;
|
||||
use Illuminate\View\FileViewFinder;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
|
||||
class ThemeService
|
||||
@@ -16,11 +15,6 @@ class ThemeService
|
||||
*/
|
||||
protected array $listeners = [];
|
||||
|
||||
/**
|
||||
* @var array<string, ThemeModule>
|
||||
*/
|
||||
protected array $modules = [];
|
||||
|
||||
/**
|
||||
* Get the currently configured theme.
|
||||
* Returns an empty string if not configured.
|
||||
@@ -82,85 +76,20 @@ class ThemeService
|
||||
}
|
||||
|
||||
/**
|
||||
* Read any actions from the 'functions.php' file of the active theme or its modules.
|
||||
* Read any actions from the set theme path if the 'functions.php' file exists.
|
||||
*/
|
||||
public function readThemeActions(): void
|
||||
{
|
||||
$moduleFunctionFiles = array_map(function (ThemeModule $module): string {
|
||||
return $module->path('functions.php');
|
||||
}, $this->modules);
|
||||
$allFunctionFiles = array_merge(array_values($moduleFunctionFiles), [theme_path('functions.php')]);
|
||||
$filteredFunctionFiles = array_filter($allFunctionFiles, function (string $file): bool {
|
||||
return $file && file_exists($file);
|
||||
});
|
||||
|
||||
foreach ($filteredFunctionFiles as $functionFile) {
|
||||
$themeActionsFile = theme_path('functions.php');
|
||||
if ($themeActionsFile && file_exists($themeActionsFile)) {
|
||||
try {
|
||||
require $functionFile;
|
||||
require $themeActionsFile;
|
||||
} catch (\Error $exception) {
|
||||
throw new ThemeException("Failed loading theme functions file at \"{$functionFile}\" with error: {$exception->getMessage()}");
|
||||
throw new ThemeException("Failed loading theme functions file at \"{$themeActionsFile}\" with error: {$exception->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the modules folder and load in any valid theme modules.
|
||||
* @throws ThemeModuleException
|
||||
*/
|
||||
public function loadModules(): void
|
||||
{
|
||||
$modulesFolder = theme_path('modules');
|
||||
if (!$modulesFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->modules = (new ThemeModuleManager($modulesFolder))->load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all loaded theme modules.
|
||||
* @return array<string, ThemeModule>
|
||||
*/
|
||||
public function getModules(): array
|
||||
{
|
||||
return $this->modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a hash to represent the currently loaded modules.
|
||||
*/
|
||||
public function getModulesHash(): string
|
||||
{
|
||||
$key = "";
|
||||
|
||||
foreach ($this->modules as $module) {
|
||||
$key .= $module->name . ':' . $module->version . ';';
|
||||
}
|
||||
|
||||
return md5($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look for a specific file within the theme or its modules.
|
||||
* Returns the first file found or null if not found.
|
||||
*/
|
||||
public function findFirstFile(string $path): ?string
|
||||
{
|
||||
$themePath = theme_path($path);
|
||||
if (file_exists($themePath)) {
|
||||
return $themePath;
|
||||
}
|
||||
|
||||
foreach ($this->modules as $module) {
|
||||
$customizedFile = $module->path($path);
|
||||
if (file_exists($customizedFile)) {
|
||||
return $customizedFile;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see SocialDriverManager::addSocialDriver
|
||||
*/
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Theming;
|
||||
|
||||
use BookStack\Exceptions\ThemeException;
|
||||
use Illuminate\View\FileViewFinder;
|
||||
|
||||
class ThemeViews
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<string, int>>
|
||||
*/
|
||||
protected array $beforeViews = [];
|
||||
|
||||
/**
|
||||
* @var array<string, array<string, int>>
|
||||
*/
|
||||
protected array $afterViews = [];
|
||||
|
||||
public function __construct(
|
||||
protected FileViewFinder $finder
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register any extra paths for where we may expect views to be located
|
||||
* with the FileViewFinder, to make custom views available for use.
|
||||
* @param ThemeModule[] $modules
|
||||
*/
|
||||
public function registerViewPathsForTheme(array $modules): void
|
||||
{
|
||||
foreach ($modules as $module) {
|
||||
$moduleViewsPath = $module->path('views');
|
||||
if (file_exists($moduleViewsPath) && is_dir($moduleViewsPath)) {
|
||||
$this->finder->prependLocation($moduleViewsPath);
|
||||
}
|
||||
}
|
||||
|
||||
$this->finder->prependLocation(theme_path());
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the response for a blade template view include.
|
||||
*/
|
||||
public function handleViewInclude(string $viewPath, array $data = [], array $mergeData = []): string
|
||||
{
|
||||
if (!$this->hasRegisteredViews()) {
|
||||
return view()->make($viewPath, $data, $mergeData)->render();
|
||||
}
|
||||
|
||||
if (str_contains('book-tree', $viewPath)) {
|
||||
dd($viewPath, $data);
|
||||
}
|
||||
|
||||
$viewsContent = [
|
||||
...$this->renderViewSets($this->beforeViews[$viewPath] ?? [], $data, $mergeData),
|
||||
view()->make($viewPath, $data, $mergeData)->render(),
|
||||
...$this->renderViewSets($this->afterViews[$viewPath] ?? [], $data, $mergeData),
|
||||
];
|
||||
|
||||
return implode("\n", $viewsContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom view to be rendered before the given target view is included in the template system.
|
||||
*/
|
||||
public function renderBefore(string $targetView, string $localView, int $priority = 50): void
|
||||
{
|
||||
$this->registerAdjacentView($this->beforeViews, $targetView, $localView, $priority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom view to be rendered after the given target view is included in the template system.
|
||||
*/
|
||||
public function renderAfter(string $targetView, string $localView, int $priority = 50): void
|
||||
{
|
||||
$this->registerAdjacentView($this->afterViews, $targetView, $localView, $priority);
|
||||
}
|
||||
|
||||
public function hasRegisteredViews(): bool
|
||||
{
|
||||
return !empty($this->beforeViews) || !empty($this->afterViews);
|
||||
}
|
||||
|
||||
protected function registerAdjacentView(array &$location, string $targetView, string $localView, int $priority = 50): void
|
||||
{
|
||||
try {
|
||||
$viewPath = $this->finder->find($localView);
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
throw new ThemeException("Expected registered view file with name \"{$localView}\" could not be found.");
|
||||
}
|
||||
|
||||
if (!isset($location[$targetView])) {
|
||||
$location[$targetView] = [];
|
||||
}
|
||||
|
||||
$location[$targetView][$viewPath] = $priority;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $viewSet
|
||||
* @return string[]
|
||||
*/
|
||||
protected function renderViewSets(array $viewSet, array $data, array $mergeData): array
|
||||
{
|
||||
$paths = array_keys($viewSet);
|
||||
usort($paths, function (string $a, string $b) use ($viewSet) {
|
||||
return $viewSet[$a] <=> $viewSet[$b];
|
||||
});
|
||||
|
||||
return array_map(function (string $viewPath) use ($data, $mergeData) {
|
||||
return view()->file($viewPath, $data, $mergeData)->render();
|
||||
}, $paths);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace BookStack\Translation;
|
||||
|
||||
use BookStack\Facades\Theme;
|
||||
use Illuminate\Translation\FileLoader as BaseLoader;
|
||||
|
||||
class FileLoader extends BaseLoader
|
||||
@@ -13,6 +12,11 @@ class FileLoader extends BaseLoader
|
||||
* Extends Laravel's translation FileLoader to look in multiple directories
|
||||
* so that we can load in translation overrides from the theme file if wanted.
|
||||
*
|
||||
* Note: As of using Laravel 10, this may now be redundant since Laravel's
|
||||
* file loader supports multiple paths. This needs further testing though
|
||||
* to confirm if Laravel works how we expect, since we specifically need
|
||||
* the theme folder to be able to partially override core lang files.
|
||||
*
|
||||
* @param string $locale
|
||||
* @param string $group
|
||||
* @param string|null $namespace
|
||||
@@ -28,18 +32,9 @@ class FileLoader extends BaseLoader
|
||||
if (is_null($namespace) || $namespace === '*') {
|
||||
$themePath = theme_path('lang');
|
||||
$themeTranslations = $themePath ? $this->loadPaths([$themePath], $locale, $group) : [];
|
||||
|
||||
$modules = Theme::getModules();
|
||||
$moduleTranslations = [];
|
||||
foreach ($modules as $module) {
|
||||
$modulePath = $module->path('lang');
|
||||
if (file_exists($modulePath)) {
|
||||
$moduleTranslations = array_merge($moduleTranslations, $this->loadPaths([$modulePath], $locale, $group));
|
||||
}
|
||||
}
|
||||
|
||||
$originalTranslations = $this->loadPaths($this->paths, $locale, $group);
|
||||
return array_merge($originalTranslations, $moduleTranslations, $themeTranslations);
|
||||
|
||||
return array_merge($originalTranslations, $themeTranslations);
|
||||
}
|
||||
|
||||
return $this->loadNamespaced($locale, $group, $namespace);
|
||||
|
||||
@@ -195,7 +195,6 @@ class AttachmentController extends Controller
|
||||
$this->validate($request, [
|
||||
'order' => ['required', 'array'],
|
||||
]);
|
||||
|
||||
$page = $this->pageQueries->findVisibleByIdOrFail($pageId);
|
||||
$this->checkOwnablePermission(Permission::PageUpdate, $page);
|
||||
|
||||
@@ -222,6 +221,8 @@ class AttachmentController extends Controller
|
||||
throw new NotFoundException(trans('errors.attachment_not_found'));
|
||||
}
|
||||
|
||||
$this->checkOwnablePermission(Permission::PageView, $page);
|
||||
|
||||
if ($attachment->external) {
|
||||
return redirect($attachment->path);
|
||||
}
|
||||
@@ -246,13 +247,6 @@ class AttachmentController extends Controller
|
||||
{
|
||||
/** @var Attachment $attachment */
|
||||
$attachment = Attachment::query()->findOrFail($attachmentId);
|
||||
|
||||
try {
|
||||
$this->pageQueries->findVisibleByIdOrFail($attachment->uploaded_to);
|
||||
} catch (NotFoundException $exception) {
|
||||
throw new NotFoundException(trans('errors.attachment_not_found'));
|
||||
}
|
||||
|
||||
$this->checkOwnablePermission(Permission::AttachmentDelete, $attachment);
|
||||
$this->attachmentService->deleteFile($attachment);
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ class ImageResizer
|
||||
|
||||
/**
|
||||
* Get the thumbnail for an image.
|
||||
* If $keepRatio is true, only the width will be used.
|
||||
* If $keepRatio is true only the width will be used.
|
||||
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
|
||||
*
|
||||
* @throws Exception
|
||||
@@ -84,7 +84,7 @@ class ImageResizer
|
||||
return $this->storage->getPublicUrl($cachedThumbPath);
|
||||
}
|
||||
|
||||
// If a thumbnail has already been generated, serve that and cache path
|
||||
// If thumbnail has already been generated, serve that and cache path
|
||||
$disk = $this->storage->getDisk($image->type);
|
||||
if (!$shouldCreate && $disk->exists($thumbFilePath)) {
|
||||
Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
|
||||
@@ -110,7 +110,7 @@ class ImageResizer
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize the image of given data to the specified size and return the new image data.
|
||||
* Resize the image of given data to the specified size, and return the new image data.
|
||||
* Format will remain the same as the input format, unless specified.
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
@@ -125,7 +125,6 @@ class ImageResizer
|
||||
try {
|
||||
$thumb = $this->interventionFromImageData($imageData, $format);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to resize image with error:' . $e->getMessage());
|
||||
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
|
||||
}
|
||||
|
||||
@@ -155,21 +154,17 @@ class ImageResizer
|
||||
|
||||
/**
|
||||
* Create an intervention image instance from the given image data.
|
||||
* Performs some manual library usage to ensure the image is specifically loaded
|
||||
* Performs some manual library usage to ensure image is specifically loaded
|
||||
* from given binary data instead of data being misinterpreted.
|
||||
*/
|
||||
protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage
|
||||
{
|
||||
if (!extension_loaded('gd')) {
|
||||
throw new ImageUploadException('The PHP "gd" extension is required to resize images, but is missing.');
|
||||
}
|
||||
|
||||
$manager = new ImageManager(
|
||||
new Driver(),
|
||||
autoOrientation: false,
|
||||
);
|
||||
|
||||
// Ensure GIF images are decoded natively instead of deferring to intervention GIF
|
||||
// Ensure gif images are decoded natively instead of deferring to intervention GIF
|
||||
// handling since we don't need the added animation support.
|
||||
$isGif = $fileType === 'gif';
|
||||
$decoder = $isGif ? NativeObjectDecoder::class : BinaryImageDecoder::class;
|
||||
@@ -228,7 +223,7 @@ class ImageResizer
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the image is a GIF. Returns true if it is, else false.
|
||||
* Checks if the image is a gif. Returns true if it is, else false.
|
||||
*/
|
||||
protected function isGif(Image $image): bool
|
||||
{
|
||||
@@ -255,7 +250,7 @@ class ImageResizer
|
||||
|
||||
/**
|
||||
* Check if the given avif image data represents an animated image.
|
||||
* This is based upon the answer here: https://stackoverflow.com/a/79457313
|
||||
* This is based up the answer here: https://stackoverflow.com/a/79457313
|
||||
*/
|
||||
protected function isAnimatedAvifData(string &$imageData): bool
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace BookStack\Users\Controllers;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UserSearchController extends Controller
|
||||
@@ -35,43 +34,8 @@ class UserSearchController extends Controller
|
||||
$query->where('name', 'like', '%' . $search . '%');
|
||||
}
|
||||
|
||||
/** @var Collection<User> $users */
|
||||
$users = $query->get();
|
||||
|
||||
return view('form.user-select-list', [
|
||||
'users' => $users,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search users in the system, with the response formatted
|
||||
* for use in a list of mentions.
|
||||
*/
|
||||
public function forMentions(Request $request)
|
||||
{
|
||||
$hasPermission = !user()->isGuest() && (
|
||||
userCan(Permission::CommentCreateAll)
|
||||
|| userCan(Permission::CommentUpdate)
|
||||
);
|
||||
|
||||
if (!$hasPermission) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
|
||||
$search = $request->get('search', '');
|
||||
$query = User::query()
|
||||
->orderBy('name', 'asc')
|
||||
->take(20);
|
||||
|
||||
if (!empty($search)) {
|
||||
$query->where('name', 'like', '%' . $search . '%');
|
||||
}
|
||||
|
||||
/** @var Collection<User> $users */
|
||||
$users = $query->get();
|
||||
|
||||
return view('form.user-mention-list', [
|
||||
'users' => $users,
|
||||
'users' => $query->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use BookStack\Activity\Models\Watch;
|
||||
use BookStack\Api\ApiToken;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\App\SluggableInterface;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Translation\LocaleDefinition;
|
||||
use BookStack\Translation\LocaleManager;
|
||||
@@ -357,4 +358,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
{
|
||||
return "({$this->id}) {$this->name}";
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function refreshSlug(): string
|
||||
{
|
||||
$this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
|
||||
|
||||
return $this->slug;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace BookStack\Users;
|
||||
use BookStack\Access\UserInviteException;
|
||||
use BookStack\Access\UserInviteService;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Exceptions\UserUpdateException;
|
||||
use BookStack\Facades\Activity;
|
||||
@@ -22,8 +21,7 @@ class UserRepo
|
||||
{
|
||||
public function __construct(
|
||||
protected UserAvatars $userAvatar,
|
||||
protected UserInviteService $inviteService,
|
||||
protected SlugGenerator $slugGenerator,
|
||||
protected UserInviteService $inviteService
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -65,7 +63,7 @@ class UserRepo
|
||||
$user->email_confirmed = $emailConfirmed;
|
||||
$user->external_auth_id = $data['external_auth_id'] ?? '';
|
||||
|
||||
$this->slugGenerator->regenerateForUser($user);
|
||||
$user->refreshSlug();
|
||||
$user->save();
|
||||
|
||||
if (!empty($data['language'])) {
|
||||
@@ -111,7 +109,7 @@ class UserRepo
|
||||
{
|
||||
if (!empty($data['name'])) {
|
||||
$user->name = $data['name'];
|
||||
$this->slugGenerator->regenerateForUser($user);
|
||||
$user->refreshSlug();
|
||||
}
|
||||
|
||||
if (!empty($data['email']) && $manageUsersAllowed) {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Util;
|
||||
|
||||
use BookStack\App\AppVersion;
|
||||
use HTMLPurifier;
|
||||
use HTMLPurifier_Config;
|
||||
use HTMLPurifier_DefinitionCache_Serializer;
|
||||
use HTMLPurifier_HTML5Config;
|
||||
use HTMLPurifier_HTMLDefinition;
|
||||
|
||||
/**
|
||||
* Provides a configured HTML Purifier instance.
|
||||
* https://github.com/ezyang/htmlpurifier
|
||||
* Also uses this to extend support to HTML5 elements:
|
||||
* https://github.com/xemlock/htmlpurifier-html5
|
||||
*/
|
||||
class ConfiguredHtmlPurifier
|
||||
{
|
||||
protected HTMLPurifier $purifier;
|
||||
protected static bool $cachedChecked = false;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// This is done by the web-server at run-time, with the existing
|
||||
// storage/framework/cache folder to ensure we're using a server-writable folder.
|
||||
$cachePath = storage_path('framework/cache/purifier');
|
||||
$this->createCacheFolderIfNeeded($cachePath);
|
||||
|
||||
$config = HTMLPurifier_HTML5Config::createDefault();
|
||||
$this->setConfig($config, $cachePath);
|
||||
$this->resetCacheIfNeeded($config);
|
||||
|
||||
$htmlDef = $config->getDefinition('HTML', true, true);
|
||||
if ($htmlDef instanceof HTMLPurifier_HTMLDefinition) {
|
||||
$this->configureDefinition($htmlDef);
|
||||
}
|
||||
|
||||
$this->purifier = new HTMLPurifier($config);
|
||||
}
|
||||
|
||||
protected function createCacheFolderIfNeeded(string $cachePath): void
|
||||
{
|
||||
if (!file_exists($cachePath)) {
|
||||
mkdir($cachePath, 0777, true);
|
||||
}
|
||||
}
|
||||
|
||||
protected function resetCacheIfNeeded(HTMLPurifier_Config $config): void
|
||||
{
|
||||
if (self::$cachedChecked) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cachedForVersion = cache('htmlpurifier::cache-version');
|
||||
$appVersion = AppVersion::get();
|
||||
if ($cachedForVersion !== $appVersion) {
|
||||
foreach (['HTML', 'CSS', 'URI'] as $name) {
|
||||
$cache = new HTMLPurifier_DefinitionCache_Serializer($name);
|
||||
$cache->flush($config);
|
||||
}
|
||||
cache()->set('htmlpurifier::cache-version', $appVersion);
|
||||
}
|
||||
|
||||
self::$cachedChecked = true;
|
||||
}
|
||||
|
||||
protected function setConfig(HTMLPurifier_Config $config, string $cachePath): void
|
||||
{
|
||||
$config->set('Cache.SerializerPath', $cachePath);
|
||||
$config->set('Core.AllowHostnameUnderscore', true);
|
||||
$config->set('CSS.AllowTricky', true);
|
||||
$config->set('HTML.SafeIframe', true);
|
||||
$config->set('HTML.TargetNoopener', false);
|
||||
$config->set('HTML.TargetNoreferrer', false);
|
||||
$config->set('Attr.EnableID', true);
|
||||
$config->set('Attr.ID.HTML5', true);
|
||||
$config->set('Output.FixInnerHTML', false);
|
||||
$config->set('URI.SafeIframeRegexp', '%^(http://|https://|//)%');
|
||||
$config->set('URI.AllowedSchemes', [
|
||||
'http' => true,
|
||||
'https' => true,
|
||||
'mailto' => true,
|
||||
'ftp' => true,
|
||||
'nntp' => true,
|
||||
'news' => true,
|
||||
'tel' => true,
|
||||
'file' => true,
|
||||
]);
|
||||
|
||||
// $config->set('Cache.DefinitionImpl', null); // Disable cache during testing
|
||||
}
|
||||
|
||||
public function configureDefinition(HTMLPurifier_HTMLDefinition $definition): void
|
||||
{
|
||||
// Allow the object element
|
||||
$definition->addElement(
|
||||
'object',
|
||||
'Inline',
|
||||
'Flow',
|
||||
'Common',
|
||||
[
|
||||
'data' => 'URI',
|
||||
'type' => 'Text',
|
||||
'width' => 'Length',
|
||||
'height' => 'Length',
|
||||
]
|
||||
);
|
||||
|
||||
// Allow the embed element
|
||||
$definition->addElement(
|
||||
'embed',
|
||||
'Inline',
|
||||
'Empty',
|
||||
'Common',
|
||||
[
|
||||
'src' => 'URI',
|
||||
'type' => 'Text',
|
||||
'width' => 'Length',
|
||||
'height' => 'Length',
|
||||
]
|
||||
);
|
||||
|
||||
// Allow checkbox inputs
|
||||
$definition->addElement(
|
||||
'input',
|
||||
'Formctrl',
|
||||
'Empty',
|
||||
'Common',
|
||||
[
|
||||
'checked' => 'Bool#checked',
|
||||
'disabled' => 'Bool#disabled',
|
||||
'name' => 'Text',
|
||||
'readonly' => 'Bool#readonly',
|
||||
'type' => 'Enum#checkbox',
|
||||
'value' => 'Text',
|
||||
]
|
||||
);
|
||||
|
||||
// Allow the drawio-diagram attribute on div elements
|
||||
$definition->addAttribute(
|
||||
'div',
|
||||
'drawio-diagram',
|
||||
'Number',
|
||||
);
|
||||
|
||||
// Allow target="_blank" on links
|
||||
$definition->addAttribute('a', 'target', 'Enum#_blank');
|
||||
|
||||
// Allow mention-ids on links
|
||||
$definition->addAttribute('a', 'data-mention-user-id', 'Number');
|
||||
}
|
||||
|
||||
public function purify(string $html): string
|
||||
{
|
||||
return $this->purifier->purify($html);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ class CspService
|
||||
*/
|
||||
protected function getScriptSrc(): string
|
||||
{
|
||||
if ($this->scriptFilteringDisabled()) {
|
||||
if (config('app.allow_content_scripts')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ class CspService
|
||||
*/
|
||||
protected function getObjectSrc(): string
|
||||
{
|
||||
if ($this->scriptFilteringDisabled()) {
|
||||
if (config('app.allow_content_scripts')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -124,11 +124,6 @@ class CspService
|
||||
return "base-uri 'self'";
|
||||
}
|
||||
|
||||
protected function scriptFilteringDisabled(): bool
|
||||
{
|
||||
return !HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'))->filterOutJavaScript;
|
||||
}
|
||||
|
||||
protected function getAllowedIframeHosts(): array
|
||||
{
|
||||
$hosts = config('app.iframe_hosts') ?? '';
|
||||
|
||||
@@ -8,46 +8,10 @@ use DOMNodeList;
|
||||
|
||||
class HtmlContentFilter
|
||||
{
|
||||
public function __construct(
|
||||
protected HtmlContentFilterConfig $config
|
||||
) {
|
||||
}
|
||||
|
||||
public function filterDocument(HtmlDocument $doc): string
|
||||
{
|
||||
if ($this->config->filterOutJavaScript) {
|
||||
$this->filterOutScriptsFromDocument($doc);
|
||||
}
|
||||
if ($this->config->filterOutFormElements) {
|
||||
$this->filterOutFormElementsFromDocument($doc);
|
||||
}
|
||||
if ($this->config->filterOutBadHtmlElements) {
|
||||
$this->filterOutBadHtmlElementsFromDocument($doc);
|
||||
}
|
||||
if ($this->config->filterOutNonContentElements) {
|
||||
$this->filterOutNonContentElementsFromDocument($doc);
|
||||
}
|
||||
|
||||
$filtered = $doc->getBodyInnerHtml();
|
||||
if ($this->config->useAllowListFilter) {
|
||||
$filtered = $this->applyAllowListFiltering($filtered);
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
public function filterString(string $html): string
|
||||
{
|
||||
return $this->filterDocument(new HtmlDocument($html));
|
||||
}
|
||||
|
||||
protected function applyAllowListFiltering(string $html): string
|
||||
{
|
||||
$purifier = new ConfiguredHtmlPurifier();
|
||||
return $purifier->purify($html);
|
||||
}
|
||||
|
||||
protected function filterOutScriptsFromDocument(HtmlDocument $doc): void
|
||||
/**
|
||||
* Remove all the script elements from the given HTML document.
|
||||
*/
|
||||
public static function removeScriptsFromDocument(HtmlDocument $doc)
|
||||
{
|
||||
// Remove standard script tags
|
||||
$scriptElems = $doc->queryXPath('//script');
|
||||
@@ -57,21 +21,21 @@ class HtmlContentFilter
|
||||
$badLinks = $doc->queryXPath('//*[' . static::xpathContains('@href', 'javascript:') . ']');
|
||||
static::removeNodes($badLinks);
|
||||
|
||||
// Remove elements with form-like attributes with calls to JavaScript URI
|
||||
// Remove forms with calls to JavaScript URI
|
||||
$badForms = $doc->queryXPath('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']');
|
||||
static::removeNodes($badForms);
|
||||
|
||||
// Remove data or JavaScript iFrames & embeds
|
||||
// Remove meta tag to prevent external redirects
|
||||
$metaTags = $doc->queryXPath('//meta[' . static::xpathContains('@content', 'url') . ']');
|
||||
static::removeNodes($metaTags);
|
||||
|
||||
// Remove data or JavaScript iFrames
|
||||
$badIframes = $doc->queryXPath('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]');
|
||||
static::removeNodes($badIframes);
|
||||
|
||||
// Remove data or JavaScript objects
|
||||
$badObjects = $doc->queryXPath('//*[' . static::xpathContains('@data', 'data:') . '] | //*[' . static::xpathContains('@data', 'javascript:') . ']');
|
||||
static::removeNodes($badObjects);
|
||||
|
||||
// Remove attributes, within svg children, hiding JavaScript or data uris.
|
||||
// A bunch of svg element and attribute combinations expose xss possibilities.
|
||||
// For example, SVG animate tag can exploit JavaScript in values.
|
||||
// For example, SVG animate tag can exploit javascript in values.
|
||||
$badValuesAttrs = $doc->queryXPath('//svg//@*[' . static::xpathContains('.', 'data:') . '] | //svg//@*[' . static::xpathContains('.', 'javascript:') . ']');
|
||||
static::removeAttributes($badValuesAttrs);
|
||||
|
||||
@@ -85,52 +49,23 @@ class HtmlContentFilter
|
||||
static::removeAttributes($onAttributes);
|
||||
}
|
||||
|
||||
protected function filterOutFormElementsFromDocument(HtmlDocument $doc): void
|
||||
/**
|
||||
* Remove scripts from the given HTML string.
|
||||
*/
|
||||
public static function removeScriptsFromHtmlString(string $html): string
|
||||
{
|
||||
// Remove form elements
|
||||
$formElements = ['form', 'fieldset', 'button', 'textarea', 'select'];
|
||||
foreach ($formElements as $formElement) {
|
||||
$matchingFormElements = $doc->queryXPath('//' . $formElement);
|
||||
static::removeNodes($matchingFormElements);
|
||||
if (empty($html)) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
// Remove non-checkbox inputs
|
||||
$inputsToRemove = $doc->queryXPath('//input');
|
||||
/** @var DOMElement $input */
|
||||
foreach ($inputsToRemove as $input) {
|
||||
$type = strtolower($input->getAttribute('type'));
|
||||
if ($type !== 'checkbox') {
|
||||
$input->parentNode->removeChild($input);
|
||||
}
|
||||
}
|
||||
$doc = new HtmlDocument($html);
|
||||
static::removeScriptsFromDocument($doc);
|
||||
|
||||
// Remove form attributes
|
||||
$formAttrs = ['form', 'formaction', 'formmethod', 'formtarget'];
|
||||
foreach ($formAttrs as $formAttr) {
|
||||
$matchingFormAttrs = $doc->queryXPath('//@' . $formAttr);
|
||||
static::removeAttributes($matchingFormAttrs);
|
||||
}
|
||||
}
|
||||
|
||||
protected function filterOutBadHtmlElementsFromDocument(HtmlDocument $doc): void
|
||||
{
|
||||
// Remove meta tag to prevent external redirects
|
||||
$metaTags = $doc->queryXPath('//meta[' . static::xpathContains('@content', 'url') . ']');
|
||||
static::removeNodes($metaTags);
|
||||
}
|
||||
|
||||
protected function filterOutNonContentElementsFromDocument(HtmlDocument $doc): void
|
||||
{
|
||||
// Remove non-content elements
|
||||
$formElements = ['link', 'style', 'meta', 'title', 'template'];
|
||||
foreach ($formElements as $formElement) {
|
||||
$matchingFormElements = $doc->queryXPath('//' . $formElement);
|
||||
static::removeNodes($matchingFormElements);
|
||||
}
|
||||
return $doc->getBodyInnerHtml();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an x-path 'contains' statement with a translation automatically built within
|
||||
* Create a xpath contains statement with a translation automatically built within
|
||||
* to affectively search in a cases-insensitive manner.
|
||||
*/
|
||||
protected static function xpathContains(string $property, string $value): string
|
||||
@@ -164,34 +99,4 @@ class HtmlContentFilter
|
||||
$parentNode->removeAttribute($attrName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias using the old method name to avoid potential compatibility breaks during patch release.
|
||||
* To remove in future feature release.
|
||||
* @deprecated Use filterDocument instead.
|
||||
*/
|
||||
public static function removeScriptsFromDocument(HtmlDocument $doc): void
|
||||
{
|
||||
$config = new HtmlContentFilterConfig(
|
||||
filterOutNonContentElements: false,
|
||||
useAllowListFilter: false,
|
||||
);
|
||||
$filter = new self($config);
|
||||
$filter->filterDocument($doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias using the old method name to avoid potential compatibility breaks during patch release.
|
||||
* To remove in future feature release.
|
||||
* @deprecated Use filterString instead.
|
||||
*/
|
||||
public static function removeScriptsFromHtmlString(string $html): string
|
||||
{
|
||||
$config = new HtmlContentFilterConfig(
|
||||
filterOutNonContentElements: false,
|
||||
useAllowListFilter: false,
|
||||
);
|
||||
$filter = new self($config);
|
||||
return $filter->filterString($html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Util;
|
||||
|
||||
readonly class HtmlContentFilterConfig
|
||||
{
|
||||
public function __construct(
|
||||
public bool $filterOutJavaScript = true,
|
||||
public bool $filterOutBadHtmlElements = true,
|
||||
public bool $filterOutFormElements = true,
|
||||
public bool $filterOutNonContentElements = true,
|
||||
public bool $useAllowListFilter = true,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an instance from a config string, where the string
|
||||
* is a combination of characters to enable filters.
|
||||
*/
|
||||
public static function fromConfigString(string $config): self
|
||||
{
|
||||
$config = strtolower($config);
|
||||
return new self(
|
||||
filterOutJavaScript: str_contains($config, 'j'),
|
||||
filterOutBadHtmlElements: str_contains($config, 'h'),
|
||||
filterOutFormElements: str_contains($config, 'f'),
|
||||
filterOutNonContentElements: str_contains($config, 'h'),
|
||||
useAllowListFilter: str_contains($config, 'a'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class HtmlDescriptionFilter
|
||||
*/
|
||||
protected static array $allowedAttrsByElements = [
|
||||
'p' => [],
|
||||
'a' => ['href', 'title', 'target', 'data-mention-user-id'],
|
||||
'a' => ['href', 'title', 'target'],
|
||||
'ol' => [],
|
||||
'ul' => [],
|
||||
'li' => [],
|
||||
|
||||
@@ -103,13 +103,7 @@ class HtmlDocument
|
||||
*/
|
||||
public function getBody(): DOMNode
|
||||
{
|
||||
$bodies = $this->document->getElementsByTagName('body');
|
||||
|
||||
if ($bodies->length === 0) {
|
||||
return new DOMElement('body', '');
|
||||
}
|
||||
|
||||
return $bodies[0];
|
||||
return $this->document->getElementsByTagName('body')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,10 +8,6 @@ use BookStack\Exceptions\HttpFetchException;
|
||||
* Validate the host we're connecting to when making a server-side-request.
|
||||
* Will use the given hosts config if given during construction otherwise
|
||||
* will look to the app configured config.
|
||||
*
|
||||
* The config format is a space-seperated list of URL prefixes which should contain the
|
||||
* protocol and host. It can optionally define a path prefix as part of the URL.
|
||||
* Wildcards, via a '*', can be used within these elements to match anything but a '/'.
|
||||
*/
|
||||
class SsrUrlValidator
|
||||
{
|
||||
@@ -52,34 +48,15 @@ class SsrUrlValidator
|
||||
{
|
||||
$pattern = rtrim(trim($pattern), '/');
|
||||
$url = trim($url);
|
||||
$urlParts = parse_url($url);
|
||||
|
||||
if (empty($pattern) || empty($url) || $urlParts === false) {
|
||||
if (empty($pattern) || empty($url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent potential tricks using percent encoded slashes
|
||||
if (str_contains(strtolower($urlParts['host'] ?? ''), '%2f')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Disregard query and fragment
|
||||
$url = explode('?', $url, 2)[0];
|
||||
$url = explode('#', $url, 2)[0];
|
||||
|
||||
// Disregard userinfo if existing
|
||||
if (!empty($urlParts['user']) || !empty($urlParts['pass'])) {
|
||||
[$start, $postUserinfo] = explode('@', $url, 2);
|
||||
$preUserinfo = explode('//', $start, 2)[0];
|
||||
$url = ($preUserinfo ? $preUserinfo . '//' : '') . $postUserinfo;
|
||||
}
|
||||
|
||||
// Prepare pattern
|
||||
$quoted = preg_quote($pattern, '/');
|
||||
$regexPattern = str_replace('\*', '[^\/]*', $quoted);
|
||||
$regexPattern = str_replace('\*', '.*', $quoted);
|
||||
|
||||
// Check against our URL
|
||||
return preg_match('/^' . $regexPattern . '($|\/.*$)/i', $url);
|
||||
return preg_match('/^' . $regexPattern . '($|\/.*$|#.*$)/i', $url);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace BookStack\Util;
|
||||
|
||||
use BookStack\Facades\Theme;
|
||||
|
||||
class SvgIcon
|
||||
{
|
||||
public function __construct(
|
||||
@@ -25,9 +23,12 @@ class SvgIcon
|
||||
$attrString .= $attrName . '="' . $attr . '" ';
|
||||
}
|
||||
|
||||
$defaultIconPath = resource_path('icons/' . $this->name . '.svg');
|
||||
$iconPath = Theme::findFirstFile("icons/{$this->name}.svg") ?? $defaultIconPath;
|
||||
if (!file_exists($iconPath)) {
|
||||
$iconPath = resource_path('icons/' . $this->name . '.svg');
|
||||
$themeIconPath = theme_path('icons/' . $this->name . '.svg');
|
||||
|
||||
if ($themeIconPath && file_exists($themeIconPath)) {
|
||||
$iconPath = $themeIconPath;
|
||||
} elseif (!file_exists($iconPath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
@@ -19,7 +19,6 @@
|
||||
"ext-zip": "*",
|
||||
"bacon/bacon-qr-code": "^3.0",
|
||||
"dompdf/dompdf": "^3.1",
|
||||
"ezyang/htmlpurifier": "^4.19",
|
||||
"guzzlehttp/guzzle": "^7.4",
|
||||
"intervention/image": "^3.5",
|
||||
"knplabs/knp-snappy": "^1.5",
|
||||
@@ -30,17 +29,16 @@
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"league/html-to-markdown": "^5.0.0",
|
||||
"league/oauth2-client": "^2.6",
|
||||
"onelogin/php-saml": "^4.3.1",
|
||||
"onelogin/php-saml": "^4.0",
|
||||
"phpseclib/phpseclib": "^3.0",
|
||||
"pragmarx/google2fa": "^9.0",
|
||||
"pragmarx/google2fa": "^8.0",
|
||||
"predis/predis": "^3.2",
|
||||
"socialiteproviders/discord": "^4.1",
|
||||
"socialiteproviders/gitlab": "^4.1",
|
||||
"socialiteproviders/microsoft-azure": "^5.1",
|
||||
"socialiteproviders/okta": "^4.2",
|
||||
"socialiteproviders/twitch": "^5.3",
|
||||
"ssddanbrown/htmldiff": "^2.0.0",
|
||||
"xemlock/htmlpurifier-html5": "^0.1.12"
|
||||
"ssddanbrown/htmldiff": "^2.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.21",
|
||||
@@ -49,7 +47,7 @@
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"larastan/larastan": "^v3.0",
|
||||
"phpunit/phpunit": "^11.5",
|
||||
"squizlabs/php_codesniffer": "^4.0.1",
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"ssddanbrown/asserthtml": "^3.1"
|
||||
},
|
||||
"autoload": {
|
||||
|
||||
1761
composer.lock
generated
1761
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
||||
project_id: "377219"
|
||||
project_identifier: bookstack
|
||||
base_path: .
|
||||
preserve_hierarchy: false
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\BookStack\Entities\Models\SlugHistory>
|
||||
*/
|
||||
class SlugHistoryFactory extends Factory
|
||||
{
|
||||
protected $model = \BookStack\Entities\Models\SlugHistory::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'sluggable_id' => Book::factory(),
|
||||
'sluggable_type' => 'book',
|
||||
'slug' => $this->faker->slug(),
|
||||
'parent_slug' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Create the table for storing slug history
|
||||
Schema::create('slug_history', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->string('sluggable_type', 10)->index();
|
||||
$table->unsignedBigInteger('sluggable_id')->index();
|
||||
$table->string('slug')->index();
|
||||
$table->string('parent_slug')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// Migrate in slugs from page revisions
|
||||
$revisionSlugQuery = DB::table('page_revisions')
|
||||
->select([
|
||||
DB::raw('\'page\' as sluggable_type'),
|
||||
'page_id as sluggable_id',
|
||||
'slug',
|
||||
'book_slug as parent_slug',
|
||||
DB::raw('min(created_at) as created_at'),
|
||||
DB::raw('min(updated_at) as updated_at'),
|
||||
])
|
||||
->where('type', '=', 'version')
|
||||
->groupBy(['sluggable_id', 'slug', 'parent_slug']);
|
||||
|
||||
DB::table('slug_history')->insertUsing(
|
||||
['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'],
|
||||
$revisionSlugQuery,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('slug_history');
|
||||
}
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('mention_history', function (Blueprint $table) {
|
||||
$table->increments('id');
|
||||
$table->string('mentionable_type', 50)->index();
|
||||
$table->unsignedBigInteger('mentionable_id')->index();
|
||||
$table->unsignedInteger('from_user_id');
|
||||
$table->unsignedInteger('to_user_id');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('mention_history');
|
||||
}
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('views', function (Blueprint $table) {
|
||||
$table->index('viewable_type', 'views_viewable_type_index');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('views', function (Blueprint $table) {
|
||||
$table->dropIndex('views_viewable_type_index');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -79,17 +79,5 @@
|
||||
"path": "/uploads/images/cover_book/2020-01/sjovall_m117hUWMu40.jpg",
|
||||
"type": "cover_book",
|
||||
"uploaded_to": 16
|
||||
},
|
||||
"shelves": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Great reads",
|
||||
"slug": "great-reads"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"name": "Personal Books",
|
||||
"slug": "personal-books"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user