mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-24 19:07:20 +03:00
Compare commits
434 Commits
developmen
...
v25.11.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -26,13 +26,6 @@ DB_DATABASE=database_database
|
|||||||
DB_USERNAME=database_username
|
DB_USERNAME=database_username
|
||||||
DB_PASSWORD=database_user_password
|
DB_PASSWORD=database_user_password
|
||||||
|
|
||||||
# Storage system to use
|
|
||||||
# By default files are stored on the local filesystem, with images being placed in
|
|
||||||
# public web space so they can be efficiently served directly by the web-server.
|
|
||||||
# For other options with different security levels & considerations, refer to:
|
|
||||||
# https://www.bookstackapp.com/docs/admin/upload-config/
|
|
||||||
STORAGE_TYPE=local
|
|
||||||
|
|
||||||
# Mail system to use
|
# Mail system to use
|
||||||
# Can be 'smtp' or 'sendmail'
|
# Can be 'smtp' or 'sendmail'
|
||||||
MAIL_DRIVER=smtp
|
MAIL_DRIVER=smtp
|
||||||
|
|||||||
@@ -351,25 +351,10 @@ EXPORT_PDF_COMMAND_TIMEOUT=15
|
|||||||
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
|
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
|
||||||
WKHTMLTOPDF=false
|
WKHTMLTOPDF=false
|
||||||
|
|
||||||
# Allow JavaScript, and other potentiall dangerous content in page content.
|
# Allow <script> tags in page content
|
||||||
# This also removes CSP-level JavaScript control.
|
|
||||||
# Note, if set to 'true' the page editor may still escape scripts.
|
# Note, if set to 'true' the page editor may still escape scripts.
|
||||||
# DEPRECATED: Use 'APP_CONTENT_FILTERING' instead as detailed below. Activiting this option
|
|
||||||
# effectively sets APP_CONTENT_FILTERING='' (No filtering)
|
|
||||||
ALLOW_CONTENT_SCRIPTS=false
|
ALLOW_CONTENT_SCRIPTS=false
|
||||||
|
|
||||||
# Control the behaviour of content filtering, primarily used for page content.
|
|
||||||
# This setting is a string of characters which represent different available filters:
|
|
||||||
# - j - Filter out JavaScript and unknown binary data based content
|
|
||||||
# - h - Filter out unexpected, and potentially dangerous, HTML elements
|
|
||||||
# - f - Filter out unexpected form elements
|
|
||||||
# - a - Run content through a more complex allowlist filter
|
|
||||||
# This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
|
|
||||||
# Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
|
|
||||||
# Note: The default value will always be the most-strict, so it's advised to leave this unset in your own configuration
|
|
||||||
# to ensure you are always using the full range of filters.
|
|
||||||
APP_CONTENT_FILTERING="jfha"
|
|
||||||
|
|
||||||
# Indicate if robots/crawlers should crawl your instance.
|
# Indicate if robots/crawlers should crawl your instance.
|
||||||
# Can be 'true', 'false' or 'null'.
|
# Can be 'true', 'false' or 'null'.
|
||||||
# The behaviour of the default 'null' option will depend on the 'app-public' admin setting.
|
# The behaviour of the default 'null' option will depend on the 'app-public' admin setting.
|
||||||
|
|||||||
19
.github/translators.txt
vendored
19
.github/translators.txt
vendored
@@ -511,22 +511,3 @@ MrCharlesIII :: Arabic
|
|||||||
David Olsen (dawin) :: Danish
|
David Olsen (dawin) :: Danish
|
||||||
ltnzr :: French
|
ltnzr :: French
|
||||||
Frank Holler (holler.frank) :: German; German Informal
|
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
|
|
||||||
|
|||||||
2
.github/workflows/test-migrations.yml
vendored
2
.github/workflows/test-migrations.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php: ['8.2', '8.3', '8.4', '8.5']
|
php: ['8.2', '8.3', '8.4']
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/test-php.yml
vendored
2
.github/workflows/test-php.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
php: ['8.2', '8.3', '8.4', '8.5']
|
php: ['8.2', '8.3', '8.4']
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -8,10 +8,10 @@ Homestead.yaml
|
|||||||
.idea
|
.idea
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
/public/dist
|
/public/dist/*.map
|
||||||
/public/plugins
|
/public/plugins
|
||||||
/public/css
|
/public/css/*.map
|
||||||
/public/js
|
/public/js/*.map
|
||||||
/public/bower
|
/public/bower
|
||||||
/public/build/
|
/public/build/
|
||||||
/public/favicon.ico
|
/public/favicon.ico
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
The MIT License (MIT)
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ use Illuminate\Http\Request;
|
|||||||
|
|
||||||
class OidcController extends Controller
|
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');
|
$this->middleware('guard:oidc');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ class OidcController extends Controller
|
|||||||
return redirect('/login');
|
return redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
session()->put('oidc_state', time() . ':' . $loginDetails['state']);
|
session()->flash('oidc_state', $loginDetails['state']);
|
||||||
|
|
||||||
return redirect($loginDetails['url']);
|
return redirect($loginDetails['url']);
|
||||||
}
|
}
|
||||||
@@ -39,16 +41,10 @@ class OidcController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function callback(Request $request)
|
public function callback(Request $request)
|
||||||
{
|
{
|
||||||
|
$storedState = session()->pull('oidc_state');
|
||||||
$responseState = $request->query('state');
|
$responseState = $request->query('state');
|
||||||
$splitState = explode(':', session()->pull('oidc_state', ':'), 2);
|
|
||||||
if (count($splitState) !== 2) {
|
|
||||||
$splitState = [null, null];
|
|
||||||
}
|
|
||||||
|
|
||||||
[$storedStateTime, $storedState] = $splitState;
|
if ($storedState !== $responseState) {
|
||||||
$threeMinutesAgo = time() - 3 * 60;
|
|
||||||
|
|
||||||
if (!$storedState || $storedState !== $responseState || intval($storedStateTime) < $threeMinutesAgo) {
|
|
||||||
$this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
|
$this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
|
||||||
|
|
||||||
return redirect('/login');
|
return redirect('/login');
|
||||||
@@ -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()
|
public function logout()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ use PragmaRX\Google2FA\Support\Constants;
|
|||||||
|
|
||||||
class TotpService
|
class TotpService
|
||||||
{
|
{
|
||||||
public function __construct(
|
protected $google2fa;
|
||||||
protected Google2FA $google2fa
|
|
||||||
) {
|
public function __construct(Google2FA $google2fa)
|
||||||
|
{
|
||||||
$this->google2fa = $google2fa;
|
$this->google2fa = $google2fa;
|
||||||
// Use SHA1 as a default, Personal testing of other options in 2021 found
|
// Use SHA1 as a default, Personal testing of other options in 2021 found
|
||||||
// many apps lack support for other algorithms yet still will scan
|
// many apps lack support for other algorithms yet still will scan
|
||||||
@@ -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
|
public function generateUrl(string $secret, User $user): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use BookStack\Permissions\PermissionApplicator;
|
|||||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||||
use BookStack\Users\Models\OwnableInterface;
|
use BookStack\Users\Models\OwnableInterface;
|
||||||
use BookStack\Util\HtmlContentFilter;
|
use BookStack\Util\HtmlContentFilter;
|
||||||
use BookStack\Util\HtmlContentFilterConfig;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
@@ -42,19 +41,7 @@ class Comment extends Model implements Loggable, OwnableInterface
|
|||||||
*/
|
*/
|
||||||
public function entity(): MorphTo
|
public function entity(): MorphTo
|
||||||
{
|
{
|
||||||
// We specifically define null here to avoid the different name (commentable)
|
return $this->morphTo('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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,8 +70,7 @@ class Comment extends Model implements Loggable, OwnableInterface
|
|||||||
|
|
||||||
public function safeHtml(): string
|
public function safeHtml(): string
|
||||||
{
|
{
|
||||||
$filter = new HtmlContentFilter(new HtmlContentFilterConfig());
|
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
|
||||||
return $filter->filterString($this->html ?? '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function jointPermissions(): HasMany
|
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();
|
$users = User::query()->whereIn('id', array_unique($userIds))->get();
|
||||||
|
|
||||||
/** @var User $user */
|
|
||||||
foreach ($users as $user) {
|
foreach ($users as $user) {
|
||||||
// Prevent sending to the user that initiated the activity
|
// Prevent sending to the user that initiated the activity
|
||||||
if ($user->id === $initiator->id) {
|
if ($user->id === $initiator->id) {
|
||||||
|
|||||||
@@ -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\Activity;
|
||||||
use BookStack\Activity\Models\Loggable;
|
use BookStack\Activity\Models\Loggable;
|
||||||
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
|
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
|
||||||
use BookStack\Activity\Notifications\Handlers\CommentMentionNotificationHandler;
|
|
||||||
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
|
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
|
||||||
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
|
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
|
||||||
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
|
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
|
||||||
@@ -49,7 +48,5 @@ class NotificationManager
|
|||||||
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
|
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
|
||||||
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
|
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
|
||||||
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
|
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
|
||||||
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class);
|
|
||||||
$this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') {
|
if ($homepageOption === 'bookshelves') {
|
||||||
$shelves = $this->queries->shelves->visibleForListWithCover()
|
$shelves = $this->queries->shelves->visibleForListWithCover()
|
||||||
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
->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]);
|
$data = array_merge($commonData, ['shelves' => $shelves]);
|
||||||
|
|
||||||
return view('home.shelves', $data);
|
return view('home.shelves', $data);
|
||||||
@@ -92,7 +92,7 @@ class HomeController extends Controller
|
|||||||
if ($homepageOption === 'books') {
|
if ($homepageOption === 'books') {
|
||||||
$books = $this->queries->books->visibleForListWithCover()
|
$books = $this->queries->books->visibleForListWithCover()
|
||||||
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
->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]);
|
$data = array_merge($commonData, ['books' => $books]);
|
||||||
|
|
||||||
return view('home.books', $data);
|
return view('home.books', $data);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace BookStack\App\Providers;
|
namespace BookStack\App\Providers;
|
||||||
|
|
||||||
use BookStack\Access\SocialDriverManager;
|
use BookStack\Access\SocialDriverManager;
|
||||||
use BookStack\Activity\Models\Comment;
|
|
||||||
use BookStack\Activity\Tools\ActivityLogger;
|
use BookStack\Activity\Tools\ActivityLogger;
|
||||||
use BookStack\Entities\Models\Book;
|
use BookStack\Entities\Models\Book;
|
||||||
use BookStack\Entities\Models\Bookshelf;
|
use BookStack\Entities\Models\Bookshelf;
|
||||||
@@ -74,7 +73,6 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
'book' => Book::class,
|
'book' => Book::class,
|
||||||
'chapter' => Chapter::class,
|
'chapter' => Chapter::class,
|
||||||
'page' => Page::class,
|
'page' => Page::class,
|
||||||
'comment' => Comment::class,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ namespace BookStack\App\Providers;
|
|||||||
|
|
||||||
use BookStack\Theming\ThemeEvents;
|
use BookStack\Theming\ThemeEvents;
|
||||||
use BookStack\Theming\ThemeService;
|
use BookStack\Theming\ThemeService;
|
||||||
use BookStack\Theming\ThemeViews;
|
|
||||||
use Illuminate\Support\Facades\Blade;
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class ThemeServiceProvider extends ServiceProvider
|
class ThemeServiceProvider extends ServiceProvider
|
||||||
@@ -26,26 +24,7 @@ class ThemeServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
// Boot up the theme system
|
// Boot up the theme system
|
||||||
$themeService = $this->app->make(ThemeService::class);
|
$themeService = $this->app->make(ThemeService::class);
|
||||||
$viewFactory = $this->app->make('view');
|
|
||||||
$themeViews = new ThemeViews($viewFactory->getFinder());
|
|
||||||
|
|
||||||
// Use a custom include so that we can insert theme views before/after includes.
|
|
||||||
// This is done, even if no theme is active, so that view caching does not create problems
|
|
||||||
// when switching between themes or when switching a theme on/off.
|
|
||||||
$viewFactory->share('__themeViews', $themeViews);
|
|
||||||
Blade::directive('include', function ($expression) {
|
|
||||||
return "<?php echo \$__themeViews->handleViewInclude({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>";
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!$themeService->getTheme()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$themeService->loadModules();
|
|
||||||
$themeService->readThemeActions();
|
$themeService->readThemeActions();
|
||||||
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
|
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
|
||||||
|
|
||||||
$themeViews->registerViewPathsForTheme($themeService->getModules());
|
|
||||||
$themeService->dispatch(ThemeEvents::THEME_REGISTER_VIEWS, $themeViews);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ namespace BookStack\App;
|
|||||||
/**
|
/**
|
||||||
* Assigned to models that can have slugs.
|
* Assigned to models that can have slugs.
|
||||||
* Must have the below properties.
|
* Must have the below properties.
|
||||||
*
|
|
||||||
* @property string $slug
|
|
||||||
*/
|
*/
|
||||||
interface SluggableInterface
|
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.
|
* 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
|
function theme_path(string $path = ''): ?string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,15 +37,10 @@ return [
|
|||||||
// The limit for all uploaded files, including images and attachments in MB.
|
// The limit for all uploaded files, including images and attachments in MB.
|
||||||
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
|
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
|
||||||
|
|
||||||
// Control the behaviour of content filtering, primarily used for page content.
|
// Allow <script> tags to entered within page content.
|
||||||
// This setting is a string of characters which represent different available filters:
|
// <script> tags are escaped by default.
|
||||||
// - j - Filter out JavaScript and unknown binary data based content
|
// Even when overridden the WYSIWYG editor may still escape script content.
|
||||||
// - h - Filter out unexpected, and potentially dangerous, HTML elements
|
'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false),
|
||||||
// - f - Filter out unexpected form elements
|
|
||||||
// - a - Run content through a more complex allowlist filter
|
|
||||||
// This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
|
|
||||||
// Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
|
|
||||||
'content_filtering' => env('APP_CONTENT_FILTERING', env('ALLOW_CONTENT_SCRIPTS', false) === true ? '' : 'jhfa'),
|
|
||||||
|
|
||||||
// Allow server-side fetches to be performed to potentially unknown
|
// Allow server-side fetches to be performed to potentially unknown
|
||||||
// and user-provided locations. Primarily used in exports when loading
|
// and user-provided locations. Primarily used in exports when loading
|
||||||
@@ -53,8 +48,8 @@ return [
|
|||||||
'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
|
'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
|
||||||
|
|
||||||
// Override the default behaviour for allowing crawlers to crawl the instance.
|
// Override the default behaviour for allowing crawlers to crawl the instance.
|
||||||
// May be ignored if the underlying view has been overridden or modified.
|
// May be ignored if view has be overridden or modified.
|
||||||
// Defaults to null in which case the 'app-public' status is used instead.
|
// Defaults to null since, if not set, 'app-public' status used instead.
|
||||||
'allow_robots' => env('ALLOW_ROBOTS', null),
|
'allow_robots' => env('ALLOW_ROBOTS', null),
|
||||||
|
|
||||||
// Application Base URL, Used by laravel in development commands
|
// Application Base URL, Used by laravel in development commands
|
||||||
|
|||||||
@@ -81,8 +81,7 @@ return [
|
|||||||
'strict' => false,
|
'strict' => false,
|
||||||
'engine' => null,
|
'engine' => null,
|
||||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
// @phpstan-ignore class.notFound
|
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||||
(PHP_VERSION_ID >= 80500 ? \Pdo\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'),
|
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
|
||||||
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
|
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
|
||||||
'books_view_type' => env('APP_VIEWS_BOOKS', '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.
|
* 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 [
|
return [
|
||||||
|
|
||||||
// App theme
|
// App theme
|
||||||
@@ -20,7 +26,7 @@ return [
|
|||||||
// Most templating systems load templates from disk. Here you may specify
|
// Most templating systems load templates from disk. Here you may specify
|
||||||
// an array of paths that should be checked for your views. Of course
|
// an array of paths that should be checked for your views. Of course
|
||||||
// the usual Laravel view path has already been registered for you.
|
// the usual Laravel view path has already been registered for you.
|
||||||
'paths' => [realpath(base_path('resources/views'))],
|
'paths' => $viewPaths,
|
||||||
|
|
||||||
// Compiled View Path
|
// Compiled View Path
|
||||||
// This option determines where all the compiled Blade templates will be
|
// This option determines where all the compiled Blade templates will be
|
||||||
|
|||||||
@@ -1,305 +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("This will download a module from {$host}. Modules 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 file and get full location
|
|
||||||
$zipPath = realpath($location);
|
|
||||||
if (!$zipPath || !is_file($zipPath)) {
|
|
||||||
$this->error("ERROR: Module file not found at {$location}");
|
|
||||||
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\Chapter;
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Entities\Queries\BookQueries;
|
use BookStack\Entities\Queries\BookQueries;
|
||||||
use BookStack\Entities\Queries\BookshelfQueries;
|
|
||||||
use BookStack\Entities\Queries\PageQueries;
|
use BookStack\Entities\Queries\PageQueries;
|
||||||
use BookStack\Entities\Repos\BookRepo;
|
use BookStack\Entities\Repos\BookRepo;
|
||||||
use BookStack\Entities\Tools\BookContents;
|
use BookStack\Entities\Tools\BookContents;
|
||||||
use BookStack\Http\ApiController;
|
use BookStack\Http\ApiController;
|
||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
@@ -24,7 +21,6 @@ class BookApiController extends ApiController
|
|||||||
protected BookRepo $bookRepo,
|
protected BookRepo $bookRepo,
|
||||||
protected BookQueries $queries,
|
protected BookQueries $queries,
|
||||||
protected PageQueries $pageQueries,
|
protected PageQueries $pageQueries,
|
||||||
protected BookshelfQueries $shelfQueries,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,20 +60,13 @@ class BookApiController extends ApiController
|
|||||||
* View the details of a single book.
|
* View the details of a single book.
|
||||||
* The response data will contain a 'content' property listing the chapter and pages directly within, in
|
* 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
|
* 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)
|
public function read(string $id)
|
||||||
{
|
{
|
||||||
$book = $this->queries->findVisibleByIdOrFail(intval($id));
|
$book = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||||
$book = $this->forJsonDisplay($book);
|
$book = $this->forJsonDisplay($book);
|
||||||
$book->load([
|
$book->load(['createdBy', 'updatedBy', 'ownedBy']);
|
||||||
'createdBy',
|
|
||||||
'updatedBy',
|
|
||||||
'ownedBy',
|
|
||||||
'shelves' => function (BelongsToMany $query) {
|
|
||||||
$query->select(['id', 'name', 'slug'])->scopes('visible');
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
$contents = (new BookContents($book))->getTree(true, false)->all();
|
$contents = (new BookContents($book))->getTree(true, false)->all();
|
||||||
$contentsApiData = (new ApiEntityListFormatter($contents))
|
$contentsApiData = (new ApiEntityListFormatter($contents))
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use BookStack\Activity\Models\View;
|
|||||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||||
use BookStack\Entities\Queries\BookQueries;
|
use BookStack\Entities\Queries\BookQueries;
|
||||||
use BookStack\Entities\Queries\BookshelfQueries;
|
use BookStack\Entities\Queries\BookshelfQueries;
|
||||||
use BookStack\Entities\Queries\EntityQueries;
|
|
||||||
use BookStack\Entities\Repos\BookRepo;
|
use BookStack\Entities\Repos\BookRepo;
|
||||||
use BookStack\Entities\Tools\BookContents;
|
use BookStack\Entities\Tools\BookContents;
|
||||||
use BookStack\Entities\Tools\Cloner;
|
use BookStack\Entities\Tools\Cloner;
|
||||||
@@ -32,7 +31,6 @@ class BookController extends Controller
|
|||||||
protected ShelfContext $shelfContext,
|
protected ShelfContext $shelfContext,
|
||||||
protected BookRepo $bookRepo,
|
protected BookRepo $bookRepo,
|
||||||
protected BookQueries $queries,
|
protected BookQueries $queries,
|
||||||
protected EntityQueries $entityQueries,
|
|
||||||
protected BookshelfQueries $shelfQueries,
|
protected BookshelfQueries $shelfQueries,
|
||||||
protected ReferenceFetcher $referenceFetcher,
|
protected ReferenceFetcher $referenceFetcher,
|
||||||
) {
|
) {
|
||||||
@@ -52,7 +50,7 @@ class BookController extends Controller
|
|||||||
|
|
||||||
$books = $this->queries->visibleForListWithCover()
|
$books = $this->queries->visibleForListWithCover()
|
||||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
->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;
|
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
|
||||||
$popular = $this->queries->popularForList()->take(4)->get();
|
$popular = $this->queries->popularForList()->take(4)->get();
|
||||||
$new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->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)
|
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||||
{
|
{
|
||||||
try {
|
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
|
||||||
} catch (NotFoundException $exception) {
|
|
||||||
$book = $this->entityQueries->findVisibleByOldSlugs('book', $slug);
|
|
||||||
if (is_null($book)) {
|
|
||||||
throw $exception;
|
|
||||||
}
|
|
||||||
return redirect($book->getUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
$bookChildren = (new BookContents($book))->getTree(true);
|
$bookChildren = (new BookContents($book))->getTree(true);
|
||||||
$bookParentShelves = $book->shelves()->scopes('visible')->get();
|
$bookParentShelves = $book->shelves()->scopes('visible')->get();
|
||||||
|
|
||||||
@@ -224,14 +213,9 @@ class BookController extends Controller
|
|||||||
{
|
{
|
||||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||||
$this->checkOwnablePermission(Permission::BookDelete, $book);
|
$this->checkOwnablePermission(Permission::BookDelete, $book);
|
||||||
$contextShelf = $this->shelfContext->getContextualShelfForBook($book);
|
|
||||||
|
|
||||||
$this->bookRepo->destroy($book);
|
$this->bookRepo->destroy($book);
|
||||||
|
|
||||||
if ($contextShelf) {
|
|
||||||
return redirect($contextShelf->getUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect('/books');
|
return redirect('/books');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use BookStack\Activity\ActivityQueries;
|
|||||||
use BookStack\Activity\Models\View;
|
use BookStack\Activity\Models\View;
|
||||||
use BookStack\Entities\Queries\BookQueries;
|
use BookStack\Entities\Queries\BookQueries;
|
||||||
use BookStack\Entities\Queries\BookshelfQueries;
|
use BookStack\Entities\Queries\BookshelfQueries;
|
||||||
use BookStack\Entities\Queries\EntityQueries;
|
|
||||||
use BookStack\Entities\Repos\BookshelfRepo;
|
use BookStack\Entities\Repos\BookshelfRepo;
|
||||||
use BookStack\Entities\Tools\ShelfContext;
|
use BookStack\Entities\Tools\ShelfContext;
|
||||||
use BookStack\Exceptions\ImageUploadException;
|
use BookStack\Exceptions\ImageUploadException;
|
||||||
@@ -24,7 +23,6 @@ class BookshelfController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
protected BookshelfRepo $shelfRepo,
|
protected BookshelfRepo $shelfRepo,
|
||||||
protected BookshelfQueries $queries,
|
protected BookshelfQueries $queries,
|
||||||
protected EntityQueries $entityQueries,
|
|
||||||
protected BookQueries $bookQueries,
|
protected BookQueries $bookQueries,
|
||||||
protected ShelfContext $shelfContext,
|
protected ShelfContext $shelfContext,
|
||||||
protected ReferenceFetcher $referenceFetcher,
|
protected ReferenceFetcher $referenceFetcher,
|
||||||
@@ -45,7 +43,7 @@ class BookshelfController extends Controller
|
|||||||
|
|
||||||
$shelves = $this->queries->visibleForListWithCover()
|
$shelves = $this->queries->visibleForListWithCover()
|
||||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
->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;
|
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
|
||||||
$popular = $this->queries->popularForList()->get();
|
$popular = $this->queries->popularForList()->get();
|
||||||
$new = $this->queries->visibleForList()
|
$new = $this->queries->visibleForList()
|
||||||
@@ -107,16 +105,7 @@ class BookshelfController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||||
{
|
{
|
||||||
try {
|
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
|
||||||
} catch (NotFoundException $exception) {
|
|
||||||
$shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug);
|
|
||||||
if (is_null($shelf)) {
|
|
||||||
throw $exception;
|
|
||||||
}
|
|
||||||
return redirect($shelf->getUrl());
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->checkOwnablePermission(Permission::BookshelfView, $shelf);
|
$this->checkOwnablePermission(Permission::BookshelfView, $shelf);
|
||||||
|
|
||||||
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
|
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
|
||||||
|
|||||||
@@ -77,15 +77,7 @@ class ChapterController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function show(string $bookSlug, string $chapterSlug)
|
public function show(string $bookSlug, string $chapterSlug)
|
||||||
{
|
{
|
||||||
try {
|
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||||
$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());
|
|
||||||
}
|
|
||||||
|
|
||||||
$sidebarTree = (new BookContents($chapter->book))->getTree();
|
$sidebarTree = (new BookContents($chapter->book))->getTree();
|
||||||
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
|
$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\PageEditActivity;
|
||||||
use BookStack\Entities\Tools\PageEditorData;
|
use BookStack\Entities\Tools\PageEditorData;
|
||||||
use BookStack\Exceptions\NotFoundException;
|
use BookStack\Exceptions\NotFoundException;
|
||||||
|
use BookStack\Exceptions\NotifyException;
|
||||||
use BookStack\Exceptions\PermissionsException;
|
use BookStack\Exceptions\PermissionsException;
|
||||||
use BookStack\Http\Controller;
|
use BookStack\Http\Controller;
|
||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
use BookStack\References\ReferenceFetcher;
|
use BookStack\References\ReferenceFetcher;
|
||||||
use BookStack\Util\HtmlContentFilter;
|
|
||||||
use BookStack\Util\HtmlContentFilterConfig;
|
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -141,7 +140,9 @@ class PageController extends Controller
|
|||||||
try {
|
try {
|
||||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||||
} catch (NotFoundException $e) {
|
} 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)) {
|
if (is_null($page)) {
|
||||||
throw $e;
|
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
|
* @throws NotFoundException
|
||||||
*/
|
*/
|
||||||
@@ -185,10 +186,6 @@ class PageController extends Controller
|
|||||||
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
|
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
|
||||||
$page->makeHidden(['book']);
|
$page->makeHidden(['book']);
|
||||||
|
|
||||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
|
||||||
$filter = new HtmlContentFilter($filterConfig);
|
|
||||||
$page->html = $filter->filterString($page->html);
|
|
||||||
|
|
||||||
return response()->json($page);
|
return response()->json($page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace BookStack\Entities\Models;
|
namespace BookStack\Entities\Models;
|
||||||
|
|
||||||
|
use BookStack\References\ReferenceUpdater;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,10 +17,34 @@ abstract class BookChild extends Entity
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Get the book this page sits in.
|
* Get the book this page sits in.
|
||||||
* @return BelongsTo<Book, $this>
|
|
||||||
*/
|
*/
|
||||||
public function book(): BelongsTo
|
public function book(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Book::class)->withTrashed();
|
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;
|
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'];
|
protected $fillable = ['name'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use BookStack\Activity\Models\Viewable;
|
|||||||
use BookStack\Activity\Models\Watch;
|
use BookStack\Activity\Models\Watch;
|
||||||
use BookStack\App\Model;
|
use BookStack\App\Model;
|
||||||
use BookStack\App\SluggableInterface;
|
use BookStack\App\SluggableInterface;
|
||||||
|
use BookStack\Entities\Tools\SlugGenerator;
|
||||||
use BookStack\Permissions\JointPermissionBuilder;
|
use BookStack\Permissions\JointPermissionBuilder;
|
||||||
use BookStack\Permissions\Models\EntityPermission;
|
use BookStack\Permissions\Models\EntityPermission;
|
||||||
use BookStack\Permissions\Models\JointPermission;
|
use BookStack\Permissions\Models\JointPermission;
|
||||||
@@ -404,6 +405,16 @@ abstract class Entity extends Model implements
|
|||||||
app()->make(SearchIndex::class)->indexEntity(clone $this);
|
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}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
@@ -430,14 +441,6 @@ abstract class Entity extends Model implements
|
|||||||
return $this->morphMany(Watch::class, 'watchable');
|
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}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -124,14 +124,6 @@ class Page extends BookChild
|
|||||||
return url('/' . implode('/', $parts));
|
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.
|
* 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\Entity;
|
||||||
use BookStack\Entities\Models\EntityTable;
|
use BookStack\Entities\Models\EntityTable;
|
||||||
use BookStack\Entities\Tools\SlugHistory;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||||
use Illuminate\Database\Query\JoinClause;
|
use Illuminate\Database\Query\JoinClause;
|
||||||
@@ -19,7 +18,6 @@ class EntityQueries
|
|||||||
public ChapterQueries $chapters,
|
public ChapterQueries $chapters,
|
||||||
public PageQueries $pages,
|
public PageQueries $pages,
|
||||||
public PageRevisionQueries $revisions,
|
public PageRevisionQueries $revisions,
|
||||||
protected SlugHistory $slugHistory,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,30 +31,9 @@ class EntityQueries
|
|||||||
$explodedId = explode(':', $identifier);
|
$explodedId = explode(':', $identifier);
|
||||||
$entityType = $explodedId[0];
|
$entityType = $explodedId[0];
|
||||||
$entityId = intval($explodedId[1]);
|
$entityId = intval($explodedId[1]);
|
||||||
|
$queries = $this->getQueriesForType($entityType);
|
||||||
|
|
||||||
return $this->findVisibleById($entityType, $entityId);
|
return $queries->findVisibleById($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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ use BookStack\Entities\Models\HasCoverInterface;
|
|||||||
use BookStack\Entities\Models\HasDescriptionInterface;
|
use BookStack\Entities\Models\HasDescriptionInterface;
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Entities\Queries\PageQueries;
|
use BookStack\Entities\Queries\PageQueries;
|
||||||
use BookStack\Entities\Tools\SlugGenerator;
|
|
||||||
use BookStack\Entities\Tools\SlugHistory;
|
|
||||||
use BookStack\Exceptions\ImageUploadException;
|
use BookStack\Exceptions\ImageUploadException;
|
||||||
use BookStack\References\ReferenceStore;
|
use BookStack\References\ReferenceStore;
|
||||||
use BookStack\References\ReferenceUpdater;
|
use BookStack\References\ReferenceUpdater;
|
||||||
@@ -27,8 +25,6 @@ class BaseRepo
|
|||||||
protected ReferenceStore $referenceStore,
|
protected ReferenceStore $referenceStore,
|
||||||
protected PageQueries $pageQueries,
|
protected PageQueries $pageQueries,
|
||||||
protected BookSorter $bookSorter,
|
protected BookSorter $bookSorter,
|
||||||
protected SlugGenerator $slugGenerator,
|
|
||||||
protected SlugHistory $slugHistory,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +43,7 @@ class BaseRepo
|
|||||||
'updated_by' => user()->id,
|
'updated_by' => user()->id,
|
||||||
'owned_by' => user()->id,
|
'owned_by' => user()->id,
|
||||||
]);
|
]);
|
||||||
$this->refreshSlug($entity);
|
$entity->refreshSlug();
|
||||||
|
|
||||||
if ($entity instanceof HasDescriptionInterface) {
|
if ($entity instanceof HasDescriptionInterface) {
|
||||||
$this->updateDescription($entity, $input);
|
$this->updateDescription($entity, $input);
|
||||||
@@ -82,7 +78,7 @@ class BaseRepo
|
|||||||
$entity->updated_by = user()->id;
|
$entity->updated_by = user()->id;
|
||||||
|
|
||||||
if ($entity->isDirty('name') || empty($entity->slug)) {
|
if ($entity->isDirty('name') || empty($entity->slug)) {
|
||||||
$this->refreshSlug($entity);
|
$entity->refreshSlug();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($entity instanceof HasDescriptionInterface) {
|
if ($entity instanceof HasDescriptionInterface) {
|
||||||
@@ -159,13 +155,4 @@ class BaseRepo
|
|||||||
$entity->descriptionInfo()->set('', $input['description']);
|
$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\Models\Chapter;
|
||||||
use BookStack\Entities\Queries\EntityQueries;
|
use BookStack\Entities\Queries\EntityQueries;
|
||||||
use BookStack\Entities\Tools\BookContents;
|
use BookStack\Entities\Tools\BookContents;
|
||||||
use BookStack\Entities\Tools\ParentChanger;
|
|
||||||
use BookStack\Entities\Tools\TrashCan;
|
use BookStack\Entities\Tools\TrashCan;
|
||||||
use BookStack\Exceptions\MoveOperationException;
|
use BookStack\Exceptions\MoveOperationException;
|
||||||
use BookStack\Exceptions\PermissionsException;
|
use BookStack\Exceptions\PermissionsException;
|
||||||
@@ -22,7 +21,6 @@ class ChapterRepo
|
|||||||
protected BaseRepo $baseRepo,
|
protected BaseRepo $baseRepo,
|
||||||
protected EntityQueries $entityQueries,
|
protected EntityQueries $entityQueries,
|
||||||
protected TrashCan $trashCan,
|
protected TrashCan $trashCan,
|
||||||
protected ParentChanger $parentChanger,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +97,7 @@ class ChapterRepo
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (new DatabaseTransaction(function () use ($chapter, $parent) {
|
return (new DatabaseTransaction(function () use ($chapter, $parent) {
|
||||||
$this->parentChanger->changeBook($chapter, $parent->id);
|
$chapter = $chapter->changeBook($parent->id);
|
||||||
$chapter->rebuildPermissions();
|
$chapter->rebuildPermissions();
|
||||||
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
|
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ use BookStack\Entities\Queries\EntityQueries;
|
|||||||
use BookStack\Entities\Tools\BookContents;
|
use BookStack\Entities\Tools\BookContents;
|
||||||
use BookStack\Entities\Tools\PageContent;
|
use BookStack\Entities\Tools\PageContent;
|
||||||
use BookStack\Entities\Tools\PageEditorType;
|
use BookStack\Entities\Tools\PageEditorType;
|
||||||
use BookStack\Entities\Tools\ParentChanger;
|
|
||||||
use BookStack\Entities\Tools\TrashCan;
|
use BookStack\Entities\Tools\TrashCan;
|
||||||
use BookStack\Exceptions\MoveOperationException;
|
use BookStack\Exceptions\MoveOperationException;
|
||||||
use BookStack\Exceptions\PermissionsException;
|
use BookStack\Exceptions\PermissionsException;
|
||||||
@@ -32,7 +31,6 @@ class PageRepo
|
|||||||
protected ReferenceStore $referenceStore,
|
protected ReferenceStore $referenceStore,
|
||||||
protected ReferenceUpdater $referenceUpdater,
|
protected ReferenceUpdater $referenceUpdater,
|
||||||
protected TrashCan $trashCan,
|
protected TrashCan $trashCan,
|
||||||
protected ParentChanger $parentChanger,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +242,7 @@ class PageRepo
|
|||||||
}
|
}
|
||||||
|
|
||||||
$page->updated_by = user()->id;
|
$page->updated_by = user()->id;
|
||||||
$this->baseRepo->refreshSlug($page);
|
$page->refreshSlug();
|
||||||
$page->save();
|
$page->save();
|
||||||
$page->indexForSearch();
|
$page->indexForSearch();
|
||||||
$this->referenceStore->updateForEntity($page);
|
$this->referenceStore->updateForEntity($page);
|
||||||
@@ -286,7 +284,7 @@ class PageRepo
|
|||||||
return (new DatabaseTransaction(function () use ($page, $parent) {
|
return (new DatabaseTransaction(function () use ($page, $parent) {
|
||||||
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
|
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
|
||||||
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
|
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
|
||||||
$this->parentChanger->changeBook($page, $newBookId);
|
$page = $page->changeBook($newBookId);
|
||||||
$page->rebuildPermissions();
|
$page->rebuildPermissions();
|
||||||
|
|
||||||
Activity::add(ActivityType::PAGE_MOVE, $page);
|
Activity::add(ActivityType::PAGE_MOVE, $page);
|
||||||
|
|||||||
@@ -13,47 +13,30 @@ use BookStack\Entities\Repos\BookRepo;
|
|||||||
use BookStack\Entities\Repos\ChapterRepo;
|
use BookStack\Entities\Repos\ChapterRepo;
|
||||||
use BookStack\Entities\Repos\PageRepo;
|
use BookStack\Entities\Repos\PageRepo;
|
||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
use BookStack\References\ReferenceChangeContext;
|
|
||||||
use BookStack\References\ReferenceUpdater;
|
|
||||||
use BookStack\Uploads\Image;
|
use BookStack\Uploads\Image;
|
||||||
use BookStack\Uploads\ImageService;
|
use BookStack\Uploads\ImageService;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
class Cloner
|
class Cloner
|
||||||
{
|
{
|
||||||
protected ReferenceChangeContext $referenceChangeContext;
|
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected PageRepo $pageRepo,
|
protected PageRepo $pageRepo,
|
||||||
protected ChapterRepo $chapterRepo,
|
protected ChapterRepo $chapterRepo,
|
||||||
protected BookRepo $bookRepo,
|
protected BookRepo $bookRepo,
|
||||||
protected ImageService $imageService,
|
protected ImageService $imageService,
|
||||||
protected ReferenceUpdater $referenceUpdater,
|
|
||||||
) {
|
) {
|
||||||
$this->referenceChangeContext = new ReferenceChangeContext();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clone the given page into the given parent using the provided name.
|
* Clone the given page into the given parent using the provided name.
|
||||||
*/
|
*/
|
||||||
public function clonePage(Page $original, Entity $parent, string $newName): Page
|
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);
|
$copyPage = $this->pageRepo->getNewDraftPage($parent);
|
||||||
$pageData = $this->entityToInputData($original);
|
$pageData = $this->entityToInputData($original);
|
||||||
$pageData['name'] = $newName;
|
$pageData['name'] = $newName;
|
||||||
|
|
||||||
$newPage = $this->pageRepo->publishDraft($copyPage, $pageData);
|
return $this->pageRepo->publishDraft($copyPage, $pageData);
|
||||||
$this->referenceChangeContext->add($original, $newPage);
|
|
||||||
|
|
||||||
return $newPage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,14 +44,6 @@ class Cloner
|
|||||||
* Clones all child pages.
|
* Clones all child pages.
|
||||||
*/
|
*/
|
||||||
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
|
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 = $this->entityToInputData($original);
|
||||||
$chapterDetails['name'] = $newName;
|
$chapterDetails['name'] = $newName;
|
||||||
@@ -78,12 +53,10 @@ class Cloner
|
|||||||
if (userCan(Permission::PageCreate, $copyChapter)) {
|
if (userCan(Permission::PageCreate, $copyChapter)) {
|
||||||
/** @var Page $page */
|
/** @var Page $page */
|
||||||
foreach ($original->getVisiblePages() as $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;
|
return $copyChapter;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,14 +65,6 @@ class Cloner
|
|||||||
* Clones all child chapters and pages.
|
* Clones all child chapters and pages.
|
||||||
*/
|
*/
|
||||||
public function cloneBook(Book $original, string $newName): Book
|
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 = $this->entityToInputData($original);
|
||||||
$bookDetails['name'] = $newName;
|
$bookDetails['name'] = $newName;
|
||||||
@@ -111,11 +76,11 @@ class Cloner
|
|||||||
$directChildren = $original->getDirectVisibleChildren();
|
$directChildren = $original->getDirectVisibleChildren();
|
||||||
foreach ($directChildren as $child) {
|
foreach ($directChildren as $child) {
|
||||||
if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) {
|
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)) {
|
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;
|
return $copyBook;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,10 +155,4 @@ class Cloner
|
|||||||
|
|
||||||
return $tags;
|
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\Bookshelf;
|
||||||
use BookStack\Entities\Models\Chapter;
|
use BookStack\Entities\Models\Chapter;
|
||||||
use BookStack\Util\HtmlContentFilter;
|
use BookStack\Util\HtmlContentFilter;
|
||||||
use BookStack\Util\HtmlContentFilterConfig;
|
|
||||||
|
|
||||||
class EntityHtmlDescription
|
class EntityHtmlDescription
|
||||||
{
|
{
|
||||||
@@ -51,13 +50,7 @@ class EntityHtmlDescription
|
|||||||
return $html;
|
return $html;
|
||||||
}
|
}
|
||||||
|
|
||||||
$isEmpty = empty(trim(strip_tags($html)));
|
return HtmlContentFilter::removeScriptsFromHtmlString($html);
|
||||||
if ($isEmpty) {
|
|
||||||
return '<p></p>';
|
|
||||||
}
|
|
||||||
|
|
||||||
$filter = new HtmlContentFilter(new HtmlContentFilterConfig());
|
|
||||||
return $filter->filterString($html);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPlain(): string
|
public function getPlain(): string
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ class HierarchyTransformer
|
|||||||
protected BookRepo $bookRepo,
|
protected BookRepo $bookRepo,
|
||||||
protected BookshelfRepo $shelfRepo,
|
protected BookshelfRepo $shelfRepo,
|
||||||
protected Cloner $cloner,
|
protected Cloner $cloner,
|
||||||
protected TrashCan $trashCan,
|
protected TrashCan $trashCan
|
||||||
protected ParentChanger $parentChanger,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +35,7 @@ class HierarchyTransformer
|
|||||||
foreach ($chapter->pages as $page) {
|
foreach ($chapter->pages as $page) {
|
||||||
$page->chapter_id = 0;
|
$page->chapter_id = 0;
|
||||||
$page->save();
|
$page->save();
|
||||||
$this->parentChanger->changeBook($page, $book->id);
|
$page->changeBook($book->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->trashCan->destroyEntity($chapter);
|
$this->trashCan->destroyEntity($chapter);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace BookStack\Entities\Tools;
|
namespace BookStack\Entities\Tools;
|
||||||
|
|
||||||
use BookStack\App\AppVersion;
|
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Entities\Models\Page;
|
||||||
use BookStack\Entities\Queries\PageQueries;
|
use BookStack\Entities\Queries\PageQueries;
|
||||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||||
@@ -14,7 +13,6 @@ use BookStack\Uploads\ImageRepo;
|
|||||||
use BookStack\Uploads\ImageService;
|
use BookStack\Uploads\ImageService;
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
use BookStack\Util\HtmlContentFilter;
|
use BookStack\Util\HtmlContentFilter;
|
||||||
use BookStack\Util\HtmlContentFilterConfig;
|
|
||||||
use BookStack\Util\HtmlDocument;
|
use BookStack\Util\HtmlDocument;
|
||||||
use BookStack\Util\WebSafeMimeSniffer;
|
use BookStack\Util\WebSafeMimeSniffer;
|
||||||
use Closure;
|
use Closure;
|
||||||
@@ -319,30 +317,11 @@ class PageContent
|
|||||||
$this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
|
$this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cacheKey = $this->getContentCacheKey($doc->getBodyInnerHtml());
|
if (!config('app.allow_content_scripts')) {
|
||||||
$cached = cache()->get($cacheKey, null);
|
HtmlContentFilter::removeScriptsFromDocument($doc);
|
||||||
if ($cached !== null) {
|
|
||||||
return $cached;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
return $doc->getBodyInnerHtml();
|
||||||
$filter = new HtmlContentFilter($filterConfig);
|
|
||||||
$filtered = $filter->filterDocument($doc);
|
|
||||||
|
|
||||||
$cacheTime = 86400 * 7; // 1 week
|
|
||||||
cache()->put($cacheKey, $filtered, $cacheTime);
|
|
||||||
|
|
||||||
return $filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
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}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ use BookStack\Entities\Queries\EntityQueries;
|
|||||||
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
||||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
use BookStack\Util\HtmlContentFilter;
|
|
||||||
use BookStack\Util\HtmlContentFilterConfig;
|
|
||||||
|
|
||||||
class PageEditorData
|
class PageEditorData
|
||||||
{
|
{
|
||||||
@@ -49,7 +47,6 @@ class PageEditorData
|
|||||||
$isDraftRevision = false;
|
$isDraftRevision = false;
|
||||||
$this->warnings = [];
|
$this->warnings = [];
|
||||||
$editActivity = new PageEditActivity($page);
|
$editActivity = new PageEditActivity($page);
|
||||||
$lastEditorId = $page->updated_by ?? user()->id;
|
|
||||||
|
|
||||||
if ($editActivity->hasActiveEditing()) {
|
if ($editActivity->hasActiveEditing()) {
|
||||||
$this->warnings[] = $editActivity->activeEditingMessage();
|
$this->warnings[] = $editActivity->activeEditingMessage();
|
||||||
@@ -61,20 +58,11 @@ class PageEditorData
|
|||||||
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
|
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
|
||||||
$isDraftRevision = true;
|
$isDraftRevision = true;
|
||||||
$this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
|
$this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
|
||||||
$lastEditorId = $userDraft->created_by;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get editor type and handle changes
|
|
||||||
$editorType = $this->getEditorType($page);
|
$editorType = $this->getEditorType($page);
|
||||||
$this->updateContentForEditor($page, $editorType);
|
$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 [
|
return [
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
'book' => $page->book,
|
'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\Model;
|
||||||
use BookStack\App\SluggableInterface;
|
use BookStack\App\SluggableInterface;
|
||||||
use BookStack\Entities\Models\BookChild;
|
use BookStack\Entities\Models\BookChild;
|
||||||
use BookStack\Entities\Models\Entity;
|
|
||||||
use BookStack\Users\Models\User;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class SlugGenerator
|
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.
|
* 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
|
public function generate(SluggableInterface&Model $model, string $slugSource): string
|
||||||
@@ -25,26 +23,6 @@ class SlugGenerator
|
|||||||
return $slug;
|
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.
|
* 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.
|
* Update entity relations to remove or update outstanding connections.
|
||||||
*/
|
*/
|
||||||
protected function destroyCommonRelations(Entity $entity): void
|
protected function destroyCommonRelations(Entity $entity)
|
||||||
{
|
{
|
||||||
Activity::removeEntity($entity);
|
Activity::removeEntity($entity);
|
||||||
$entity->views()->delete();
|
$entity->views()->delete();
|
||||||
@@ -402,7 +402,6 @@ class TrashCan
|
|||||||
$entity->watches()->delete();
|
$entity->watches()->delete();
|
||||||
$entity->referencesTo()->delete();
|
$entity->referencesTo()->delete();
|
||||||
$entity->referencesFrom()->delete();
|
$entity->referencesFrom()->delete();
|
||||||
$entity->slugHistory()->delete();
|
|
||||||
|
|
||||||
if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
|
if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
|
||||||
$imageService = app()->make(ImageService::class);
|
$imageService = app()->make(ImageService::class);
|
||||||
|
|||||||
@@ -58,16 +58,6 @@ class ZipExportReader
|
|||||||
{
|
{
|
||||||
$this->open();
|
$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
|
// Validate json data exists, including metadata
|
||||||
$jsonData = $this->zip->getFromName('data.json') ?: '';
|
$jsonData = $this->zip->getFromName('data.json') ?: '';
|
||||||
$importData = json_decode($jsonData, true);
|
$importData = json_decode($jsonData, true);
|
||||||
@@ -83,17 +73,6 @@ class ZipExportReader
|
|||||||
return $this->zip->statName("files/{$fileName}") !== false;
|
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
|
* @return false|resource
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
|||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
use BookStack\Uploads\Attachment;
|
use BookStack\Uploads\Attachment;
|
||||||
use BookStack\Uploads\Image;
|
use BookStack\Uploads\Image;
|
||||||
use BookStack\Uploads\ImageService;
|
|
||||||
|
|
||||||
class ZipExportReferences
|
class ZipExportReferences
|
||||||
{
|
{
|
||||||
@@ -34,7 +33,6 @@ class ZipExportReferences
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected ZipReferenceParser $parser,
|
protected ZipReferenceParser $parser,
|
||||||
protected ImageService $imageService,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,17 +133,10 @@ class ZipExportReferences
|
|||||||
return "[[bsexport:image:{$model->id}]]";
|
return "[[bsexport:image:{$model->id}]]";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the page which we'll reference this image upon
|
// Find and include images if in visibility
|
||||||
$page = $model->getPage();
|
$page = $model->getPage();
|
||||||
$pageExportModel = null;
|
$pageExportModel = $this->pages[$page->id] ?? ($exportModel instanceof ZipExportPage ? $exportModel : null);
|
||||||
if ($page && isset($this->pages[$page->id])) {
|
if (isset($this->images[$model->id]) || ($page && $pageExportModel && userCan(Permission::PageView, $page))) {
|
||||||
$pageExportModel = $this->pages[$page->id];
|
|
||||||
} elseif ($exportModel instanceof ZipExportPage) {
|
|
||||||
$pageExportModel = $exportModel;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the image to the export if it's accessible or just return the existing reference if already added
|
|
||||||
if (isset($this->images[$model->id]) || ($pageExportModel && $this->imageService->imageAccessible($model))) {
|
|
||||||
if (!isset($this->images[$model->id])) {
|
if (!isset($this->images[$model->id])) {
|
||||||
$exportImage = ZipExportImage::fromModel($model, $files);
|
$exportImage = ZipExportImage::fromModel($model, $files);
|
||||||
$this->images[$model->id] = $exportImage;
|
$this->images[$model->id] = $exportImage;
|
||||||
@@ -153,7 +144,6 @@ class ZipExportReferences
|
|||||||
}
|
}
|
||||||
return "[[bsexport:image:{$model->id}]]";
|
return "[[bsexport:image:{$model->id}]]";
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class ZipFileReferenceRule implements ValidationRule
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @inheritDoc
|
* @inheritDoc
|
||||||
*/
|
*/
|
||||||
@@ -22,13 +23,6 @@ class ZipFileReferenceRule implements ValidationRule
|
|||||||
$fail('validation.zip_file')->translate();
|
$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)) {
|
if (!empty($this->acceptedMimes)) {
|
||||||
$fileMime = $this->context->zipReader->sniffFileMime($value);
|
$fileMime = $this->context->zipReader->sniffFileMime($value);
|
||||||
if (!in_array($fileMime, $this->acceptedMimes)) {
|
if (!in_array($fileMime, $this->acceptedMimes)) {
|
||||||
|
|||||||
@@ -265,12 +265,6 @@ class ZipImportRunner
|
|||||||
|
|
||||||
protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile
|
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');
|
$tempPath = tempnam(sys_get_temp_dir(), 'bszipextract');
|
||||||
$fileStream = $reader->streamFile($fileName);
|
$fileStream = $reader->streamFile($fileName);
|
||||||
$tempStream = fopen($tempPath, 'wb');
|
$tempStream = fopen($tempPath, 'wb');
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class ApiAuthenticate
|
|||||||
public function handle(Request $request, Closure $next)
|
public function handle(Request $request, Closure $next)
|
||||||
{
|
{
|
||||||
// Validate the token and it's users API access
|
// Validate the token and it's users API access
|
||||||
$this->ensureAuthorizedBySessionOrToken($request);
|
$this->ensureAuthorizedBySessionOrToken();
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
@@ -28,28 +28,22 @@ class ApiAuthenticate
|
|||||||
*
|
*
|
||||||
* @throws ApiAuthException
|
* @throws ApiAuthException
|
||||||
*/
|
*/
|
||||||
protected function ensureAuthorizedBySessionOrToken(Request $request): void
|
protected function ensureAuthorizedBySessionOrToken(): void
|
||||||
{
|
{
|
||||||
// Use the active user session already exists.
|
// Return if the user is already found to be signed in via session-based auth.
|
||||||
// This is to make it easy to explore API endpoints via the UI.
|
// This is to make it easy to browser the API via browser after just logging into the system.
|
||||||
if (session()->isStarted()) {
|
if (!user()->isGuest() || session()->isStarted()) {
|
||||||
// Ensure the user has API access permission
|
|
||||||
if (!$this->sessionUserHasApiAccess()) {
|
if (!$this->sessionUserHasApiAccess()) {
|
||||||
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set our api guard to be the default for this request lifecycle.
|
// Set our api guard to be the default for this request lifecycle.
|
||||||
auth()->shouldUse('api');
|
auth()->shouldUse('api');
|
||||||
|
|
||||||
// Validate the token and its users API access
|
// Validate the token and it's users API access
|
||||||
auth()->authenticate();
|
auth()->authenticate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ use Illuminate\Session\Middleware\StartSession as Middleware;
|
|||||||
class StartSessionExtended extends Middleware
|
class StartSessionExtended extends Middleware
|
||||||
{
|
{
|
||||||
protected static array $pathPrefixesExcludedFromHistory = [
|
protected static array $pathPrefixesExcludedFromHistory = [
|
||||||
'uploads/images/',
|
'uploads/images/'
|
||||||
'dist/',
|
|
||||||
'manifest.json',
|
|
||||||
'opensearch.xml',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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\Book;
|
||||||
use BookStack\Entities\Models\HasDescriptionInterface;
|
use BookStack\Entities\Models\HasDescriptionInterface;
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
|
use BookStack\Entities\Models\EntityContainerData;
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Entities\Models\Page;
|
||||||
use BookStack\Entities\Repos\RevisionRepo;
|
use BookStack\Entities\Repos\RevisionRepo;
|
||||||
use BookStack\Util\HtmlDocument;
|
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[]
|
* @return Reference[]
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -25,12 +25,11 @@ class SearchController extends Controller
|
|||||||
$searchOpts = SearchOptions::fromRequest($request);
|
$searchOpts = SearchOptions::fromRequest($request);
|
||||||
$fullSearchString = $searchOpts->toString();
|
$fullSearchString = $searchOpts->toString();
|
||||||
$page = intval($request->get('page', '0')) ?: 1;
|
$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);
|
$formatter->format($results['results']->all(), $searchOpts);
|
||||||
$paginator = new LengthAwarePaginator($results['results'], $results['total'], $count, $page);
|
$paginator = new LengthAwarePaginator($results['results'], $results['total'], 20, $page);
|
||||||
$paginator->setPath(url('/search'));
|
$paginator->setPath('/search');
|
||||||
$paginator->appends($request->except('page'));
|
$paginator->appends($request->except('page'));
|
||||||
|
|
||||||
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
|
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
|
||||||
@@ -78,9 +77,8 @@ class SearchController extends Controller
|
|||||||
|
|
||||||
// Search for entities otherwise show most popular
|
// Search for entities otherwise show most popular
|
||||||
if ($searchTerm !== false) {
|
if ($searchTerm !== false) {
|
||||||
$options = SearchOptions::fromString($searchTerm);
|
$searchTerm .= ' {type:' . implode('|', $entityTypes) . '}';
|
||||||
$options->setFilter('type', implode('|', $entityTypes));
|
$entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20)['results'];
|
||||||
$entities = $this->searchRunner->searchEntities($options, 'all', 1, 20)['results'];
|
|
||||||
} else {
|
} else {
|
||||||
$entities = $queryPopular->run(20, 0, $entityTypes);
|
$entities = $queryPopular->run(20, 0, $entityTypes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ class SearchIndex
|
|||||||
$termMap = $this->textToTermCountMap($text);
|
$termMap = $this->textToTermCountMap($text);
|
||||||
|
|
||||||
foreach ($termMap as $term => $count) {
|
foreach ($termMap as $term => $count) {
|
||||||
$termMap[$term] = intval($count * $scoreAdjustment);
|
$termMap[$term] = floor($count * $scoreAdjustment);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $termMap;
|
return $termMap;
|
||||||
|
|||||||
@@ -82,12 +82,4 @@ class SearchOptionSet
|
|||||||
$values = array_values(array_filter($this->options, fn (SearchOption $option) => !$option->negated));
|
$values = array_values(array_filter($this->options, fn (SearchOption $option) => !$option->negated));
|
||||||
return new self($values);
|
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 = new self();
|
||||||
$instance->addOptionsFromString($search);
|
$instance->addOptionsFromString($search);
|
||||||
$instance->limitOptions();
|
|
||||||
return $instance;
|
return $instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +87,6 @@ class SearchOptions
|
|||||||
$instance->filters = $instance->filters->merge($extras->filters);
|
$instance->filters = $instance->filters->merge($extras->filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
$instance->limitOptions();
|
|
||||||
|
|
||||||
return $instance;
|
return $instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,25 +147,6 @@ class SearchOptions
|
|||||||
$this->filters = $this->filters->merge(new SearchOptionSet($terms['filters']));
|
$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.
|
* Decode backslash escaping within the input string.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class AppSettingsStore
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function storeFromUpdateRequest(Request $request, string $category): void
|
public function storeFromUpdateRequest(Request $request, string $category)
|
||||||
{
|
{
|
||||||
$this->storeSimpleSettings($request);
|
$this->storeSimpleSettings($request);
|
||||||
if ($category === 'customization') {
|
if ($category === 'customization') {
|
||||||
@@ -76,7 +76,7 @@ class AppSettingsStore
|
|||||||
protected function storeSimpleSettings(Request $request): void
|
protected function storeSimpleSettings(Request $request): void
|
||||||
{
|
{
|
||||||
foreach ($request->all() as $name => $value) {
|
foreach ($request->all() as $name => $value) {
|
||||||
if (!str_starts_with($name, 'setting-')) {
|
if (strpos($name, 'setting-') !== 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ class AppSettingsStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function destroyExistingSettingImage(string $settingKey): void
|
protected function destroyExistingSettingImage(string $settingKey)
|
||||||
{
|
{
|
||||||
$existingVal = setting()->get($settingKey);
|
$existingVal = setting()->get($settingKey);
|
||||||
if ($existingVal) {
|
if ($existingVal) {
|
||||||
|
|||||||
@@ -28,21 +28,6 @@ class SettingService
|
|||||||
return $this->formatValue($value, $default);
|
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.
|
* Get a value from the session instead of the main store option.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -26,14 +26,9 @@ class UserNotificationPreferences
|
|||||||
return $this->getNotificationSetting('comment-replies');
|
return $this->getNotificationSetting('comment-replies');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function notifyOnCommentMentions(): bool
|
|
||||||
{
|
|
||||||
return $this->getNotificationSetting('comment-mentions');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateFromSettingsArray(array $settings)
|
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) {
|
foreach ($settings as $setting => $status) {
|
||||||
if (!in_array($setting, $allowList)) {
|
if (!in_array($setting, $allowList)) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -8,14 +8,12 @@ use BookStack\Entities\Models\Chapter;
|
|||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Entities\Models\Page;
|
use BookStack\Entities\Models\Page;
|
||||||
use BookStack\Entities\Queries\EntityQueries;
|
use BookStack\Entities\Queries\EntityQueries;
|
||||||
use BookStack\Entities\Tools\ParentChanger;
|
|
||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
|
|
||||||
class BookSorter
|
class BookSorter
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected EntityQueries $queries,
|
protected EntityQueries $queries,
|
||||||
protected ParentChanger $parentChanger,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +155,7 @@ class BookSorter
|
|||||||
|
|
||||||
// Action the required changes
|
// Action the required changes
|
||||||
if ($bookChanged) {
|
if ($bookChanged) {
|
||||||
$this->parentChanger->changeBook($model, $newBook->id);
|
$model = $model->changeBook($newBook->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($model instanceof Page && $chapterChanged) {
|
if ($model instanceof Page && $chapterChanged) {
|
||||||
|
|||||||
@@ -4,16 +4,25 @@ namespace BookStack\Theming;
|
|||||||
|
|
||||||
use BookStack\Util\CspService;
|
use BookStack\Util\CspService;
|
||||||
use BookStack\Util\HtmlContentFilter;
|
use BookStack\Util\HtmlContentFilter;
|
||||||
use BookStack\Util\HtmlContentFilterConfig;
|
|
||||||
use BookStack\Util\HtmlNonceApplicator;
|
use BookStack\Util\HtmlNonceApplicator;
|
||||||
use Illuminate\Contracts\Cache\Repository as Cache;
|
use Illuminate\Contracts\Cache\Repository as Cache;
|
||||||
|
|
||||||
class CustomHtmlHeadContentProvider
|
class CustomHtmlHeadContentProvider
|
||||||
{
|
{
|
||||||
public function __construct(
|
/**
|
||||||
protected CspService $cspService,
|
* @var CspService
|
||||||
protected Cache $cache
|
*/
|
||||||
) {
|
protected $cspService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Cache
|
||||||
|
*/
|
||||||
|
protected $cache;
|
||||||
|
|
||||||
|
public function __construct(CspService $cspService, Cache $cache)
|
||||||
|
{
|
||||||
|
$this->cspService = $cspService;
|
||||||
|
$this->cache = $cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,8 +50,7 @@ class CustomHtmlHeadContentProvider
|
|||||||
$hash = md5($content);
|
$hash = md5($content);
|
||||||
|
|
||||||
return $this->cache->remember('custom-head-export:' . $hash, 86400, function () use ($content) {
|
return $this->cache->remember('custom-head-export:' . $hash, 86400, function () use ($content) {
|
||||||
$config = new HtmlContentFilterConfig(filterOutNonContentElements: false, useAllowListFilter: false);
|
return HtmlContentFilter::removeScriptsFromHtmlString($content);
|
||||||
return (new HtmlContentFilter($config))->filterString($content);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,22 +5,21 @@ namespace BookStack\Theming;
|
|||||||
use BookStack\Facades\Theme;
|
use BookStack\Facades\Theme;
|
||||||
use BookStack\Http\Controller;
|
use BookStack\Http\Controller;
|
||||||
use BookStack\Util\FilePathNormalizer;
|
use BookStack\Util\FilePathNormalizer;
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
||||||
|
|
||||||
class ThemeController extends Controller
|
class ThemeController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Serve a public file from the configured theme.
|
* 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);
|
$cleanPath = FilePathNormalizer::normalize($path);
|
||||||
if ($theme !== Theme::getTheme() || !$cleanPath) {
|
if ($theme !== Theme::getTheme() || !$cleanPath) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$filePath = Theme::findFirstFile("public/{$cleanPath}");
|
$filePath = theme_path("public/{$cleanPath}");
|
||||||
if (!$filePath) {
|
if (!file_exists($filePath)) {
|
||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,16 +134,6 @@ class ThemeEvents
|
|||||||
*/
|
*/
|
||||||
const ROUTES_REGISTER_WEB_AUTH = 'routes_register_web_auth';
|
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.
|
* Web before middleware action.
|
||||||
* Runs before the request is handled but after all other middleware apart from those
|
* 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), 20);
|
|
||||||
$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 BookStack\Exceptions\ThemeException;
|
||||||
use Illuminate\Console\Application;
|
use Illuminate\Console\Application;
|
||||||
use Illuminate\Console\Application as Artisan;
|
use Illuminate\Console\Application as Artisan;
|
||||||
use Illuminate\View\FileViewFinder;
|
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
|
||||||
class ThemeService
|
class ThemeService
|
||||||
@@ -16,11 +15,6 @@ class ThemeService
|
|||||||
*/
|
*/
|
||||||
protected array $listeners = [];
|
protected array $listeners = [];
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, ThemeModule>
|
|
||||||
*/
|
|
||||||
protected array $modules = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the currently configured theme.
|
* Get the currently configured theme.
|
||||||
* Returns an empty string if not configured.
|
* Returns an empty string if not configured.
|
||||||
@@ -82,71 +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
|
public function readThemeActions(): void
|
||||||
{
|
{
|
||||||
$moduleFunctionFiles = array_map(function (ThemeModule $module): string {
|
$themeActionsFile = theme_path('functions.php');
|
||||||
return $module->path('functions.php');
|
if ($themeActionsFile && file_exists($themeActionsFile)) {
|
||||||
}, $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) {
|
|
||||||
try {
|
try {
|
||||||
require $functionFile;
|
require $themeActionsFile;
|
||||||
} catch (\Error $exception) {
|
} 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
* @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;
|
namespace BookStack\Translation;
|
||||||
|
|
||||||
use BookStack\Facades\Theme;
|
|
||||||
use Illuminate\Translation\FileLoader as BaseLoader;
|
use Illuminate\Translation\FileLoader as BaseLoader;
|
||||||
|
|
||||||
class FileLoader extends BaseLoader
|
class FileLoader extends BaseLoader
|
||||||
@@ -13,6 +12,11 @@ class FileLoader extends BaseLoader
|
|||||||
* Extends Laravel's translation FileLoader to look in multiple directories
|
* Extends Laravel's translation FileLoader to look in multiple directories
|
||||||
* so that we can load in translation overrides from the theme file if wanted.
|
* 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 $locale
|
||||||
* @param string $group
|
* @param string $group
|
||||||
* @param string|null $namespace
|
* @param string|null $namespace
|
||||||
@@ -28,18 +32,9 @@ class FileLoader extends BaseLoader
|
|||||||
if (is_null($namespace) || $namespace === '*') {
|
if (is_null($namespace) || $namespace === '*') {
|
||||||
$themePath = theme_path('lang');
|
$themePath = theme_path('lang');
|
||||||
$themeTranslations = $themePath ? $this->loadPaths([$themePath], $locale, $group) : [];
|
$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);
|
$originalTranslations = $this->loadPaths($this->paths, $locale, $group);
|
||||||
return array_merge($originalTranslations, $moduleTranslations, $themeTranslations);
|
|
||||||
|
return array_merge($originalTranslations, $themeTranslations);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->loadNamespaced($locale, $group, $namespace);
|
return $this->loadNamespaced($locale, $group, $namespace);
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property string $url
|
* @property string $url
|
||||||
* @property string $path
|
* @property string $path
|
||||||
* @property string $type
|
* @property string $type
|
||||||
* @property int|null $uploaded_to
|
* @property int $uploaded_to
|
||||||
* @property int $created_by
|
* @property int $created_by
|
||||||
* @property int $updated_by
|
* @property int $updated_by
|
||||||
*/
|
*/
|
||||||
class Image extends Model implements OwnableInterface
|
class Image extends Model implements OwnableInterface
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class ImageResizer
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the thumbnail for an image.
|
* 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.
|
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
|
||||||
*
|
*
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
@@ -84,7 +84,7 @@ class ImageResizer
|
|||||||
return $this->storage->getPublicUrl($cachedThumbPath);
|
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);
|
$disk = $this->storage->getDisk($image->type);
|
||||||
if (!$shouldCreate && $disk->exists($thumbFilePath)) {
|
if (!$shouldCreate && $disk->exists($thumbFilePath)) {
|
||||||
Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
|
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.
|
* Format will remain the same as the input format, unless specified.
|
||||||
*
|
*
|
||||||
* @throws ImageUploadException
|
* @throws ImageUploadException
|
||||||
@@ -125,7 +125,6 @@ class ImageResizer
|
|||||||
try {
|
try {
|
||||||
$thumb = $this->interventionFromImageData($imageData, $format);
|
$thumb = $this->interventionFromImageData($imageData, $format);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Log::error('Failed to resize image with error:' . $e->getMessage());
|
|
||||||
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
|
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,21 +154,17 @@ class ImageResizer
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an intervention image instance from the given image data.
|
* 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.
|
* from given binary data instead of data being misinterpreted.
|
||||||
*/
|
*/
|
||||||
protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage
|
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(
|
$manager = new ImageManager(
|
||||||
new Driver(),
|
new Driver(),
|
||||||
autoOrientation: false,
|
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.
|
// handling since we don't need the added animation support.
|
||||||
$isGif = $fileType === 'gif';
|
$isGif = $fileType === 'gif';
|
||||||
$decoder = $isGif ? NativeObjectDecoder::class : BinaryImageDecoder::class;
|
$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
|
protected function isGif(Image $image): bool
|
||||||
{
|
{
|
||||||
@@ -255,7 +250,7 @@ class ImageResizer
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if the given avif image data represents an animated image.
|
* 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
|
protected function isAnimatedAvifData(string &$imageData): bool
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ class ImageService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Destroy an image along with its revisions, thumbnails, and remaining folders.
|
* Destroy an image along with its revisions, thumbnails and remaining folders.
|
||||||
*
|
*
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
@@ -252,7 +252,16 @@ class ImageService
|
|||||||
{
|
{
|
||||||
$disk = $this->storage->getDisk('gallery');
|
$disk = $this->storage->getDisk('gallery');
|
||||||
|
|
||||||
return $disk->usingSecureImages() && $this->pathAccessible($imagePath);
|
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check local_secure is active
|
||||||
|
return $disk->usingSecureImages()
|
||||||
|
// Check the image file exists
|
||||||
|
&& $disk->exists($imagePath)
|
||||||
|
// Check the file is likely an image file
|
||||||
|
&& str_starts_with($disk->mimeType($imagePath), 'image/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -260,51 +269,16 @@ class ImageService
|
|||||||
*/
|
*/
|
||||||
public function pathAccessible(string $imagePath): bool
|
public function pathAccessible(string $imagePath): bool
|
||||||
{
|
{
|
||||||
|
$disk = $this->storage->getDisk('gallery');
|
||||||
|
|
||||||
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
|
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->blockedBySecureImages()) {
|
// Check local_secure is active
|
||||||
return false;
|
return $disk->exists($imagePath)
|
||||||
}
|
// Check the file is likely an image file
|
||||||
|
&& str_starts_with($disk->mimeType($imagePath), 'image/');
|
||||||
return $this->imageFileExists($imagePath, 'gallery');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the given image should be accessible to the current user.
|
|
||||||
*/
|
|
||||||
public function imageAccessible(Image $image): bool
|
|
||||||
{
|
|
||||||
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImage($image)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->blockedBySecureImages()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->imageFileExists($image->path, $image->type);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the current user should be blocked from accessing images based on if secure images are enabled
|
|
||||||
* and if public access is enabled for the application.
|
|
||||||
*/
|
|
||||||
protected function blockedBySecureImages(): bool
|
|
||||||
{
|
|
||||||
$enforced = $this->storage->usingSecureImages() && !setting('app-public');
|
|
||||||
|
|
||||||
return $enforced && user()->isGuest();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the given image path exists for the given image type and that it is likely an image file.
|
|
||||||
*/
|
|
||||||
protected function imageFileExists(string $imagePath, string $imageType): bool
|
|
||||||
{
|
|
||||||
$disk = $this->storage->getDisk($imageType);
|
|
||||||
return $disk->exists($imagePath) && str_starts_with($disk->mimeType($imagePath), 'image/');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -333,11 +307,6 @@ class ImageService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->checkUserHasAccessToRelationOfImage($image);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function checkUserHasAccessToRelationOfImage(Image $image): bool
|
|
||||||
{
|
|
||||||
$imageType = $image->type;
|
$imageType = $image->type;
|
||||||
|
|
||||||
// Allow user or system (logo) images
|
// Allow user or system (logo) images
|
||||||
|
|||||||
@@ -34,15 +34,6 @@ class ImageStorage
|
|||||||
return config('filesystems.images') === 'local_secure_restricted';
|
return config('filesystems.images') === 'local_secure_restricted';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if "local secure" (Fetched behind auth, either with or without permissions enforced)
|
|
||||||
* is currently active in the instance.
|
|
||||||
*/
|
|
||||||
public function usingSecureImages(): bool
|
|
||||||
{
|
|
||||||
return config('filesystems.images') === 'local_secure' || $this->usingSecureRestrictedImages();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up an image file name to be both URL and storage safe.
|
* Clean up an image file name to be both URL and storage safe.
|
||||||
*/
|
*/
|
||||||
@@ -74,7 +65,7 @@ class ImageStorage
|
|||||||
return 'local';
|
return 'local';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename local_secure options to get our image-specific storage driver, which
|
// Rename local_secure options to get our image specific storage driver which
|
||||||
// is scoped to the relevant image directories.
|
// is scoped to the relevant image directories.
|
||||||
if ($localSecureInUse) {
|
if ($localSecureInUse) {
|
||||||
return 'local_secure_images';
|
return 'local_secure_images';
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ namespace BookStack\Users\Controllers;
|
|||||||
use BookStack\Http\Controller;
|
use BookStack\Http\Controller;
|
||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class UserSearchController extends Controller
|
class UserSearchController extends Controller
|
||||||
@@ -35,43 +34,8 @@ class UserSearchController extends Controller
|
|||||||
$query->where('name', 'like', '%' . $search . '%');
|
$query->where('name', 'like', '%' . $search . '%');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Collection<User> $users */
|
|
||||||
$users = $query->get();
|
|
||||||
|
|
||||||
return view('form.user-select-list', [
|
return view('form.user-select-list', [
|
||||||
'users' => $users,
|
'users' => $query->get(),
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use BookStack\Activity\Models\Watch;
|
|||||||
use BookStack\Api\ApiToken;
|
use BookStack\Api\ApiToken;
|
||||||
use BookStack\App\Model;
|
use BookStack\App\Model;
|
||||||
use BookStack\App\SluggableInterface;
|
use BookStack\App\SluggableInterface;
|
||||||
|
use BookStack\Entities\Tools\SlugGenerator;
|
||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
use BookStack\Translation\LocaleDefinition;
|
use BookStack\Translation\LocaleDefinition;
|
||||||
use BookStack\Translation\LocaleManager;
|
use BookStack\Translation\LocaleManager;
|
||||||
@@ -357,4 +358,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
|||||||
{
|
{
|
||||||
return "({$this->id}) {$this->name}";
|
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\UserInviteException;
|
||||||
use BookStack\Access\UserInviteService;
|
use BookStack\Access\UserInviteService;
|
||||||
use BookStack\Activity\ActivityType;
|
use BookStack\Activity\ActivityType;
|
||||||
use BookStack\Entities\Tools\SlugGenerator;
|
|
||||||
use BookStack\Exceptions\NotifyException;
|
use BookStack\Exceptions\NotifyException;
|
||||||
use BookStack\Exceptions\UserUpdateException;
|
use BookStack\Exceptions\UserUpdateException;
|
||||||
use BookStack\Facades\Activity;
|
use BookStack\Facades\Activity;
|
||||||
@@ -22,8 +21,7 @@ class UserRepo
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected UserAvatars $userAvatar,
|
protected UserAvatars $userAvatar,
|
||||||
protected UserInviteService $inviteService,
|
protected UserInviteService $inviteService
|
||||||
protected SlugGenerator $slugGenerator,
|
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +63,7 @@ class UserRepo
|
|||||||
$user->email_confirmed = $emailConfirmed;
|
$user->email_confirmed = $emailConfirmed;
|
||||||
$user->external_auth_id = $data['external_auth_id'] ?? '';
|
$user->external_auth_id = $data['external_auth_id'] ?? '';
|
||||||
|
|
||||||
$this->slugGenerator->regenerateForUser($user);
|
$user->refreshSlug();
|
||||||
$user->save();
|
$user->save();
|
||||||
|
|
||||||
if (!empty($data['language'])) {
|
if (!empty($data['language'])) {
|
||||||
@@ -111,7 +109,7 @@ class UserRepo
|
|||||||
{
|
{
|
||||||
if (!empty($data['name'])) {
|
if (!empty($data['name'])) {
|
||||||
$user->name = $data['name'];
|
$user->name = $data['name'];
|
||||||
$this->slugGenerator->regenerateForUser($user);
|
$user->refreshSlug();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($data['email']) && $manageUsersAllowed) {
|
if (!empty($data['email']) && $manageUsersAllowed) {
|
||||||
|
|||||||
@@ -1,150 +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('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',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function purify(string $html): string
|
|
||||||
{
|
|
||||||
return $this->purifier->purify($html);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -65,7 +65,7 @@ class CspService
|
|||||||
*/
|
*/
|
||||||
protected function getScriptSrc(): string
|
protected function getScriptSrc(): string
|
||||||
{
|
{
|
||||||
if ($this->scriptFilteringDisabled()) {
|
if (config('app.allow_content_scripts')) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ class CspService
|
|||||||
*/
|
*/
|
||||||
protected function getObjectSrc(): string
|
protected function getObjectSrc(): string
|
||||||
{
|
{
|
||||||
if ($this->scriptFilteringDisabled()) {
|
if (config('app.allow_content_scripts')) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,11 +124,6 @@ class CspService
|
|||||||
return "base-uri 'self'";
|
return "base-uri 'self'";
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function scriptFilteringDisabled(): bool
|
|
||||||
{
|
|
||||||
return !HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'))->filterOutJavaScript;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getAllowedIframeHosts(): array
|
protected function getAllowedIframeHosts(): array
|
||||||
{
|
{
|
||||||
$hosts = config('app.iframe_hosts') ?? '';
|
$hosts = config('app.iframe_hosts') ?? '';
|
||||||
|
|||||||
@@ -8,46 +8,10 @@ use DOMNodeList;
|
|||||||
|
|
||||||
class HtmlContentFilter
|
class HtmlContentFilter
|
||||||
{
|
{
|
||||||
public function __construct(
|
/**
|
||||||
protected HtmlContentFilterConfig $config
|
* Remove all the script elements from the given HTML document.
|
||||||
) {
|
*/
|
||||||
}
|
public static function removeScriptsFromDocument(HtmlDocument $doc)
|
||||||
|
|
||||||
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 standard script tags
|
// Remove standard script tags
|
||||||
$scriptElems = $doc->queryXPath('//script');
|
$scriptElems = $doc->queryXPath('//script');
|
||||||
@@ -57,21 +21,21 @@ class HtmlContentFilter
|
|||||||
$badLinks = $doc->queryXPath('//*[' . static::xpathContains('@href', 'javascript:') . ']');
|
$badLinks = $doc->queryXPath('//*[' . static::xpathContains('@href', 'javascript:') . ']');
|
||||||
static::removeNodes($badLinks);
|
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:') . ']');
|
$badForms = $doc->queryXPath('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']');
|
||||||
static::removeNodes($badForms);
|
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]');
|
$badIframes = $doc->queryXPath('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]');
|
||||||
static::removeNodes($badIframes);
|
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.
|
// Remove attributes, within svg children, hiding JavaScript or data uris.
|
||||||
// A bunch of svg element and attribute combinations expose xss possibilities.
|
// 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:') . ']');
|
$badValuesAttrs = $doc->queryXPath('//svg//@*[' . static::xpathContains('.', 'data:') . '] | //svg//@*[' . static::xpathContains('.', 'javascript:') . ']');
|
||||||
static::removeAttributes($badValuesAttrs);
|
static::removeAttributes($badValuesAttrs);
|
||||||
|
|
||||||
@@ -85,52 +49,23 @@ class HtmlContentFilter
|
|||||||
static::removeAttributes($onAttributes);
|
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
|
if (empty($html)) {
|
||||||
$formElements = ['form', 'fieldset', 'button', 'textarea', 'select'];
|
return $html;
|
||||||
foreach ($formElements as $formElement) {
|
|
||||||
$matchingFormElements = $doc->queryXPath('//' . $formElement);
|
|
||||||
static::removeNodes($matchingFormElements);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove non-checkbox inputs
|
$doc = new HtmlDocument($html);
|
||||||
$inputsToRemove = $doc->queryXPath('//input');
|
static::removeScriptsFromDocument($doc);
|
||||||
/** @var DOMElement $input */
|
|
||||||
foreach ($inputsToRemove as $input) {
|
|
||||||
$type = strtolower($input->getAttribute('type'));
|
|
||||||
if ($type !== 'checkbox') {
|
|
||||||
$input->parentNode->removeChild($input);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove form attributes
|
return $doc->getBodyInnerHtml();
|
||||||
$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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
* to affectively search in a cases-insensitive manner.
|
||||||
*/
|
*/
|
||||||
protected static function xpathContains(string $property, string $value): string
|
protected static function xpathContains(string $property, string $value): string
|
||||||
@@ -164,34 +99,4 @@ class HtmlContentFilter
|
|||||||
$parentNode->removeAttribute($attrName);
|
$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 = [
|
protected static array $allowedAttrsByElements = [
|
||||||
'p' => [],
|
'p' => [],
|
||||||
'a' => ['href', 'title', 'target', 'data-mention-user-id'],
|
'a' => ['href', 'title', 'target'],
|
||||||
'ol' => [],
|
'ol' => [],
|
||||||
'ul' => [],
|
'ul' => [],
|
||||||
'li' => [],
|
'li' => [],
|
||||||
|
|||||||
@@ -103,13 +103,7 @@ class HtmlDocument
|
|||||||
*/
|
*/
|
||||||
public function getBody(): DOMNode
|
public function getBody(): DOMNode
|
||||||
{
|
{
|
||||||
$bodies = $this->document->getElementsByTagName('body');
|
return $this->document->getElementsByTagName('body')[0];
|
||||||
|
|
||||||
if ($bodies->length === 0) {
|
|
||||||
return new DOMElement('body', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $bodies[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
namespace BookStack\Util;
|
namespace BookStack\Util;
|
||||||
|
|
||||||
use BookStack\Facades\Theme;
|
|
||||||
|
|
||||||
class SvgIcon
|
class SvgIcon
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -25,9 +23,12 @@ class SvgIcon
|
|||||||
$attrString .= $attrName . '="' . $attr . '" ';
|
$attrString .= $attrName . '="' . $attr . '" ';
|
||||||
}
|
}
|
||||||
|
|
||||||
$defaultIconPath = resource_path('icons/' . $this->name . '.svg');
|
$iconPath = resource_path('icons/' . $this->name . '.svg');
|
||||||
$iconPath = Theme::findFirstFile("icons/{$this->name}.svg") ?? $defaultIconPath;
|
$themeIconPath = theme_path('icons/' . $this->name . '.svg');
|
||||||
if (!file_exists($iconPath)) {
|
|
||||||
|
if ($themeIconPath && file_exists($themeIconPath)) {
|
||||||
|
$iconPath = $themeIconPath;
|
||||||
|
} elseif (!file_exists($iconPath)) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -19,7 +19,6 @@
|
|||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"bacon/bacon-qr-code": "^3.0",
|
"bacon/bacon-qr-code": "^3.0",
|
||||||
"dompdf/dompdf": "^3.1",
|
"dompdf/dompdf": "^3.1",
|
||||||
"ezyang/htmlpurifier": "^4.19",
|
|
||||||
"guzzlehttp/guzzle": "^7.4",
|
"guzzlehttp/guzzle": "^7.4",
|
||||||
"intervention/image": "^3.5",
|
"intervention/image": "^3.5",
|
||||||
"knplabs/knp-snappy": "^1.5",
|
"knplabs/knp-snappy": "^1.5",
|
||||||
@@ -30,17 +29,16 @@
|
|||||||
"league/flysystem-aws-s3-v3": "^3.0",
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
"league/html-to-markdown": "^5.0.0",
|
"league/html-to-markdown": "^5.0.0",
|
||||||
"league/oauth2-client": "^2.6",
|
"league/oauth2-client": "^2.6",
|
||||||
"onelogin/php-saml": "^4.3.1",
|
"onelogin/php-saml": "^4.0",
|
||||||
"phpseclib/phpseclib": "^3.0",
|
"phpseclib/phpseclib": "^3.0",
|
||||||
"pragmarx/google2fa": "^9.0",
|
"pragmarx/google2fa": "^8.0",
|
||||||
"predis/predis": "^3.2",
|
"predis/predis": "^3.2",
|
||||||
"socialiteproviders/discord": "^4.1",
|
"socialiteproviders/discord": "^4.1",
|
||||||
"socialiteproviders/gitlab": "^4.1",
|
"socialiteproviders/gitlab": "^4.1",
|
||||||
"socialiteproviders/microsoft-azure": "^5.1",
|
"socialiteproviders/microsoft-azure": "^5.1",
|
||||||
"socialiteproviders/okta": "^4.2",
|
"socialiteproviders/okta": "^4.2",
|
||||||
"socialiteproviders/twitch": "^5.3",
|
"socialiteproviders/twitch": "^5.3",
|
||||||
"ssddanbrown/htmldiff": "^2.0.0",
|
"ssddanbrown/htmldiff": "^2.0.0"
|
||||||
"xemlock/htmlpurifier-html5": "^0.1.12"
|
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.21",
|
"fakerphp/faker": "^1.21",
|
||||||
@@ -49,7 +47,7 @@
|
|||||||
"nunomaduro/collision": "^8.6",
|
"nunomaduro/collision": "^8.6",
|
||||||
"larastan/larastan": "^v3.0",
|
"larastan/larastan": "^v3.0",
|
||||||
"phpunit/phpunit": "^11.5",
|
"phpunit/phpunit": "^11.5",
|
||||||
"squizlabs/php_codesniffer": "^4.0.1",
|
"squizlabs/php_codesniffer": "^3.7",
|
||||||
"ssddanbrown/asserthtml": "^3.1"
|
"ssddanbrown/asserthtml": "^3.1"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -95,7 +93,6 @@
|
|||||||
"@php artisan view:clear"
|
"@php artisan view:clear"
|
||||||
],
|
],
|
||||||
"refresh-test-database": [
|
"refresh-test-database": [
|
||||||
"@putenv APP_TIMEZONE=UTC",
|
|
||||||
"@php artisan migrate:refresh --database=mysql_testing",
|
"@php artisan migrate:refresh --database=mysql_testing",
|
||||||
"@php artisan db:seed --class=DummyContentSeeder --database=mysql_testing"
|
"@php artisan db:seed --class=DummyContentSeeder --database=mysql_testing"
|
||||||
]
|
]
|
||||||
|
|||||||
1555
composer.lock
generated
1555
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
|||||||
project_id: "377219"
|
|
||||||
project_identifier: bookstack
|
project_identifier: bookstack
|
||||||
base_path: .
|
base_path: .
|
||||||
preserve_hierarchy: false
|
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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -13,6 +13,7 @@ use BookStack\Search\SearchIndex;
|
|||||||
use BookStack\Users\Models\Role;
|
use BookStack\Users\Models\Role;
|
||||||
use BookStack\Users\Models\User;
|
use BookStack\Users\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
@@ -22,8 +23,10 @@ class DummyContentSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Run the database seeds.
|
* Run the database seeds.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run()
|
||||||
{
|
{
|
||||||
// Create an editor user
|
// Create an editor user
|
||||||
$editorUser = User::factory()->create();
|
$editorUser = User::factory()->create();
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import * as esbuild from 'esbuild';
|
const esbuild = require('esbuild');
|
||||||
import * as path from 'node:path';
|
const path = require('path');
|
||||||
import * as fs from 'node:fs';
|
const fs = require('fs');
|
||||||
import * as process from "node:process";
|
|
||||||
|
|
||||||
// Check if we're building for production
|
// Check if we're building for production
|
||||||
// (Set via passing `production` as first argument)
|
// (Set via passing `production` as first argument)
|
||||||
const mode = process.argv[2];
|
const isProd = process.argv[2] === 'production';
|
||||||
const isProd = mode === 'production';
|
|
||||||
const __dirname = import.meta.dirname;
|
|
||||||
|
|
||||||
// Gather our input files
|
// Gather our input files
|
||||||
const entryPoints = {
|
const entryPoints = {
|
||||||
@@ -20,16 +17,11 @@ const entryPoints = {
|
|||||||
wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'),
|
wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watch styles so we can reload on change
|
|
||||||
if (mode === 'watch') {
|
|
||||||
entryPoints['styles-dummy'] = path.join(__dirname, '../../public/dist/styles.css');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Locate our output directory
|
// Locate our output directory
|
||||||
const outdir = path.join(__dirname, '../../public/dist');
|
const outdir = path.join(__dirname, '../../public/dist');
|
||||||
|
|
||||||
// Define the options for esbuild
|
// Build via esbuild
|
||||||
const options = {
|
esbuild.build({
|
||||||
bundle: true,
|
bundle: true,
|
||||||
metafile: true,
|
metafile: true,
|
||||||
entryPoints,
|
entryPoints,
|
||||||
@@ -41,7 +33,6 @@ const options = {
|
|||||||
minify: isProd,
|
minify: isProd,
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
loader: {
|
loader: {
|
||||||
'.html': 'copy',
|
|
||||||
'.svg': 'text',
|
'.svg': 'text',
|
||||||
},
|
},
|
||||||
absWorkingDir: path.join(__dirname, '../..'),
|
absWorkingDir: path.join(__dirname, '../..'),
|
||||||
@@ -54,34 +45,6 @@ const options = {
|
|||||||
js: '// See the "/licenses" URI for full package license details',
|
js: '// See the "/licenses" URI for full package license details',
|
||||||
css: '/* See the "/licenses" URI for full package license details */',
|
css: '/* See the "/licenses" URI for full package license details */',
|
||||||
},
|
},
|
||||||
};
|
}).then(result => {
|
||||||
|
|
||||||
if (mode === 'watch') {
|
|
||||||
options.inject = [
|
|
||||||
path.join(__dirname, './livereload.js'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = await esbuild.context(options);
|
|
||||||
|
|
||||||
if (mode === 'watch') {
|
|
||||||
// Watch for changes and rebuild on change
|
|
||||||
ctx.watch({});
|
|
||||||
let {hosts, port} = await ctx.serve({
|
|
||||||
servedir: path.join(__dirname, '../../public'),
|
|
||||||
cors: {
|
|
||||||
origin: '*',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Build with meta output for analysis
|
|
||||||
const result = await ctx.rebuild();
|
|
||||||
const outputs = result.metafile.outputs;
|
|
||||||
const files = Object.keys(outputs);
|
|
||||||
for (const file of files) {
|
|
||||||
const output = outputs[file];
|
|
||||||
console.log(`Written: ${file} @ ${Math.round(output.bytes / 1000)}kB`);
|
|
||||||
}
|
|
||||||
fs.writeFileSync('esbuild-meta.json', JSON.stringify(result.metafile));
|
fs.writeFileSync('esbuild-meta.json', JSON.stringify(result.metafile));
|
||||||
process.exit(0);
|
}).catch(() => process.exit(1));
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
if (!window.__dev_reload_listening) {
|
|
||||||
listen();
|
|
||||||
window.__dev_reload_listening = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function listen() {
|
|
||||||
console.log('Listening for livereload events...');
|
|
||||||
new EventSource("http://127.0.0.1:8000/esbuild").addEventListener('change', e => {
|
|
||||||
const { added, removed, updated } = JSON.parse(e.data);
|
|
||||||
|
|
||||||
if (!added.length && !removed.length && updated.length > 0) {
|
|
||||||
const updatedPath = updated.filter(path => path.endsWith('.css'))[0]
|
|
||||||
if (!updatedPath) return;
|
|
||||||
|
|
||||||
const links = [...document.querySelectorAll("link[rel='stylesheet']")];
|
|
||||||
for (const link of links) {
|
|
||||||
const url = new URL(link.href);
|
|
||||||
const name = updatedPath.replace('-dummy', '');
|
|
||||||
|
|
||||||
if (url.pathname.endsWith(name)) {
|
|
||||||
const next = link.cloneNode();
|
|
||||||
next.href = name + '?version=' + Math.random().toString(36).slice(2);
|
|
||||||
next.onload = function() {
|
|
||||||
link.remove();
|
|
||||||
};
|
|
||||||
link.after(next);
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
location.reload()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user