mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-07 03:09:44 +03:00
Compare commits
336 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d2cd20e80 | ||
|
|
b0c574356a | ||
|
|
980a684b14 | ||
|
|
d56eea9279 | ||
|
|
2be504e0d2 | ||
|
|
c84d999456 | ||
|
|
01825ddb93 | ||
|
|
1f88bc2a59 | ||
|
|
ebe2ca7faf | ||
|
|
f4005a139b | ||
|
|
fca8f928a3 | ||
|
|
ace8af077d | ||
|
|
e50cd33277 | ||
|
|
8486775edf | ||
|
|
5887322178 | ||
|
|
3f86937f74 | ||
|
|
2f119d3033 | ||
|
|
5f07f31c9f | ||
|
|
a71aa241ad | ||
|
|
97b201f61f | ||
|
|
a8ef820443 | ||
|
|
7e1a8e5ec6 | ||
|
|
19ee1c9be7 | ||
|
|
fcf0bf79a9 | ||
|
|
0ece664475 | ||
|
|
509af2463d | ||
|
|
5632fef621 | ||
|
|
8ec26e8083 | ||
|
|
617b2edea0 | ||
|
|
55d074f1a5 | ||
|
|
7e6f6af463 | ||
|
|
d00cf6e1ba | ||
|
|
9fdd100f2d | ||
|
|
57d8449660 | ||
|
|
ebd4604f21 | ||
|
|
36a4d79120 | ||
|
|
f3fa63a5ae | ||
|
|
5164375b18 | ||
|
|
fec44452cb | ||
|
|
18ab38a87b | ||
|
|
0f9957bc03 | ||
|
|
80f258c3c5 | ||
|
|
90341e0e00 | ||
|
|
3298374113 | ||
|
|
227c5e155b | ||
|
|
fdbbcf2b8a | ||
|
|
0a07b0d162 | ||
|
|
07e45a20e5 | ||
|
|
14056c69e6 | ||
|
|
fb9c840c46 | ||
|
|
94165cc18f | ||
|
|
f5ecd51461 | ||
|
|
e9f906ce56 | ||
|
|
4630f07282 | ||
|
|
978acecdcf | ||
|
|
bc1f1d92e5 | ||
|
|
415cd6a360 | ||
|
|
68ce340741 | ||
|
|
bdca9fc1ce | ||
|
|
edb684c72c | ||
|
|
17f7afe12d | ||
|
|
0a182a45ba | ||
|
|
95d62e7f57 | ||
|
|
9ecc91929a | ||
|
|
f79c6aef8d | ||
|
|
c0dff6d4a6 | ||
|
|
59cfc087e1 | ||
|
|
e2f6e50df4 | ||
|
|
c2c64e207f | ||
|
|
8645aeaa4a | ||
|
|
7681e32dca | ||
|
|
b7476a9e7f | ||
|
|
5fba4a5399 | ||
|
|
c0b377050e | ||
|
|
306b8774c2 | ||
|
|
c40ab4147e | ||
|
|
48c101aa7a | ||
|
|
378f0d595f | ||
|
|
f12946d581 | ||
|
|
d13e4d2eef | ||
|
|
f3efb6441d | ||
|
|
0cf313a21e | ||
|
|
ac27e18933 | ||
|
|
e5a6ccc4d4 | ||
|
|
e42cdbe8e0 | ||
|
|
a6ba8dd68f | ||
|
|
7017a1cae5 | ||
|
|
8120278b8c | ||
|
|
73babcbfe3 | ||
|
|
45189d9517 | ||
|
|
7b84558ca1 | ||
|
|
92cfde495e | ||
|
|
14578c2257 | ||
|
|
8f6f81948e | ||
|
|
c6109c7087 | ||
|
|
8ea3855e02 | ||
|
|
74fce9640e | ||
|
|
259aa829d4 | ||
|
|
c4ec50d437 | ||
|
|
b50b7b667d | ||
|
|
fbeb2e23d4 | ||
|
|
4b60c03caa | ||
|
|
a56a28fbb7 | ||
|
|
4051d5b803 | ||
|
|
87242ce6cb | ||
|
|
72d9ffd8b4 | ||
|
|
f606711463 | ||
|
|
d1f69feb4a | ||
|
|
e4ca3bf132 | ||
|
|
7aaf866064 | ||
|
|
484342f26a | ||
|
|
42ada66fdd | ||
|
|
f732ef05d5 | ||
|
|
4fb4fe0931 | ||
|
|
06ffd8ee72 | ||
|
|
90a8070518 | ||
|
|
3e656efb00 | ||
|
|
7c39dd5cba | ||
|
|
21ccfa97dd | ||
|
|
bf0262d7d1 | ||
|
|
42b9700673 | ||
|
|
42bd07d733 | ||
|
|
6f1c54d018 | ||
|
|
1930af91ce | ||
|
|
e088d09e47 | ||
|
|
209fa04752 | ||
|
|
f41c02cbd7 | ||
|
|
4dc75bad05 | ||
|
|
a3d0f7478f | ||
|
|
b9b5003239 | ||
|
|
2e8d6ce7d9 | ||
|
|
26aadffb20 | ||
|
|
a5f48e3202 | ||
|
|
a58102d6ef | ||
|
|
65453bd94e | ||
|
|
d22413b931 | ||
|
|
8b9bcc1768 | ||
|
|
51287d545b | ||
|
|
c314a60a16 | ||
|
|
9b2520aa0c | ||
|
|
346b88ae43 | ||
|
|
2766c76491 | ||
|
|
be6529d0a1 | ||
|
|
b1a3ea1aa4 | ||
|
|
6646dcc24d | ||
|
|
966ff91386 | ||
|
|
cd84d08157 | ||
|
|
93c677a6a9 | ||
|
|
177cfd72bf | ||
|
|
34ade50181 | ||
|
|
e65655594f | ||
|
|
514db60617 | ||
|
|
8bc6e75319 | ||
|
|
2f74cfb42c | ||
|
|
1302e3c959 | ||
|
|
a5b031f906 | ||
|
|
f583354748 | ||
|
|
d12e8ec923 | ||
|
|
89f84c9a95 | ||
|
|
6103a22feb | ||
|
|
42264f402d | ||
|
|
abda9bc00a | ||
|
|
eec639d84e | ||
|
|
56b9107c6b | ||
|
|
b35b62d59f | ||
|
|
1b9310e766 | ||
|
|
a62d8381be | ||
|
|
8b32e6c15a | ||
|
|
c8ccb2bac7 | ||
|
|
ef3de1050f | ||
|
|
2add15bd72 | ||
|
|
e6edd9340e | ||
|
|
654a7a5d03 | ||
|
|
dba8ab947f | ||
|
|
787e06e3d8 | ||
|
|
ccd486f2a9 | ||
|
|
22d078b47f | ||
|
|
03490d6597 | ||
|
|
5f46d71af0 | ||
|
|
4f890c431c | ||
|
|
c110a97d8a | ||
|
|
6872eb802c | ||
|
|
662110c269 | ||
|
|
5083188ed8 | ||
|
|
2036438203 | ||
|
|
476c2be5a6 | ||
|
|
ced66f1671 | ||
|
|
fb49371c6b | ||
|
|
fd07aa0f05 | ||
|
|
16518a4f89 | ||
|
|
bed2c29a33 | ||
|
|
e5b6d28bca | ||
|
|
1c9afcb84e | ||
|
|
b0dda6e6a7 | ||
|
|
d4025d95e7 | ||
|
|
3a058a6e34 | ||
|
|
aac7d564c8 | ||
|
|
9aa3442a17 | ||
|
|
c68d154f0f | ||
|
|
1b4ed69f41 | ||
|
|
8cef998f49 | ||
|
|
90d1223acd | ||
|
|
1f2506221a | ||
|
|
9f68ca5358 | ||
|
|
1ebb0f8c93 | ||
|
|
8a13a9df80 | ||
|
|
ddf5f2543c | ||
|
|
dbb2fe3e59 | ||
|
|
aa1fac62d5 | ||
|
|
111a313d51 | ||
|
|
0039f893cc | ||
|
|
ad6b26ba97 | ||
|
|
1ef4044419 | ||
|
|
accf2565a0 | ||
|
|
ec965f28c0 | ||
|
|
ebf95f637a | ||
|
|
abbfd42a6c | ||
|
|
db4208a7eb | ||
|
|
da54e1d87c | ||
|
|
e8532ef4de | ||
|
|
fa6d66db49 | ||
|
|
6604e7365f | ||
|
|
fcc1c2968d | ||
|
|
b3d3b14f79 | ||
|
|
8939f310db | ||
|
|
efec752985 | ||
|
|
e94ad78ea7 | ||
|
|
a27a325af7 | ||
|
|
6b06d490c5 | ||
|
|
13f8f39dd5 | ||
|
|
fe05cff64f | ||
|
|
d86837ac07 | ||
|
|
9a7edc6e52 | ||
|
|
ce8c9dd079 | ||
|
|
c8f6b7e0d6 | ||
|
|
f284d31861 | ||
|
|
76b0d2d5d8 | ||
|
|
2cab778f19 | ||
|
|
c31f8eb2e0 | ||
|
|
b618287585 | ||
|
|
63f4b42453 | ||
|
|
c7c0df0964 | ||
|
|
fb87fb5750 | ||
|
|
634b0aaa07 | ||
|
|
5002a89754 | ||
|
|
b367490edc | ||
|
|
e145f21512 | ||
|
|
ea4c50c2c2 | ||
|
|
d6021f4d22 | ||
|
|
b9a3290731 | ||
|
|
47ac0d5c3e | ||
|
|
75f225d6dc | ||
|
|
adb7bf7016 | ||
|
|
897bb338f9 | ||
|
|
767699a066 | ||
|
|
7161f22706 | ||
|
|
ddec8097b7 | ||
|
|
95c3cc5c00 | ||
|
|
60c53705ca | ||
|
|
51d8044a54 | ||
|
|
ce697ab0f5 | ||
|
|
ca310966b2 | ||
|
|
25f92ce584 | ||
|
|
2c96af9aea | ||
|
|
04c7e680fd | ||
|
|
9b0ef85f77 | ||
|
|
a8f1160743 | ||
|
|
feca1f0502 | ||
|
|
d0a5a5ef37 | ||
|
|
97f570a4ee | ||
|
|
9ebbf7ce94 | ||
|
|
c2ecbf071f | ||
|
|
b1c489090e | ||
|
|
c9a03c5b01 | ||
|
|
517c578a5f | ||
|
|
14837e34fb | ||
|
|
f10ec3271a | ||
|
|
4e2820d6e3 | ||
|
|
72a0e081ca | ||
|
|
b1130cb1c3 | ||
|
|
59936631ec | ||
|
|
3af22ce754 | ||
|
|
5546b8ff43 | ||
|
|
a07092b7e6 | ||
|
|
ac01c62e6e | ||
|
|
f47f7dd9d2 | ||
|
|
13d970c7ce | ||
|
|
e2409a5fab | ||
|
|
e30aae3399 | ||
|
|
b81f2b52d0 | ||
|
|
9e43e03db4 | ||
|
|
a475cf68bf | ||
|
|
e889bc680b | ||
|
|
48f235ea5a | ||
|
|
047771b9f4 | ||
|
|
c096b20d9c | ||
|
|
11a7ccc37e | ||
|
|
d9b9e6c0b1 | ||
|
|
f18d42f08e | ||
|
|
4986f008b9 | ||
|
|
a8ce199e0d | ||
|
|
c77e8730d6 | ||
|
|
3406846c82 | ||
|
|
bddc6ae66b | ||
|
|
5c343638b6 | ||
|
|
0722960260 | ||
|
|
e959c468f6 | ||
|
|
ba871ec46a | ||
|
|
bd6e3c022f | ||
|
|
a74e04141c | ||
|
|
7c504a10a8 | ||
|
|
ae98745439 | ||
|
|
57259aee00 | ||
|
|
8759fff116 | ||
|
|
dc1a40ea74 | ||
|
|
483d9bf26c | ||
|
|
b24d60e98d | ||
|
|
0f8bd869d8 | ||
|
|
49546cd627 | ||
|
|
6e852d2e65 | ||
|
|
5a4f595341 | ||
|
|
6019d2ee14 | ||
|
|
b5375114d3 | ||
|
|
fc13e56cea | ||
|
|
f937bf3abb | ||
|
|
586e8963a8 | ||
|
|
bdfa76ed9a | ||
|
|
d133f904d3 | ||
|
|
69af9e0dbd | ||
|
|
72c5141dec | ||
|
|
5651d2c43d | ||
|
|
fc236f930b | ||
|
|
570af500f4 | ||
|
|
38913288d8 | ||
|
|
c14d7d9509 | ||
|
|
79f5be4170 |
@@ -334,6 +334,11 @@ EXPORT_PAGE_SIZE=a4
|
||||
# Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
|
||||
EXPORT_PDF_COMMAND=false
|
||||
|
||||
# Export PDF Command Timeout
|
||||
# The number of seconds that the export PDF command will run before a timeout occurs.
|
||||
# Only applies for the EXPORT_PDF_COMMAND option, not for DomPDF or wkhtmltopdf.
|
||||
EXPORT_PDF_COMMAND_TIMEOUT=15
|
||||
|
||||
# Set path to wkhtmltopdf binary for PDF generation.
|
||||
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
|
||||
# When false, BookStack will attempt to find a wkhtmltopdf in the application
|
||||
|
||||
45
.github/translators.txt
vendored
45
.github/translators.txt
vendored
@@ -141,7 +141,7 @@ Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
|
||||
MatthieuParis :: French
|
||||
Douradinho :: Portuguese, Brazilian; Portuguese
|
||||
Gaku Yaguchi (tama11) :: Japanese
|
||||
johnroyer :: Chinese Traditional
|
||||
Zero Huang (johnroyer) :: Chinese Traditional
|
||||
jackaaa :: Chinese Traditional
|
||||
Irfan Hukama Arsyad (IrfanArsyad) :: Indonesian
|
||||
Jeff Huang (s8321414) :: Chinese Traditional
|
||||
@@ -347,7 +347,7 @@ Taygun Yıldırım (yildirimtaygun) :: Turkish
|
||||
robing29 :: German
|
||||
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
|
||||
Igor V Belousov (biv) :: Russian
|
||||
David Bauer (davbauer) :: German
|
||||
David Bauer (davbauer) :: German; German Informal
|
||||
Guttorm Hveem (guttormhveem) :: Norwegian Nynorsk; Norwegian Bokmal
|
||||
Minh Giang Truong (minhgiang1204) :: Vietnamese
|
||||
Ioannis Ioannides (i.ioannides) :: Greek
|
||||
@@ -389,7 +389,7 @@ Marc Hagen (MarcHagen) :: Dutch
|
||||
Kasper Alsøe (zeonos) :: Danish
|
||||
sultani :: Persian
|
||||
renge :: Korean
|
||||
Tim (thegatesdev) :: Dutch; German Informal; Romanian; French; Catalan; Czech; Danish; German; Finnish; Hungarian; Italian; Japanese; Korean; Polish; Russian; Ukrainian; Chinese Simplified; Chinese Traditional; Portuguese, Brazilian; Persian; Spanish, Argentina; Croatian; Norwegian Nynorsk; Estonian; Uzbek; Norwegian Bokmal
|
||||
Tim (thegatesdev) :: Dutch; German Informal; French; Romanian; Catalan; Czech; Danish; German; Finnish; Hungarian; Italian; Japanese; Korean; Polish; Russian; Ukrainian; Chinese Simplified; Chinese Traditional; Portuguese, Brazilian; Persian; Spanish, Argentina; Croatian; Norwegian Nynorsk; Estonian; Uzbek; Norwegian Bokmal
|
||||
Irdi (irdiOL) :: Albanian
|
||||
KateBarber :: Welsh
|
||||
Twister (theuncles75) :: Hebrew
|
||||
@@ -422,3 +422,42 @@ crow_ :: Latvian
|
||||
JocelynDelalande :: French
|
||||
Jan (JW-CH) :: German Informal
|
||||
Timo B (lommes) :: German Informal
|
||||
Erik Lundstedt (Erik.Lundstedt) :: Swedish
|
||||
yngams (younessmouhid) :: Arabic
|
||||
Ohadp :: Hebrew
|
||||
cbridi :: Portuguese, Brazilian
|
||||
nanangsb :: Indonesian
|
||||
Michal Melich (michalmelich) :: Czech
|
||||
David (david-prv) :: German; German Informal
|
||||
Larry (lahoje) :: Swedish
|
||||
Marcia dos Santos (marciab80) :: Portuguese
|
||||
Ricard López Torres (richilpez.torres) :: Catalan
|
||||
sarahalves7 :: Portuguese, Brazilian
|
||||
petr.husak :: Czech
|
||||
javadataherian :: Persian
|
||||
Ludo-code :: French
|
||||
hollsten :: Swedish
|
||||
Ngoc Lan Phung (lanpncz) :: Vietnamese
|
||||
Worive :: Catalan
|
||||
Илья Скаба (skabailya) :: Russian
|
||||
Irjan Olsen (Irch) :: Norwegian Bokmal
|
||||
Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic)
|
||||
Red (RedVortex) :: Hebrew
|
||||
xgrug :: Chinese Simplified
|
||||
HrCalmar :: Danish
|
||||
Avishay Rapp (AvishayRapp) :: Hebrew
|
||||
matthias4217 :: French
|
||||
Berke BOYLU2 (berkeboylu2) :: Turkish
|
||||
etwas7B :: German
|
||||
Mohammed srhiri (m.sghiri20) :: Arabic
|
||||
YongMin Kim (kym0118) :: Korean
|
||||
Rivo Zängov (Eraser) :: Estonian
|
||||
Francisco Rafael Fonseca (chicoraf) :: Portuguese, Brazilian
|
||||
ИEØ_ΙΙØZ (NEO_IIOZ) :: Chinese Traditional
|
||||
madnjpn (madnjpn.) :: Georgian
|
||||
Ásgeir Shiny Ásgeirsson (AsgeirShiny) :: Icelandic
|
||||
Mohammad Aftab Uddin (chirohorit) :: Bengali
|
||||
Yannis Karlaftis (meliseus) :: Greek
|
||||
felixxx :: German Informal
|
||||
randi (randi65535) :: Korean
|
||||
test65428 :: Greek
|
||||
|
||||
4
.github/workflows/analyse-php.yml
vendored
4
.github/workflows/analyse-php.yml
vendored
@@ -11,9 +11,9 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
|
||||
4
.github/workflows/lint-js.yml
vendored
4
.github/workflows/lint-js.yml
vendored
@@ -13,9 +13,9 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install NPM deps
|
||||
run: npm ci
|
||||
|
||||
6
.github/workflows/lint-php.yml
vendored
6
.github/workflows/lint-php.yml
vendored
@@ -11,14 +11,14 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.1
|
||||
php-version: 8.3
|
||||
tools: phpcs
|
||||
|
||||
- name: Run formatting check
|
||||
|
||||
29
.github/workflows/test-js.yml
vendored
Normal file
29
.github/workflows/test-js.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: test-js
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.ts'
|
||||
- '**.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.js'
|
||||
- '**.ts'
|
||||
- '**.json'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install NPM deps
|
||||
run: npm ci
|
||||
|
||||
- name: Run TypeScript type checking
|
||||
run: npm run ts:lint
|
||||
|
||||
- name: Run JavaScript tests
|
||||
run: npm run test
|
||||
6
.github/workflows/test-migrations.yml
vendored
6
.github/workflows/test-migrations.yml
vendored
@@ -13,12 +13,12 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
php: ['8.1', '8.2', '8.3', '8.4']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
|
||||
4
.github/workflows/test-php.yml
vendored
4
.github/workflows/test-php.yml
vendored
@@ -16,9 +16,9 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['8.1', '8.2', '8.3']
|
||||
php: ['8.1', '8.2', '8.3', '8.4']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
/node_modules
|
||||
/.vscode
|
||||
/composer
|
||||
/coverage
|
||||
Homestead.yaml
|
||||
.env
|
||||
.idea
|
||||
|
||||
@@ -32,13 +32,17 @@ class ConfirmEmailController extends Controller
|
||||
|
||||
/**
|
||||
* Shows a notice that a user's email address has not been confirmed,
|
||||
* Also has the option to re-send the confirmation email.
|
||||
* along with the option to re-send the confirmation email.
|
||||
*/
|
||||
public function showAwaiting()
|
||||
{
|
||||
$user = $this->loginService->getLastLoginAttemptUser();
|
||||
if ($user === null) {
|
||||
$this->showErrorNotification(trans('errors.login_user_not_found'));
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
return view('auth.user-unconfirmed', ['user' => $user]);
|
||||
return view('auth.register-confirm-awaiting');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,19 +94,24 @@ class ConfirmEmailController extends Controller
|
||||
/**
|
||||
* Resend the confirmation email.
|
||||
*/
|
||||
public function resend(Request $request)
|
||||
public function resend()
|
||||
{
|
||||
$this->validate($request, [
|
||||
'email' => ['required', 'email', 'exists:users,email'],
|
||||
]);
|
||||
$user = $this->userRepo->getByEmail($request->get('email'));
|
||||
$user = $this->loginService->getLastLoginAttemptUser();
|
||||
if ($user === null) {
|
||||
$this->showErrorNotification(trans('errors.login_user_not_found'));
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->emailConfirmationService->sendConfirmation($user);
|
||||
} catch (ConfirmationEmailException $e) {
|
||||
$this->showErrorNotification($e->getMessage());
|
||||
|
||||
return redirect('/login');
|
||||
} catch (Exception $e) {
|
||||
$this->showErrorNotification(trans('auth.email_confirm_send_error'));
|
||||
|
||||
return redirect('/register/confirm');
|
||||
return redirect('/register/awaiting');
|
||||
}
|
||||
|
||||
$this->showSuccessNotification(trans('auth.email_confirm_resent'));
|
||||
|
||||
@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType;
|
||||
use BookStack\Http\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Sleep;
|
||||
|
||||
class ForgotPasswordController extends Controller
|
||||
{
|
||||
@@ -32,6 +33,10 @@ class ForgotPasswordController extends Controller
|
||||
'email' => ['required', 'email'],
|
||||
]);
|
||||
|
||||
// Add random pause to the response to help avoid time-base sniffing
|
||||
// of valid resets via slower email send handling.
|
||||
Sleep::for(random_int(1000, 3000))->milliseconds();
|
||||
|
||||
// We will send the password reset link to this user. Once we have attempted
|
||||
// to send the link, we will examine the response then see the message we
|
||||
// need to show to the user. Finally, we'll send out a proper response.
|
||||
|
||||
@@ -17,7 +17,7 @@ trait HandlesPartialLogins
|
||||
$user = auth()->user() ?? $loginService->getLastLoginAttemptUser();
|
||||
|
||||
if (!$user) {
|
||||
throw new NotFoundException('A user for this action could not be found');
|
||||
throw new NotFoundException(trans('errors.login_user_not_found'));
|
||||
}
|
||||
|
||||
return $user;
|
||||
|
||||
@@ -15,14 +15,11 @@ use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
|
||||
class ResetPasswordController extends Controller
|
||||
{
|
||||
protected LoginService $loginService;
|
||||
|
||||
public function __construct(LoginService $loginService)
|
||||
{
|
||||
public function __construct(
|
||||
protected LoginService $loginService
|
||||
) {
|
||||
$this->middleware('guest');
|
||||
$this->middleware('guard:standard');
|
||||
|
||||
$this->loginService = $loginService;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,7 @@ class EmailConfirmationService extends UserTokenService
|
||||
*
|
||||
* @throws ConfirmationEmailException
|
||||
*/
|
||||
public function sendConfirmation(User $user)
|
||||
public function sendConfirmation(User $user): void
|
||||
{
|
||||
if ($user->email_confirmed) {
|
||||
throw new ConfirmationEmailException(trans('errors.email_already_confirmed'), '/login');
|
||||
|
||||
@@ -52,13 +52,25 @@ class Ldap
|
||||
*
|
||||
* @param resource|\LDAP\Connection $ldapConnection
|
||||
*
|
||||
* @return resource|\LDAP\Result
|
||||
* @return \LDAP\Result|array|false
|
||||
*/
|
||||
public function search($ldapConnection, string $baseDn, string $filter, array $attributes = null)
|
||||
{
|
||||
return ldap_search($ldapConnection, $baseDn, $filter, $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an entry from the LDAP tree.
|
||||
*
|
||||
* @param resource|\Ldap\Connection $ldapConnection
|
||||
*
|
||||
* @return \LDAP\Result|array|false
|
||||
*/
|
||||
public function read($ldapConnection, string $baseDn, string $filter, array $attributes = null)
|
||||
{
|
||||
return ldap_read($ldapConnection, $baseDn, $filter, $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entries from an LDAP search result.
|
||||
*
|
||||
|
||||
@@ -71,6 +71,26 @@ class LdapService
|
||||
return $users[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the user display name from the (potentially multiple) attributes defined by the configuration.
|
||||
*/
|
||||
protected function getUserDisplayName(array $userDetails, array $displayNameAttrs, string $defaultValue): string
|
||||
{
|
||||
$displayNameParts = [];
|
||||
foreach ($displayNameAttrs as $dnAttr) {
|
||||
$dnComponent = $this->getUserResponseProperty($userDetails, $dnAttr, null);
|
||||
if ($dnComponent) {
|
||||
$displayNameParts[] = $dnComponent;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($displayNameParts)) {
|
||||
return $defaultValue;
|
||||
}
|
||||
|
||||
return implode(' ', $displayNameParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the details of a user from LDAP using the given username.
|
||||
* User found via configurable user filter.
|
||||
@@ -81,11 +101,11 @@ class LdapService
|
||||
{
|
||||
$idAttr = $this->config['id_attribute'];
|
||||
$emailAttr = $this->config['email_attribute'];
|
||||
$displayNameAttr = $this->config['display_name_attribute'];
|
||||
$displayNameAttrs = explode('|', $this->config['display_name_attribute']);
|
||||
$thumbnailAttr = $this->config['thumbnail_attribute'];
|
||||
|
||||
$user = $this->getUserWithAttributes($userName, array_filter([
|
||||
'cn', 'dn', $idAttr, $emailAttr, $displayNameAttr, $thumbnailAttr,
|
||||
'cn', 'dn', $idAttr, $emailAttr, ...$displayNameAttrs, $thumbnailAttr,
|
||||
]));
|
||||
|
||||
if (is_null($user)) {
|
||||
@@ -95,7 +115,7 @@ class LdapService
|
||||
$userCn = $this->getUserResponseProperty($user, 'cn', null);
|
||||
$formatted = [
|
||||
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
|
||||
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
|
||||
'name' => $this->getUserDisplayName($user, $displayNameAttrs, $userCn),
|
||||
'dn' => $user['dn'],
|
||||
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
|
||||
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
|
||||
@@ -321,94 +341,105 @@ class LdapService
|
||||
return [];
|
||||
}
|
||||
|
||||
$userGroups = $this->groupFilter($user);
|
||||
$userGroups = $this->extractGroupsFromSearchResponseEntry($user);
|
||||
$allGroups = $this->getGroupsRecursive($userGroups, []);
|
||||
$formattedGroups = $this->extractGroupNamesFromLdapGroupDns($allGroups);
|
||||
|
||||
if ($this->config['dump_user_groups']) {
|
||||
throw new JsonDebugException([
|
||||
'details_from_ldap' => $user,
|
||||
'parsed_direct_user_groups' => $userGroups,
|
||||
'parsed_recursive_user_groups' => $allGroups,
|
||||
'details_from_ldap' => $user,
|
||||
'parsed_direct_user_groups' => $userGroups,
|
||||
'parsed_recursive_user_groups' => $allGroups,
|
||||
'parsed_resulting_group_names' => $formattedGroups,
|
||||
]);
|
||||
}
|
||||
|
||||
return $allGroups;
|
||||
return $formattedGroups;
|
||||
}
|
||||
|
||||
protected function extractGroupNamesFromLdapGroupDns(array $groupDNs): array
|
||||
{
|
||||
$names = [];
|
||||
|
||||
foreach ($groupDNs as $groupDN) {
|
||||
$exploded = $this->ldap->explodeDn($groupDN, 1);
|
||||
if ($exploded !== false && count($exploded) > 0) {
|
||||
$names[] = $exploded[0];
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($names);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent groups of an array of groups.
|
||||
* Build an array of all relevant groups DNs after recursively scanning
|
||||
* across parents of the groups given.
|
||||
*
|
||||
* @throws LdapException
|
||||
*/
|
||||
private function getGroupsRecursive(array $groupsArray, array $checked): array
|
||||
protected function getGroupsRecursive(array $groupDNs, array $checked): array
|
||||
{
|
||||
$groupsToAdd = [];
|
||||
foreach ($groupsArray as $groupName) {
|
||||
if (in_array($groupName, $checked)) {
|
||||
foreach ($groupDNs as $groupDN) {
|
||||
if (in_array($groupDN, $checked)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parentGroups = $this->getGroupGroups($groupName);
|
||||
$parentGroups = $this->getParentsOfGroup($groupDN);
|
||||
$groupsToAdd = array_merge($groupsToAdd, $parentGroups);
|
||||
$checked[] = $groupName;
|
||||
$checked[] = $groupDN;
|
||||
}
|
||||
|
||||
$groupsArray = array_unique(array_merge($groupsArray, $groupsToAdd), SORT_REGULAR);
|
||||
$uniqueDNs = array_unique(array_merge($groupDNs, $groupsToAdd), SORT_REGULAR);
|
||||
|
||||
if (empty($groupsToAdd)) {
|
||||
return $groupsArray;
|
||||
return $uniqueDNs;
|
||||
}
|
||||
|
||||
return $this->getGroupsRecursive($groupsArray, $checked);
|
||||
return $this->getGroupsRecursive($uniqueDNs, $checked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent groups of a single group.
|
||||
*
|
||||
* @throws LdapException
|
||||
*/
|
||||
private function getGroupGroups(string $groupName): array
|
||||
protected function getParentsOfGroup(string $groupDN): array
|
||||
{
|
||||
$groupsAttr = strtolower($this->config['group_attribute']);
|
||||
$ldapConnection = $this->getConnection();
|
||||
$this->bindSystemUser($ldapConnection);
|
||||
|
||||
$followReferrals = $this->config['follow_referrals'] ? 1 : 0;
|
||||
$this->ldap->setOption($ldapConnection, LDAP_OPT_REFERRALS, $followReferrals);
|
||||
|
||||
$baseDn = $this->config['base_dn'];
|
||||
$groupsAttr = strtolower($this->config['group_attribute']);
|
||||
|
||||
$groupFilter = 'CN=' . $this->ldap->escape($groupName);
|
||||
$groups = $this->ldap->searchAndGetEntries($ldapConnection, $baseDn, $groupFilter, [$groupsAttr]);
|
||||
if ($groups['count'] === 0) {
|
||||
$read = $this->ldap->read($ldapConnection, $groupDN, '(objectClass=*)', [$groupsAttr]);
|
||||
$results = $this->ldap->getEntries($ldapConnection, $read);
|
||||
if ($results['count'] === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->groupFilter($groups[0]);
|
||||
return $this->extractGroupsFromSearchResponseEntry($results[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out LDAP CN and DN language in a ldap search return.
|
||||
* Gets the base CN (common name) of the string.
|
||||
* Extract an array of group DN values from the given LDAP search response entry
|
||||
*/
|
||||
protected function groupFilter(array $userGroupSearchResponse): array
|
||||
protected function extractGroupsFromSearchResponseEntry(array $ldapEntry): array
|
||||
{
|
||||
$groupsAttr = strtolower($this->config['group_attribute']);
|
||||
$ldapGroups = [];
|
||||
$groupDNs = [];
|
||||
$count = 0;
|
||||
|
||||
if (isset($userGroupSearchResponse[$groupsAttr]['count'])) {
|
||||
$count = (int) $userGroupSearchResponse[$groupsAttr]['count'];
|
||||
if (isset($ldapEntry[$groupsAttr]['count'])) {
|
||||
$count = (int) $ldapEntry[$groupsAttr]['count'];
|
||||
}
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$dnComponents = $this->ldap->explodeDn($userGroupSearchResponse[$groupsAttr][$i], 1);
|
||||
if (!in_array($dnComponents[0], $ldapGroups)) {
|
||||
$ldapGroups[] = $dnComponents[0];
|
||||
$dn = $ldapEntry[$groupsAttr][$i];
|
||||
if (!in_array($dn, $groupDNs)) {
|
||||
$groupDNs[] = $dn;
|
||||
}
|
||||
}
|
||||
|
||||
return $ldapGroups;
|
||||
return $groupDNs;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace BookStack\Access;
|
||||
use BookStack\Access\Mfa\MfaSession;
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Exceptions\LoginAttemptInvalidUserException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Facades\Theme;
|
||||
@@ -29,10 +30,14 @@ class LoginService
|
||||
* a reason to (MFA or Unconfirmed Email).
|
||||
* Returns a boolean to indicate the current login result.
|
||||
*
|
||||
* @throws StoppedAuthenticationException
|
||||
* @throws StoppedAuthenticationException|LoginAttemptInvalidUserException
|
||||
*/
|
||||
public function login(User $user, string $method, bool $remember = false): void
|
||||
{
|
||||
if ($user->isGuest()) {
|
||||
throw new LoginAttemptInvalidUserException('Login not allowed for guest user');
|
||||
}
|
||||
|
||||
if ($this->awaitingEmailConfirmation($user) || $this->needsMfaVerification($user)) {
|
||||
$this->setLastLoginAttemptedForUser($user, $method, $remember);
|
||||
|
||||
@@ -58,7 +63,7 @@ class LoginService
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function reattemptLoginFor(User $user)
|
||||
public function reattemptLoginFor(User $user): void
|
||||
{
|
||||
if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) {
|
||||
throw new Exception('Login reattempt user does align with current session state');
|
||||
@@ -152,16 +157,40 @@ class LoginService
|
||||
*/
|
||||
public function attempt(array $credentials, string $method, bool $remember = false): bool
|
||||
{
|
||||
if ($this->areCredentialsForGuest($credentials)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = auth()->attempt($credentials, $remember);
|
||||
if ($result) {
|
||||
$user = auth()->user();
|
||||
auth()->logout();
|
||||
$this->login($user, $method, $remember);
|
||||
try {
|
||||
$this->login($user, $method, $remember);
|
||||
} catch (LoginAttemptInvalidUserException $e) {
|
||||
// Catch and return false for non-login accounts
|
||||
// so it looks like a normal invalid login.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given credentials are likely for the system guest account.
|
||||
*/
|
||||
protected function areCredentialsForGuest(array $credentials): bool
|
||||
{
|
||||
if (isset($credentials['email'])) {
|
||||
return User::query()->where('email', '=', $credentials['email'])
|
||||
->where('system_name', '=', 'public')
|
||||
->exists();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs the current user out of the application.
|
||||
* Returns an app post-redirect path.
|
||||
|
||||
@@ -22,7 +22,7 @@ class OidcUserDetails
|
||||
$hasEmpty = empty($this->externalId)
|
||||
|| empty($this->email)
|
||||
|| empty($this->name)
|
||||
|| ($groupSyncActive && empty($this->groups));
|
||||
|| ($groupSyncActive && $this->groups === null);
|
||||
|
||||
return !$hasEmpty;
|
||||
}
|
||||
@@ -57,15 +57,15 @@ class OidcUserDetails
|
||||
return implode(' ', $displayName);
|
||||
}
|
||||
|
||||
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $token): array
|
||||
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $token): ?array
|
||||
{
|
||||
if (empty($groupsClaim)) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
$groupsList = Arr::get($token->getAllClaims(), $groupsClaim);
|
||||
if (!is_array($groupsList)) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
|
||||
return array_values(array_filter($groupsList, function ($val) {
|
||||
|
||||
@@ -11,7 +11,9 @@ class OidcUserinfoResponse implements ProvidesClaims
|
||||
|
||||
public function __construct(ResponseInterface $response, string $issuer, array $keys)
|
||||
{
|
||||
$contentType = $response->getHeader('Content-Type')[0];
|
||||
$contentTypeHeaderValue = $response->getHeader('Content-Type')[0] ?? '';
|
||||
$contentType = strtolower(trim(explode(';', $contentTypeHeaderValue, 2)[0]));
|
||||
|
||||
if ($contentType === 'application/json') {
|
||||
$this->claims = json_decode($response->getBody()->getContents(), true);
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ class Saml2Service
|
||||
// value so that the exact encoding format is matched when checking the signature.
|
||||
// This is primarily due to ADFS encoding query params with lowercase percent encoding while
|
||||
// PHP (And most other sensible providers) standardise on uppercase.
|
||||
/** @var ?string $samlRedirect */
|
||||
$samlRedirect = $toolkit->processSLO(true, $requestId, true, null, true);
|
||||
$errors = $toolkit->getErrors();
|
||||
|
||||
|
||||
10
app/Access/UserInviteException.php
Normal file
10
app/Access/UserInviteException.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Access;
|
||||
|
||||
use Exception;
|
||||
|
||||
class UserInviteException extends Exception
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -13,11 +13,17 @@ class UserInviteService extends UserTokenService
|
||||
/**
|
||||
* Send an invitation to a user to sign into BookStack
|
||||
* Removes existing invitation tokens.
|
||||
* @throws UserInviteException
|
||||
*/
|
||||
public function sendInvitation(User $user)
|
||||
{
|
||||
$this->deleteByUser($user);
|
||||
$token = $this->createTokenForUser($user);
|
||||
$user->notify(new UserInviteNotification($token));
|
||||
|
||||
try {
|
||||
$user->notify(new UserInviteNotification($token));
|
||||
} catch (\Exception $exception) {
|
||||
throw new UserInviteException($exception->getMessage(), $exception->getCode(), $exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ class ActivityType
|
||||
const WEBHOOK_UPDATE = 'webhook_update';
|
||||
const WEBHOOK_DELETE = 'webhook_delete';
|
||||
|
||||
const IMPORT_CREATE = 'import_create';
|
||||
const IMPORT_RUN = 'import_run';
|
||||
const IMPORT_DELETE = 'import_delete';
|
||||
|
||||
/**
|
||||
* Get all the possible values.
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ use BookStack\Activity\Notifications\Messages\BaseActivityNotification;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
abstract class BaseNotificationHandler implements NotificationHandler
|
||||
{
|
||||
@@ -36,7 +37,11 @@ abstract class BaseNotificationHandler implements NotificationHandler
|
||||
}
|
||||
|
||||
// Send the notification
|
||||
$user->notify(new $notification($detail, $initiator));
|
||||
try {
|
||||
$user->notify(new $notification($detail, $initiator));
|
||||
} catch (\Exception $exception) {
|
||||
Log::error("Failed to send email notification to user [id:{$user->id}] with error: {$exception->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ abstract class BaseActivityNotification extends MailNotification
|
||||
protected function buildReasonFooterLine(LocaleDefinition $locale): LinkedMailMessageLine
|
||||
{
|
||||
return new LinkedMailMessageLine(
|
||||
url('/preferences/notifications'),
|
||||
url('/my-account/notifications'),
|
||||
$locale->trans('notifications.footer_reason'),
|
||||
$locale->trans('notifications.footer_reason_link'),
|
||||
);
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
|
||||
class ApiEntityListFormatter
|
||||
{
|
||||
@@ -20,8 +22,16 @@ class ApiEntityListFormatter
|
||||
* @var array<string|int, string|callable>
|
||||
*/
|
||||
protected array $fields = [
|
||||
'id', 'name', 'slug', 'book_id', 'chapter_id', 'draft',
|
||||
'template', 'priority', 'created_at', 'updated_at',
|
||||
'id',
|
||||
'name',
|
||||
'slug',
|
||||
'book_id',
|
||||
'chapter_id',
|
||||
'draft',
|
||||
'template',
|
||||
'priority',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
public function __construct(array $list)
|
||||
@@ -62,6 +72,28 @@ class ApiEntityListFormatter
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Include parent book/chapter info in the formatted data.
|
||||
*/
|
||||
public function withParents(): self
|
||||
{
|
||||
$this->withField('book', function (Entity $entity) {
|
||||
if ($entity instanceof BookChild && $entity->book) {
|
||||
return $entity->book->only(['id', 'name', 'slug']);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
$this->withField('chapter', function (Entity $entity) {
|
||||
if ($entity instanceof Page && $entity->chapter) {
|
||||
return $entity->chapter->only(['id', 'name', 'slug']);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the data and return an array of formatted content.
|
||||
* @return array[]
|
||||
|
||||
@@ -64,4 +64,14 @@ class MetaController extends Controller
|
||||
'jsLibData' => file_get_contents(base_path('dev/licensing/js-library-licenses.txt')),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view for /opensearch.xml.
|
||||
*/
|
||||
public function opensearch()
|
||||
{
|
||||
return response()
|
||||
->view('misc.opensearch')
|
||||
->header('Content-Type', 'application/opensearchdescription+xml');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,5 +81,9 @@ class RouteServiceProvider extends ServiceProvider
|
||||
RateLimiter::for('api', function (Request $request) {
|
||||
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('public', function (Request $request) {
|
||||
return Limit::perMinute(10)->by($request->ip());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ return [
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
'path' => storage_path('framework/cache'),
|
||||
'lock_path' => storage_path('framework/cache'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
|
||||
@@ -29,6 +29,10 @@ return [
|
||||
// Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
|
||||
'pdf_command' => env('EXPORT_PDF_COMMAND', false),
|
||||
|
||||
// The amount of time allowed for PDF generation command to run
|
||||
// before the process times out and is stopped.
|
||||
'pdf_command_timeout' => env('EXPORT_PDF_COMMAND_TIMEOUT', 15),
|
||||
|
||||
// 2024-04: Snappy/WKHTMLtoPDF now considered deprecated in regard to BookStack support.
|
||||
'snappy' => [
|
||||
'pdf_binary' => env('WKHTMLTOPDF', false),
|
||||
|
||||
@@ -49,6 +49,7 @@ class UpdateUrlCommand extends Command
|
||||
'chapters' => ['description_html'],
|
||||
'books' => ['description_html'],
|
||||
'bookshelves' => ['description_html'],
|
||||
'page_revisions' => ['html', 'text', 'markdown'],
|
||||
'images' => ['url'],
|
||||
'settings' => ['value'],
|
||||
'comments' => ['html', 'text'],
|
||||
@@ -77,6 +78,12 @@ class UpdateUrlCommand extends Command
|
||||
$this->info('URL update procedure complete.');
|
||||
$this->info('============================================================================');
|
||||
$this->info('Be sure to run "php artisan cache:clear" to clear any old URLs in the cache.');
|
||||
|
||||
if (!str_starts_with($newUrl, url('/'))) {
|
||||
$this->warn('You still need to update your APP_URL env value. This is currently set to:');
|
||||
$this->warn(url('/'));
|
||||
}
|
||||
|
||||
$this->info('============================================================================');
|
||||
|
||||
return 0;
|
||||
|
||||
@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Http\ApiController;
|
||||
@@ -18,6 +19,7 @@ class BookApiController extends ApiController
|
||||
public function __construct(
|
||||
protected BookRepo $bookRepo,
|
||||
protected BookQueries $queries,
|
||||
protected PageQueries $pageQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -28,6 +30,7 @@ class BookApiController extends ApiController
|
||||
{
|
||||
$books = $this->queries
|
||||
->visibleForList()
|
||||
->with(['cover:id,name,url'])
|
||||
->addSelect(['created_by', 'updated_by']);
|
||||
|
||||
return $this->apiListingResponse($books, [
|
||||
@@ -69,7 +72,8 @@ class BookApiController extends ApiController
|
||||
->withType()
|
||||
->withField('pages', function (Entity $entity) {
|
||||
if ($entity instanceof Chapter) {
|
||||
return (new ApiEntityListFormatter($entity->pages->all()))->format();
|
||||
$pages = $this->pageQueries->visibleForChapterList($entity->id)->get()->all();
|
||||
return (new ApiEntityListFormatter($pages))->format();
|
||||
}
|
||||
return null;
|
||||
})->format();
|
||||
|
||||
@@ -26,6 +26,7 @@ class BookshelfApiController extends ApiController
|
||||
{
|
||||
$shelves = $this->queries
|
||||
->visibleForList()
|
||||
->with(['cover:id,name,url'])
|
||||
->addSelect(['created_by', 'updated_by']);
|
||||
|
||||
return $this->apiListingResponse($shelves, [
|
||||
|
||||
@@ -60,6 +60,7 @@ class Chapter extends BookChild
|
||||
|
||||
/**
|
||||
* Get the visible pages in this chapter.
|
||||
* @returns Collection<Page>
|
||||
*/
|
||||
public function getVisiblePages(): Collection
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditorType;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Uploads\Attachment;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
@@ -11,7 +11,7 @@ use BookStack\Entities\Models\PageRevision;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditorData;
|
||||
use BookStack\Entities\Tools\PageEditorType;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
@@ -43,6 +43,7 @@ class PageRepo
|
||||
'owned_by' => user()->id,
|
||||
'updated_by' => user()->id,
|
||||
'draft' => true,
|
||||
'editor' => PageEditorType::getSystemDefault()->value,
|
||||
]);
|
||||
|
||||
if ($parent instanceof Chapter) {
|
||||
@@ -77,7 +78,8 @@ class PageRepo
|
||||
$this->updateTemplateStatusAndContentFromInput($draft, $input);
|
||||
$this->baseRepo->update($draft, $input);
|
||||
|
||||
$this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision'));
|
||||
$summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
|
||||
$this->revisionRepo->storeNewForPage($draft, $summary);
|
||||
$draft->refresh();
|
||||
|
||||
Activity::add(ActivityType::PAGE_CREATE, $draft);
|
||||
@@ -85,6 +87,17 @@ class PageRepo
|
||||
return $draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Directly update the content for the given page from the provided input.
|
||||
* Used for direct content access in a way that performs required changes
|
||||
* (Search index & reference regen) without performing an official update.
|
||||
*/
|
||||
public function setContentFromInput(Page $page, array $input): void
|
||||
{
|
||||
$this->updateTemplateStatusAndContentFromInput($page, $input);
|
||||
$this->baseRepo->update($page, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a page in the system.
|
||||
*/
|
||||
@@ -119,14 +132,16 @@ class PageRepo
|
||||
return $page;
|
||||
}
|
||||
|
||||
protected function updateTemplateStatusAndContentFromInput(Page $page, array $input)
|
||||
protected function updateTemplateStatusAndContentFromInput(Page $page, array $input): void
|
||||
{
|
||||
if (isset($input['template']) && userCan('templates-manage')) {
|
||||
$page->template = ($input['template'] === 'true');
|
||||
}
|
||||
|
||||
$pageContent = new PageContent($page);
|
||||
$currentEditor = $page->editor ?: PageEditorData::getSystemDefaultEditor();
|
||||
$defaultEditor = PageEditorType::getSystemDefault();
|
||||
$currentEditor = PageEditorType::forPage($page) ?: $defaultEditor;
|
||||
$inputEditor = PageEditorType::fromRequestValue($input['editor'] ?? '') ?? $currentEditor;
|
||||
$newEditor = $currentEditor;
|
||||
|
||||
$haveInput = isset($input['markdown']) || isset($input['html']);
|
||||
@@ -135,15 +150,17 @@ class PageRepo
|
||||
if ($haveInput && $inputEmpty) {
|
||||
$pageContent->setNewHTML('', user());
|
||||
} elseif (!empty($input['markdown']) && is_string($input['markdown'])) {
|
||||
$newEditor = 'markdown';
|
||||
$newEditor = PageEditorType::Markdown;
|
||||
$pageContent->setNewMarkdown($input['markdown'], user());
|
||||
} elseif (isset($input['html'])) {
|
||||
$newEditor = 'wysiwyg';
|
||||
$newEditor = ($inputEditor->isHtmlBased() ? $inputEditor : null) ?? ($defaultEditor->isHtmlBased() ? $defaultEditor : null) ?? PageEditorType::WysiwygTinymce;
|
||||
$pageContent->setNewHTML($input['html'], user());
|
||||
}
|
||||
|
||||
if ($newEditor !== $currentEditor && userCan('editor-change')) {
|
||||
$page->editor = $newEditor;
|
||||
if (($newEditor !== $currentEditor || empty($page->editor)) && userCan('editor-change')) {
|
||||
$page->editor = $newEditor->value;
|
||||
} elseif (empty($page->editor)) {
|
||||
$page->editor = $defaultEditor->value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,17 +18,12 @@ use Illuminate\Http\UploadedFile;
|
||||
|
||||
class Cloner
|
||||
{
|
||||
protected PageRepo $pageRepo;
|
||||
protected ChapterRepo $chapterRepo;
|
||||
protected BookRepo $bookRepo;
|
||||
protected ImageService $imageService;
|
||||
|
||||
public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService)
|
||||
{
|
||||
$this->pageRepo = $pageRepo;
|
||||
$this->chapterRepo = $chapterRepo;
|
||||
$this->bookRepo = $bookRepo;
|
||||
$this->imageService = $imageService;
|
||||
public function __construct(
|
||||
protected PageRepo $pageRepo,
|
||||
protected ChapterRepo $chapterRepo,
|
||||
protected BookRepo $bookRepo,
|
||||
protected ImageService $imageService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,17 +74,17 @@ class PageEditorData
|
||||
];
|
||||
}
|
||||
|
||||
protected function updateContentForEditor(Page $page, string $editorType): void
|
||||
protected function updateContentForEditor(Page $page, PageEditorType $editorType): void
|
||||
{
|
||||
$isHtml = !empty($page->html) && empty($page->markdown);
|
||||
|
||||
// HTML to markdown-clean conversion
|
||||
if ($editorType === 'markdown' && $isHtml && $this->requestedEditor === 'markdown-clean') {
|
||||
if ($editorType === PageEditorType::Markdown && $isHtml && $this->requestedEditor === 'markdown-clean') {
|
||||
$page->markdown = (new HtmlToMarkdown($page->html))->convert();
|
||||
}
|
||||
|
||||
// Markdown to HTML conversion if we don't have HTML
|
||||
if ($editorType === 'wysiwyg' && !$isHtml) {
|
||||
if ($editorType->isHtmlBased() && !$isHtml) {
|
||||
$page->html = (new MarkdownToHtml($page->markdown))->convert();
|
||||
}
|
||||
}
|
||||
@@ -94,24 +94,16 @@ class PageEditorData
|
||||
* Defaults based upon the current content of the page otherwise will fall back
|
||||
* to system default but will take a requested type (if provided) if permissions allow.
|
||||
*/
|
||||
protected function getEditorType(Page $page): string
|
||||
protected function getEditorType(Page $page): PageEditorType
|
||||
{
|
||||
$editorType = $page->editor ?: self::getSystemDefaultEditor();
|
||||
$editorType = PageEditorType::forPage($page) ?: PageEditorType::getSystemDefault();
|
||||
|
||||
// Use requested editor if valid and if we have permission
|
||||
$requestedType = explode('-', $this->requestedEditor)[0];
|
||||
if (($requestedType === 'markdown' || $requestedType === 'wysiwyg') && userCan('editor-change')) {
|
||||
$requestedType = PageEditorType::fromRequestValue($this->requestedEditor);
|
||||
if ($requestedType && userCan('editor-change')) {
|
||||
$editorType = $requestedType;
|
||||
}
|
||||
|
||||
return $editorType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured system default editor.
|
||||
*/
|
||||
public static function getSystemDefaultEditor(): string
|
||||
{
|
||||
return setting('app-editor') === 'markdown' ? 'markdown' : 'wysiwyg';
|
||||
}
|
||||
}
|
||||
|
||||
37
app/Entities/Tools/PageEditorType.php
Normal file
37
app/Entities/Tools/PageEditorType.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
|
||||
enum PageEditorType: string
|
||||
{
|
||||
case WysiwygTinymce = 'wysiwyg';
|
||||
case WysiwygLexical = 'wysiwyg2024';
|
||||
case Markdown = 'markdown';
|
||||
|
||||
public function isHtmlBased(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::WysiwygTinymce, self::WysiwygLexical => true,
|
||||
self::Markdown => false,
|
||||
};
|
||||
}
|
||||
|
||||
public static function fromRequestValue(string $value): static|null
|
||||
{
|
||||
$editor = explode('-', $value)[0];
|
||||
return static::tryFrom($editor);
|
||||
}
|
||||
|
||||
public static function forPage(Page $page): static|null
|
||||
{
|
||||
return static::tryFrom($page->editor);
|
||||
}
|
||||
|
||||
public static function getSystemDefault(): static
|
||||
{
|
||||
$setting = setting('app-editor');
|
||||
return static::tryFrom($setting) ?? static::WysiwygTinymce;
|
||||
}
|
||||
}
|
||||
@@ -104,10 +104,10 @@ class PageIncludeParser
|
||||
|
||||
if ($currentOffset < $tagStartOffset) {
|
||||
$previousText = substr($text, $currentOffset, $tagStartOffset - $currentOffset);
|
||||
$textNode->parentNode->insertBefore(new DOMText($previousText), $textNode);
|
||||
$textNode->parentNode->insertBefore($this->doc->createTextNode($previousText), $textNode);
|
||||
}
|
||||
|
||||
$node = $textNode->parentNode->insertBefore(new DOMText($tagOuterContent), $textNode);
|
||||
$node = $textNode->parentNode->insertBefore($this->doc->createTextNode($tagOuterContent), $textNode);
|
||||
$includeTags[] = new PageIncludeTag($tagInnerContent, $node);
|
||||
$currentOffset = $tagStartOffset + strlen($tagOuterContent);
|
||||
}
|
||||
|
||||
7
app/Exceptions/LoginAttemptInvalidUserException.php
Normal file
7
app/Exceptions/LoginAttemptInvalidUserException.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
class LoginAttemptInvalidUserException extends LoginAttemptException
|
||||
{
|
||||
}
|
||||
@@ -9,16 +9,10 @@ use Illuminate\Http\Request;
|
||||
|
||||
class StoppedAuthenticationException extends \Exception implements Responsable
|
||||
{
|
||||
protected $user;
|
||||
protected $loginService;
|
||||
|
||||
/**
|
||||
* StoppedAuthenticationException constructor.
|
||||
*/
|
||||
public function __construct(User $user, LoginService $loginService)
|
||||
{
|
||||
$this->user = $user;
|
||||
$this->loginService = $loginService;
|
||||
public function __construct(
|
||||
protected User $user,
|
||||
protected LoginService $loginService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
|
||||
7
app/Exceptions/ZipExportException.php
Normal file
7
app/Exceptions/ZipExportException.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
class ZipExportException extends \Exception
|
||||
{
|
||||
}
|
||||
13
app/Exceptions/ZipImportException.php
Normal file
13
app/Exceptions/ZipImportException.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
class ZipImportException extends \Exception
|
||||
{
|
||||
public function __construct(
|
||||
public array $errors
|
||||
) {
|
||||
$message = "Import failed with errors:" . implode("\n", $this->errors);
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
12
app/Exceptions/ZipValidationException.php
Normal file
12
app/Exceptions/ZipValidationException.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exceptions;
|
||||
|
||||
class ZipValidationException extends \Exception
|
||||
{
|
||||
public function __construct(
|
||||
public array $errors
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
namespace BookStack\Exports\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Exports\ExportFormatter;
|
||||
use BookStack\Http\ApiController;
|
||||
use Throwable;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
namespace BookStack\Exports\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exports\ExportFormatter;
|
||||
use BookStack\Exports\ZipExports\ZipExportBuilder;
|
||||
use BookStack\Http\Controller;
|
||||
use Throwable;
|
||||
|
||||
@@ -63,4 +65,16 @@ class BookExportController extends Controller
|
||||
|
||||
return $this->download()->directly($textContent, $bookSlug . '.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a book to a contained ZIP export file.
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function zip(string $bookSlug, ZipExportBuilder $builder)
|
||||
{
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$zip = $builder->buildForBook($book);
|
||||
|
||||
return $this->download()->streamedDirectly(fopen($zip, 'r'), $bookSlug . '.zip', filesize($zip));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
namespace BookStack\Exports\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\ChapterQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Exports\ExportFormatter;
|
||||
use BookStack\Http\ApiController;
|
||||
use Throwable;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
namespace BookStack\Exports\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\ChapterQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exports\ExportFormatter;
|
||||
use BookStack\Exports\ZipExports\ZipExportBuilder;
|
||||
use BookStack\Http\Controller;
|
||||
use Throwable;
|
||||
|
||||
@@ -70,4 +71,16 @@ class ChapterExportController extends Controller
|
||||
|
||||
return $this->download()->directly($chapterText, $chapterSlug . '.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a book to a contained ZIP export file.
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function zip(string $bookSlug, string $chapterSlug, ZipExportBuilder $builder)
|
||||
{
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
$zip = $builder->buildForChapter($chapter);
|
||||
|
||||
return $this->download()->streamedDirectly(fopen($zip, 'r'), $chapterSlug . '.zip', filesize($zip));
|
||||
}
|
||||
}
|
||||
110
app/Exports/Controllers/ImportController.php
Normal file
110
app/Exports/Controllers/ImportController.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Exports\Controllers;
|
||||
|
||||
use BookStack\Exceptions\ZipImportException;
|
||||
use BookStack\Exceptions\ZipValidationException;
|
||||
use BookStack\Exports\ImportRepo;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Uploads\AttachmentService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ImportController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected ImportRepo $imports,
|
||||
) {
|
||||
$this->middleware('can:content-import');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the view to start a new import, and also list out the existing
|
||||
* in progress imports that are visible to the user.
|
||||
*/
|
||||
public function start()
|
||||
{
|
||||
$imports = $this->imports->getVisibleImports();
|
||||
|
||||
$this->setPageTitle(trans('entities.import'));
|
||||
|
||||
return view('exports.import', [
|
||||
'imports' => $imports,
|
||||
'zipErrors' => session()->pull('validation_errors') ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload, validate and store an import file.
|
||||
*/
|
||||
public function upload(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'file' => ['required', ...AttachmentService::getFileValidationRules()]
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
try {
|
||||
$import = $this->imports->storeFromUpload($file);
|
||||
} catch (ZipValidationException $exception) {
|
||||
return redirect('/import')->with('validation_errors', $exception->errors);
|
||||
}
|
||||
|
||||
return redirect($import->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a pending import, with a form to allow progressing
|
||||
* with the import process.
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
$import = $this->imports->findVisible($id);
|
||||
|
||||
$this->setPageTitle(trans('entities.import_continue'));
|
||||
|
||||
return view('exports.import-show', [
|
||||
'import' => $import,
|
||||
'data' => $import->decodeMetadata(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the import process against an uploaded import ZIP.
|
||||
*/
|
||||
public function run(int $id, Request $request)
|
||||
{
|
||||
$import = $this->imports->findVisible($id);
|
||||
$parent = null;
|
||||
|
||||
if ($import->type === 'page' || $import->type === 'chapter') {
|
||||
session()->setPreviousUrl($import->getUrl());
|
||||
$data = $this->validate($request, [
|
||||
'parent' => ['required', 'string'],
|
||||
]);
|
||||
$parent = $data['parent'];
|
||||
}
|
||||
|
||||
try {
|
||||
$entity = $this->imports->runImport($import, $parent);
|
||||
} catch (ZipImportException $exception) {
|
||||
session()->flush();
|
||||
$this->showErrorNotification(trans('errors.import_zip_failed_notification'));
|
||||
return redirect($import->getUrl())->with('import_errors', $exception->errors);
|
||||
}
|
||||
|
||||
return redirect($entity->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an active pending import from the filesystem and database.
|
||||
*/
|
||||
public function delete(int $id)
|
||||
{
|
||||
$import = $this->imports->findVisible($id);
|
||||
$this->imports->deleteImport($import);
|
||||
|
||||
return redirect('/import');
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
namespace BookStack\Exports\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Exports\ExportFormatter;
|
||||
use BookStack\Http\ApiController;
|
||||
use Throwable;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Controllers;
|
||||
namespace BookStack\Exports\Controllers;
|
||||
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Tools\ExportFormatter;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exports\ExportFormatter;
|
||||
use BookStack\Exports\ZipExports\ZipExportBuilder;
|
||||
use BookStack\Http\Controller;
|
||||
use Throwable;
|
||||
|
||||
@@ -74,4 +75,16 @@ class PageExportController extends Controller
|
||||
|
||||
return $this->download()->directly($pageText, $pageSlug . '.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a page to a contained ZIP export file.
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function zip(string $bookSlug, string $pageSlug, ZipExportBuilder $builder)
|
||||
{
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$zip = $builder->buildForPage($page);
|
||||
|
||||
return $this->download()->streamedDirectly(fopen($zip, 'r'), $pageSlug . '.zip', filesize($zip));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
namespace BookStack\Exports;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Util\CspService;
|
||||
use BookStack\Util\HtmlDocument;
|
||||
@@ -315,7 +317,12 @@ class ExportFormatter
|
||||
public function chapterToMarkdown(Chapter $chapter): string
|
||||
{
|
||||
$text = '# ' . $chapter->name . "\n\n";
|
||||
$text .= $chapter->description . "\n\n";
|
||||
|
||||
$description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert();
|
||||
if ($description) {
|
||||
$text .= $description . "\n\n";
|
||||
}
|
||||
|
||||
foreach ($chapter->pages as $page) {
|
||||
$text .= $this->pageToMarkdown($page) . "\n\n";
|
||||
}
|
||||
@@ -330,6 +337,12 @@ class ExportFormatter
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$text = '# ' . $book->name . "\n\n";
|
||||
|
||||
$description = (new HtmlToMarkdown($book->descriptionHtml()))->convert();
|
||||
if ($description) {
|
||||
$text .= $description . "\n\n";
|
||||
}
|
||||
|
||||
foreach ($bookTree as $bookChild) {
|
||||
if ($bookChild instanceof Chapter) {
|
||||
$text .= $this->chapterToMarkdown($bookChild) . "\n\n";
|
||||
66
app/Exports/Import.php
Normal file
66
app/Exports/Import.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports;
|
||||
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportBook;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
use BookStack\Users\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $path
|
||||
* @property string $name
|
||||
* @property int $size - ZIP size in bytes
|
||||
* @property string $type
|
||||
* @property string $metadata
|
||||
* @property int $created_by
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property User $createdBy
|
||||
*/
|
||||
class Import extends Model implements Loggable
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public function getSizeString(): string
|
||||
{
|
||||
$mb = round($this->size / 1000000, 2);
|
||||
return "{$mb} MB";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL to view/continue this import.
|
||||
*/
|
||||
public function getUrl(string $path = ''): string
|
||||
{
|
||||
$path = ltrim($path, '/');
|
||||
return url("/import/{$this->id}" . ($path ? '/' . $path : ''));
|
||||
}
|
||||
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "({$this->id}) {$this->name}";
|
||||
}
|
||||
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function decodeMetadata(): ZipExportBook|ZipExportChapter|ZipExportPage|null
|
||||
{
|
||||
$metadataArray = json_decode($this->metadata, true);
|
||||
return match ($this->type) {
|
||||
'book' => ZipExportBook::fromArray($metadataArray),
|
||||
'chapter' => ZipExportChapter::fromArray($metadataArray),
|
||||
'page' => ZipExportPage::fromArray($metadataArray),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
137
app/Exports/ImportRepo.php
Normal file
137
app/Exports/ImportRepo.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
use BookStack\Exceptions\ZipExportException;
|
||||
use BookStack\Exceptions\ZipImportException;
|
||||
use BookStack\Exceptions\ZipValidationException;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportBook;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
use BookStack\Exports\ZipExports\ZipExportReader;
|
||||
use BookStack\Exports\ZipExports\ZipExportValidator;
|
||||
use BookStack\Exports\ZipExports\ZipImportRunner;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Uploads\FileStorage;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class ImportRepo
|
||||
{
|
||||
public function __construct(
|
||||
protected FileStorage $storage,
|
||||
protected ZipImportRunner $importer,
|
||||
protected EntityQueries $entityQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<Import>
|
||||
*/
|
||||
public function getVisibleImports(): Collection
|
||||
{
|
||||
$query = Import::query();
|
||||
|
||||
if (!userCan('settings-manage')) {
|
||||
$query->where('created_by', user()->id);
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
public function findVisible(int $id): Import
|
||||
{
|
||||
$query = Import::query();
|
||||
|
||||
if (!userCan('settings-manage')) {
|
||||
$query->where('created_by', user()->id);
|
||||
}
|
||||
|
||||
return $query->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws FileUploadException
|
||||
* @throws ZipValidationException
|
||||
* @throws ZipExportException
|
||||
*/
|
||||
public function storeFromUpload(UploadedFile $file): Import
|
||||
{
|
||||
$zipPath = $file->getRealPath();
|
||||
$reader = new ZipExportReader($zipPath);
|
||||
|
||||
$errors = (new ZipExportValidator($reader))->validate();
|
||||
if ($errors) {
|
||||
throw new ZipValidationException($errors);
|
||||
}
|
||||
|
||||
$exportModel = $reader->decodeDataToExportModel();
|
||||
|
||||
$import = new Import();
|
||||
$import->type = match (get_class($exportModel)) {
|
||||
ZipExportPage::class => 'page',
|
||||
ZipExportChapter::class => 'chapter',
|
||||
ZipExportBook::class => 'book',
|
||||
};
|
||||
|
||||
$import->name = $exportModel->name;
|
||||
$import->created_by = user()->id;
|
||||
$import->size = filesize($zipPath);
|
||||
|
||||
$exportModel->metadataOnly();
|
||||
$import->metadata = json_encode($exportModel);
|
||||
|
||||
$path = $this->storage->uploadFile(
|
||||
$file,
|
||||
'uploads/files/imports/',
|
||||
'',
|
||||
'zip'
|
||||
);
|
||||
|
||||
$import->path = $path;
|
||||
$import->save();
|
||||
|
||||
Activity::add(ActivityType::IMPORT_CREATE, $import);
|
||||
|
||||
return $import;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipImportException
|
||||
*/
|
||||
public function runImport(Import $import, ?string $parent = null): Entity
|
||||
{
|
||||
$parentModel = null;
|
||||
if ($import->type === 'page' || $import->type === 'chapter') {
|
||||
$parentModel = $parent ? $this->entityQueries->findVisibleByStringIdentifier($parent) : null;
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
$model = $this->importer->run($import, $parentModel);
|
||||
} catch (ZipImportException $e) {
|
||||
DB::rollBack();
|
||||
$this->importer->revertStoredFiles();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
$this->deleteImport($import);
|
||||
Activity::add(ActivityType::IMPORT_RUN, $import);
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
public function deleteImport(Import $import): void
|
||||
{
|
||||
$this->storage->delete($import->path);
|
||||
$import->delete();
|
||||
|
||||
Activity::add(ActivityType::IMPORT_DELETE, $import);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
namespace BookStack\Exports;
|
||||
|
||||
use BookStack\Exceptions\PdfExportException;
|
||||
use Knp\Snappy\Pdf as SnappyPdf;
|
||||
use Dompdf\Dompdf;
|
||||
use Knp\Snappy\Pdf as SnappyPdf;
|
||||
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class PdfGenerator
|
||||
@@ -85,9 +86,15 @@ class PdfGenerator
|
||||
|
||||
file_put_contents($inputHtml, $html);
|
||||
|
||||
$timeout = intval(config('exports.pdf_command_timeout'));
|
||||
$process = Process::fromShellCommandline($command);
|
||||
$process->setTimeout(15);
|
||||
$process->run();
|
||||
$process->setTimeout($timeout);
|
||||
|
||||
try {
|
||||
$process->run();
|
||||
} catch (ProcessTimedOutException $e) {
|
||||
throw new PdfExportException("PDF Export via command failed due to timeout at {$timeout} second(s)");
|
||||
}
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
throw new PdfExportException("PDF Export via command failed with exit code {$process->getExitCode()}, stdout: {$process->getOutput()}, stderr: {$process->getErrorOutput()}");
|
||||
66
app/Exports/ZipExports/Models/ZipExportAttachment.php
Normal file
66
app/Exports/ZipExports/Models/ZipExportAttachment.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports\Models;
|
||||
|
||||
use BookStack\Exports\ZipExports\ZipExportFiles;
|
||||
use BookStack\Exports\ZipExports\ZipValidationHelper;
|
||||
use BookStack\Uploads\Attachment;
|
||||
|
||||
class ZipExportAttachment extends ZipExportModel
|
||||
{
|
||||
public ?int $id = null;
|
||||
public string $name;
|
||||
public ?string $link = null;
|
||||
public ?string $file = null;
|
||||
|
||||
public function metadataOnly(): void
|
||||
{
|
||||
$this->link = $this->file = null;
|
||||
}
|
||||
|
||||
public static function fromModel(Attachment $model, ZipExportFiles $files): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->id = $model->id;
|
||||
$instance->name = $model->name;
|
||||
|
||||
if ($model->external) {
|
||||
$instance->link = $model->path;
|
||||
} else {
|
||||
$instance->file = $files->referenceForAttachment($model);
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public static function fromModelArray(array $attachmentArray, ZipExportFiles $files): array
|
||||
{
|
||||
return array_values(array_map(function (Attachment $attachment) use ($files) {
|
||||
return self::fromModel($attachment, $files);
|
||||
}, $attachmentArray));
|
||||
}
|
||||
|
||||
public static function validate(ZipValidationHelper $context, array $data): array
|
||||
{
|
||||
$rules = [
|
||||
'id' => ['nullable', 'int', $context->uniqueIdRule('attachment')],
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'link' => ['required_without:file', 'nullable', 'string'],
|
||||
'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()],
|
||||
];
|
||||
|
||||
return $context->validateData($data, $rules);
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$model = new self();
|
||||
|
||||
$model->id = $data['id'] ?? null;
|
||||
$model->name = $data['name'];
|
||||
$model->link = $data['link'] ?? null;
|
||||
$model->file = $data['file'] ?? null;
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
118
app/Exports/ZipExports/Models/ZipExportBook.php
Normal file
118
app/Exports/ZipExports/Models/ZipExportBook.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports\Models;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exports\ZipExports\ZipExportFiles;
|
||||
use BookStack\Exports\ZipExports\ZipValidationHelper;
|
||||
|
||||
class ZipExportBook extends ZipExportModel
|
||||
{
|
||||
public ?int $id = null;
|
||||
public string $name;
|
||||
public ?string $description_html = null;
|
||||
public ?string $cover = null;
|
||||
/** @var ZipExportChapter[] */
|
||||
public array $chapters = [];
|
||||
/** @var ZipExportPage[] */
|
||||
public array $pages = [];
|
||||
/** @var ZipExportTag[] */
|
||||
public array $tags = [];
|
||||
|
||||
public function metadataOnly(): void
|
||||
{
|
||||
$this->description_html = $this->cover = null;
|
||||
|
||||
foreach ($this->chapters as $chapter) {
|
||||
$chapter->metadataOnly();
|
||||
}
|
||||
foreach ($this->pages as $page) {
|
||||
$page->metadataOnly();
|
||||
}
|
||||
foreach ($this->tags as $tag) {
|
||||
$tag->metadataOnly();
|
||||
}
|
||||
}
|
||||
|
||||
public function children(): array
|
||||
{
|
||||
$children = [
|
||||
...$this->pages,
|
||||
...$this->chapters,
|
||||
];
|
||||
|
||||
usort($children, function ($a, $b) {
|
||||
return ($a->priority ?? 0) - ($b->priority ?? 0);
|
||||
});
|
||||
|
||||
return $children;
|
||||
}
|
||||
|
||||
public static function fromModel(Book $model, ZipExportFiles $files): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->id = $model->id;
|
||||
$instance->name = $model->name;
|
||||
$instance->description_html = $model->descriptionHtml();
|
||||
|
||||
if ($model->cover) {
|
||||
$instance->cover = $files->referenceForImage($model->cover);
|
||||
}
|
||||
|
||||
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
|
||||
|
||||
$chapters = [];
|
||||
$pages = [];
|
||||
|
||||
$children = $model->getDirectVisibleChildren()->all();
|
||||
foreach ($children as $child) {
|
||||
if ($child instanceof Chapter) {
|
||||
$chapters[] = $child;
|
||||
} else if ($child instanceof Page && !$child->draft) {
|
||||
$pages[] = $child;
|
||||
}
|
||||
}
|
||||
|
||||
$instance->pages = ZipExportPage::fromModelArray($pages, $files);
|
||||
$instance->chapters = ZipExportChapter::fromModelArray($chapters, $files);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public static function validate(ZipValidationHelper $context, array $data): array
|
||||
{
|
||||
$rules = [
|
||||
'id' => ['nullable', 'int', $context->uniqueIdRule('book')],
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'description_html' => ['nullable', 'string'],
|
||||
'cover' => ['nullable', 'string', $context->fileReferenceRule()],
|
||||
'tags' => ['array'],
|
||||
'pages' => ['array'],
|
||||
'chapters' => ['array'],
|
||||
];
|
||||
|
||||
$errors = $context->validateData($data, $rules);
|
||||
$errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class);
|
||||
$errors['pages'] = $context->validateRelations($data['pages'] ?? [], ZipExportPage::class);
|
||||
$errors['chapters'] = $context->validateRelations($data['chapters'] ?? [], ZipExportChapter::class);
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$model = new self();
|
||||
|
||||
$model->id = $data['id'] ?? null;
|
||||
$model->name = $data['name'];
|
||||
$model->description_html = $data['description_html'] ?? null;
|
||||
$model->cover = $data['cover'] ?? null;
|
||||
$model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []);
|
||||
$model->pages = ZipExportPage::fromManyArray($data['pages'] ?? []);
|
||||
$model->chapters = ZipExportChapter::fromManyArray($data['chapters'] ?? []);
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
95
app/Exports/ZipExports/Models/ZipExportChapter.php
Normal file
95
app/Exports/ZipExports/Models/ZipExportChapter.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports\Models;
|
||||
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exports\ZipExports\ZipExportFiles;
|
||||
use BookStack\Exports\ZipExports\ZipValidationHelper;
|
||||
|
||||
class ZipExportChapter extends ZipExportModel
|
||||
{
|
||||
public ?int $id = null;
|
||||
public string $name;
|
||||
public ?string $description_html = null;
|
||||
public ?int $priority = null;
|
||||
/** @var ZipExportPage[] */
|
||||
public array $pages = [];
|
||||
/** @var ZipExportTag[] */
|
||||
public array $tags = [];
|
||||
|
||||
public function metadataOnly(): void
|
||||
{
|
||||
$this->description_html = null;
|
||||
|
||||
foreach ($this->pages as $page) {
|
||||
$page->metadataOnly();
|
||||
}
|
||||
foreach ($this->tags as $tag) {
|
||||
$tag->metadataOnly();
|
||||
}
|
||||
}
|
||||
|
||||
public function children(): array
|
||||
{
|
||||
return $this->pages;
|
||||
}
|
||||
|
||||
public static function fromModel(Chapter $model, ZipExportFiles $files): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->id = $model->id;
|
||||
$instance->name = $model->name;
|
||||
$instance->description_html = $model->descriptionHtml();
|
||||
$instance->priority = $model->priority;
|
||||
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
|
||||
|
||||
$pages = $model->getVisiblePages()->filter(fn (Page $page) => !$page->draft)->all();
|
||||
$instance->pages = ZipExportPage::fromModelArray($pages, $files);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Chapter[] $chapterArray
|
||||
* @return self[]
|
||||
*/
|
||||
public static function fromModelArray(array $chapterArray, ZipExportFiles $files): array
|
||||
{
|
||||
return array_values(array_map(function (Chapter $chapter) use ($files) {
|
||||
return self::fromModel($chapter, $files);
|
||||
}, $chapterArray));
|
||||
}
|
||||
|
||||
public static function validate(ZipValidationHelper $context, array $data): array
|
||||
{
|
||||
$rules = [
|
||||
'id' => ['nullable', 'int', $context->uniqueIdRule('chapter')],
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'description_html' => ['nullable', 'string'],
|
||||
'priority' => ['nullable', 'int'],
|
||||
'tags' => ['array'],
|
||||
'pages' => ['array'],
|
||||
];
|
||||
|
||||
$errors = $context->validateData($data, $rules);
|
||||
$errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class);
|
||||
$errors['pages'] = $context->validateRelations($data['pages'] ?? [], ZipExportPage::class);
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$model = new self();
|
||||
|
||||
$model->id = $data['id'] ?? null;
|
||||
$model->name = $data['name'];
|
||||
$model->description_html = $data['description_html'] ?? null;
|
||||
$model->priority = isset($data['priority']) ? intval($data['priority']) : null;
|
||||
$model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []);
|
||||
$model->pages = ZipExportPage::fromManyArray($data['pages'] ?? []);
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
57
app/Exports/ZipExports/Models/ZipExportImage.php
Normal file
57
app/Exports/ZipExports/Models/ZipExportImage.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports\Models;
|
||||
|
||||
use BookStack\Exports\ZipExports\ZipExportFiles;
|
||||
use BookStack\Exports\ZipExports\ZipValidationHelper;
|
||||
use BookStack\Uploads\Image;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ZipExportImage extends ZipExportModel
|
||||
{
|
||||
public ?int $id = null;
|
||||
public string $name;
|
||||
public string $file;
|
||||
public string $type;
|
||||
|
||||
public static function fromModel(Image $model, ZipExportFiles $files): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->id = $model->id;
|
||||
$instance->name = $model->name;
|
||||
$instance->type = $model->type;
|
||||
$instance->file = $files->referenceForImage($model);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public function metadataOnly(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public static function validate(ZipValidationHelper $context, array $data): array
|
||||
{
|
||||
$acceptedImageTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
||||
$rules = [
|
||||
'id' => ['nullable', 'int', $context->uniqueIdRule('image')],
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'file' => ['required', 'string', $context->fileReferenceRule($acceptedImageTypes)],
|
||||
'type' => ['required', 'string', Rule::in(['gallery', 'drawio'])],
|
||||
];
|
||||
|
||||
return $context->validateData($data, $rules);
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$model = new self();
|
||||
|
||||
$model->id = $data['id'] ?? null;
|
||||
$model->name = $data['name'];
|
||||
$model->file = $data['file'];
|
||||
$model->type = $data['type'];
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
57
app/Exports/ZipExports/Models/ZipExportModel.php
Normal file
57
app/Exports/ZipExports/Models/ZipExportModel.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports\Models;
|
||||
|
||||
use BookStack\Exports\ZipExports\ZipValidationHelper;
|
||||
use JsonSerializable;
|
||||
|
||||
abstract class ZipExportModel implements JsonSerializable
|
||||
{
|
||||
/**
|
||||
* Handle the serialization to JSON.
|
||||
* For these exports, we filter out optional (represented as nullable) fields
|
||||
* just to clean things up and prevent confusion to avoid null states in the
|
||||
* resulting export format itself.
|
||||
*/
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
$publicProps = get_object_vars(...)->__invoke($this);
|
||||
return array_filter($publicProps, fn ($value) => $value !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the given array of data intended for this model.
|
||||
* Return an array of validation errors messages.
|
||||
* Child items can be considered in the validation result by returning a keyed
|
||||
* item in the array for its own validation messages.
|
||||
*/
|
||||
abstract public static function validate(ZipValidationHelper $context, array $data): array;
|
||||
|
||||
/**
|
||||
* Decode the array of data into this export model.
|
||||
*/
|
||||
abstract public static function fromArray(array $data): self;
|
||||
|
||||
/**
|
||||
* Decode an array of array data into an array of export models.
|
||||
* @param array[] $data
|
||||
* @return self[]
|
||||
*/
|
||||
public static function fromManyArray(array $data): array
|
||||
{
|
||||
$results = [];
|
||||
foreach ($data as $item) {
|
||||
$results[] = static::fromArray($item);
|
||||
}
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove additional content in this model to reduce it down
|
||||
* to just essential id/name values for identification.
|
||||
*
|
||||
* The result of this may be something that does not pass validation, but is
|
||||
* simple for the purpose of creating a contents.
|
||||
*/
|
||||
abstract public function metadataOnly(): void;
|
||||
}
|
||||
104
app/Exports/ZipExports/Models/ZipExportPage.php
Normal file
104
app/Exports/ZipExports/Models/ZipExportPage.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports\Models;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Exports\ZipExports\ZipExportFiles;
|
||||
use BookStack\Exports\ZipExports\ZipValidationHelper;
|
||||
|
||||
class ZipExportPage extends ZipExportModel
|
||||
{
|
||||
public ?int $id = null;
|
||||
public string $name;
|
||||
public ?string $html = null;
|
||||
public ?string $markdown = null;
|
||||
public ?int $priority = null;
|
||||
/** @var ZipExportAttachment[] */
|
||||
public array $attachments = [];
|
||||
/** @var ZipExportImage[] */
|
||||
public array $images = [];
|
||||
/** @var ZipExportTag[] */
|
||||
public array $tags = [];
|
||||
|
||||
public function metadataOnly(): void
|
||||
{
|
||||
$this->html = $this->markdown = null;
|
||||
|
||||
foreach ($this->attachments as $attachment) {
|
||||
$attachment->metadataOnly();
|
||||
}
|
||||
foreach ($this->images as $image) {
|
||||
$image->metadataOnly();
|
||||
}
|
||||
foreach ($this->tags as $tag) {
|
||||
$tag->metadataOnly();
|
||||
}
|
||||
}
|
||||
|
||||
public static function fromModel(Page $model, ZipExportFiles $files): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->id = $model->id;
|
||||
$instance->name = $model->name;
|
||||
$instance->html = (new PageContent($model))->render();
|
||||
$instance->priority = $model->priority;
|
||||
|
||||
if (!empty($model->markdown)) {
|
||||
$instance->markdown = $model->markdown;
|
||||
}
|
||||
|
||||
$instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all());
|
||||
$instance->attachments = ZipExportAttachment::fromModelArray($model->attachments()->get()->all(), $files);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Page[] $pageArray
|
||||
* @return self[]
|
||||
*/
|
||||
public static function fromModelArray(array $pageArray, ZipExportFiles $files): array
|
||||
{
|
||||
return array_values(array_map(function (Page $page) use ($files) {
|
||||
return self::fromModel($page, $files);
|
||||
}, $pageArray));
|
||||
}
|
||||
|
||||
public static function validate(ZipValidationHelper $context, array $data): array
|
||||
{
|
||||
$rules = [
|
||||
'id' => ['nullable', 'int', $context->uniqueIdRule('page')],
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'html' => ['nullable', 'string'],
|
||||
'markdown' => ['nullable', 'string'],
|
||||
'priority' => ['nullable', 'int'],
|
||||
'attachments' => ['array'],
|
||||
'images' => ['array'],
|
||||
'tags' => ['array'],
|
||||
];
|
||||
|
||||
$errors = $context->validateData($data, $rules);
|
||||
$errors['attachments'] = $context->validateRelations($data['attachments'] ?? [], ZipExportAttachment::class);
|
||||
$errors['images'] = $context->validateRelations($data['images'] ?? [], ZipExportImage::class);
|
||||
$errors['tags'] = $context->validateRelations($data['tags'] ?? [], ZipExportTag::class);
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$model = new self();
|
||||
|
||||
$model->id = $data['id'] ?? null;
|
||||
$model->name = $data['name'];
|
||||
$model->html = $data['html'] ?? null;
|
||||
$model->markdown = $data['markdown'] ?? null;
|
||||
$model->priority = isset($data['priority']) ? intval($data['priority']) : null;
|
||||
$model->attachments = ZipExportAttachment::fromManyArray($data['attachments'] ?? []);
|
||||
$model->images = ZipExportImage::fromManyArray($data['images'] ?? []);
|
||||
$model->tags = ZipExportTag::fromManyArray($data['tags'] ?? []);
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
51
app/Exports/ZipExports/Models/ZipExportTag.php
Normal file
51
app/Exports/ZipExports/Models/ZipExportTag.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports\Models;
|
||||
|
||||
use BookStack\Activity\Models\Tag;
|
||||
use BookStack\Exports\ZipExports\ZipValidationHelper;
|
||||
|
||||
class ZipExportTag extends ZipExportModel
|
||||
{
|
||||
public string $name;
|
||||
public ?string $value = null;
|
||||
|
||||
public function metadataOnly(): void
|
||||
{
|
||||
$this->value = null;
|
||||
}
|
||||
|
||||
public static function fromModel(Tag $model): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->name = $model->name;
|
||||
$instance->value = $model->value;
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
public static function fromModelArray(array $tagArray): array
|
||||
{
|
||||
return array_values(array_map(self::fromModel(...), $tagArray));
|
||||
}
|
||||
|
||||
public static function validate(ZipValidationHelper $context, array $data): array
|
||||
{
|
||||
$rules = [
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'value' => ['nullable', 'string'],
|
||||
];
|
||||
|
||||
return $context->validateData($data, $rules);
|
||||
}
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$model = new self();
|
||||
|
||||
$model->name = $data['name'];
|
||||
$model->value = $data['value'] ?? null;
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
100
app/Exports/ZipExports/ZipExportBuilder.php
Normal file
100
app/Exports/ZipExports/ZipExportBuilder.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exceptions\ZipExportException;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportBook;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
use ZipArchive;
|
||||
|
||||
class ZipExportBuilder
|
||||
{
|
||||
protected array $data = [];
|
||||
|
||||
public function __construct(
|
||||
protected ZipExportFiles $files,
|
||||
protected ZipExportReferences $references,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipExportException
|
||||
*/
|
||||
public function buildForPage(Page $page): string
|
||||
{
|
||||
$exportPage = ZipExportPage::fromModel($page, $this->files);
|
||||
$this->data['page'] = $exportPage;
|
||||
|
||||
$this->references->addPage($exportPage);
|
||||
|
||||
return $this->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipExportException
|
||||
*/
|
||||
public function buildForChapter(Chapter $chapter): string
|
||||
{
|
||||
$exportChapter = ZipExportChapter::fromModel($chapter, $this->files);
|
||||
$this->data['chapter'] = $exportChapter;
|
||||
|
||||
$this->references->addChapter($exportChapter);
|
||||
|
||||
return $this->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipExportException
|
||||
*/
|
||||
public function buildForBook(Book $book): string
|
||||
{
|
||||
$exportBook = ZipExportBook::fromModel($book, $this->files);
|
||||
$this->data['book'] = $exportBook;
|
||||
|
||||
$this->references->addBook($exportBook);
|
||||
|
||||
return $this->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipExportException
|
||||
*/
|
||||
protected function build(): string
|
||||
{
|
||||
$this->references->buildReferences($this->files);
|
||||
|
||||
$this->data['exported_at'] = date(DATE_ATOM);
|
||||
$this->data['instance'] = [
|
||||
'id' => setting('instance-id', ''),
|
||||
'version' => trim(file_get_contents(base_path('version'))),
|
||||
];
|
||||
|
||||
$zipFile = tempnam(sys_get_temp_dir(), 'bszip-');
|
||||
$zip = new ZipArchive();
|
||||
$opened = $zip->open($zipFile, ZipArchive::CREATE);
|
||||
if ($opened !== true) {
|
||||
throw new ZipExportException('Failed to create zip file for export.');
|
||||
}
|
||||
|
||||
$zip->addFromString('data.json', json_encode($this->data));
|
||||
$zip->addEmptyDir('files');
|
||||
|
||||
$toRemove = [];
|
||||
$this->files->extractEach(function ($filePath, $fileRef) use ($zip, &$toRemove) {
|
||||
$zip->addFile($filePath, "files/$fileRef");
|
||||
$toRemove[] = $filePath;
|
||||
});
|
||||
|
||||
$zip->close();
|
||||
|
||||
foreach ($toRemove as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
|
||||
return $zipFile;
|
||||
}
|
||||
}
|
||||
107
app/Exports/ZipExports/ZipExportFiles.php
Normal file
107
app/Exports/ZipExports/ZipExportFiles.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\Uploads\Attachment;
|
||||
use BookStack\Uploads\AttachmentService;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ZipExportFiles
|
||||
{
|
||||
/**
|
||||
* References for attachments by attachment ID.
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $attachmentRefsById = [];
|
||||
|
||||
/**
|
||||
* References for images by image ID.
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected array $imageRefsById = [];
|
||||
|
||||
public function __construct(
|
||||
protected AttachmentService $attachmentService,
|
||||
protected ImageService $imageService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gain a reference to the given attachment instance.
|
||||
* This is expected to be a file-based attachment that the user
|
||||
* has visibility of, no permission/access checks are performed here.
|
||||
*/
|
||||
public function referenceForAttachment(Attachment $attachment): string
|
||||
{
|
||||
if (isset($this->attachmentRefsById[$attachment->id])) {
|
||||
return $this->attachmentRefsById[$attachment->id];
|
||||
}
|
||||
|
||||
$existingFiles = $this->getAllFileNames();
|
||||
do {
|
||||
$fileName = Str::random(20) . '.' . $attachment->extension;
|
||||
} while (in_array($fileName, $existingFiles));
|
||||
|
||||
$this->attachmentRefsById[$attachment->id] = $fileName;
|
||||
|
||||
return $fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gain a reference to the given image instance.
|
||||
* This is expected to be an image that the user has visibility of,
|
||||
* no permission/access checks are performed here.
|
||||
*/
|
||||
public function referenceForImage(Image $image): string
|
||||
{
|
||||
if (isset($this->imageRefsById[$image->id])) {
|
||||
return $this->imageRefsById[$image->id];
|
||||
}
|
||||
|
||||
$existingFiles = $this->getAllFileNames();
|
||||
$extension = pathinfo($image->path, PATHINFO_EXTENSION);
|
||||
do {
|
||||
$fileName = Str::random(20) . '.' . $extension;
|
||||
} while (in_array($fileName, $existingFiles));
|
||||
|
||||
$this->imageRefsById[$image->id] = $fileName;
|
||||
|
||||
return $fileName;
|
||||
}
|
||||
|
||||
protected function getAllFileNames(): array
|
||||
{
|
||||
return array_merge(
|
||||
array_values($this->attachmentRefsById),
|
||||
array_values($this->imageRefsById),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract each of the ZIP export tracked files.
|
||||
* Calls the given callback for each tracked file, passing a temporary
|
||||
* file reference of the file contents, and the zip-local tracked reference.
|
||||
*/
|
||||
public function extractEach(callable $callback): void
|
||||
{
|
||||
foreach ($this->attachmentRefsById as $attachmentId => $ref) {
|
||||
$attachment = Attachment::query()->find($attachmentId);
|
||||
$stream = $this->attachmentService->streamAttachmentFromStorage($attachment);
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'bszipfile-');
|
||||
$tmpFileStream = fopen($tmpFile, 'w');
|
||||
stream_copy_to_stream($stream, $tmpFileStream);
|
||||
$callback($tmpFile, $ref);
|
||||
}
|
||||
|
||||
foreach ($this->imageRefsById as $imageId => $ref) {
|
||||
$image = Image::query()->find($imageId);
|
||||
$stream = $this->imageService->getImageStream($image);
|
||||
$tmpFile = tempnam(sys_get_temp_dir(), 'bszipimage-');
|
||||
$tmpFileStream = fopen($tmpFile, 'w');
|
||||
stream_copy_to_stream($stream, $tmpFileStream);
|
||||
$callback($tmpFile, $ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
111
app/Exports/ZipExports/ZipExportReader.php
Normal file
111
app/Exports/ZipExports/ZipExportReader.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\Exceptions\ZipExportException;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportBook;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
use BookStack\Util\WebSafeMimeSniffer;
|
||||
use ZipArchive;
|
||||
|
||||
class ZipExportReader
|
||||
{
|
||||
protected ZipArchive $zip;
|
||||
protected bool $open = false;
|
||||
|
||||
public function __construct(
|
||||
protected string $zipPath,
|
||||
) {
|
||||
$this->zip = new ZipArchive();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipExportException
|
||||
*/
|
||||
protected function open(): void
|
||||
{
|
||||
if ($this->open) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file exists
|
||||
if (!file_exists($this->zipPath) || !is_readable($this->zipPath)) {
|
||||
throw new ZipExportException(trans('errors.import_zip_cant_read'));
|
||||
}
|
||||
|
||||
// Validate file is valid zip
|
||||
$opened = $this->zip->open($this->zipPath, ZipArchive::RDONLY);
|
||||
if ($opened !== true) {
|
||||
throw new ZipExportException(trans('errors.import_zip_cant_read'));
|
||||
}
|
||||
|
||||
$this->open = true;
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
if ($this->open) {
|
||||
$this->zip->close();
|
||||
$this->open = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipExportException
|
||||
*/
|
||||
public function readData(): array
|
||||
{
|
||||
$this->open();
|
||||
|
||||
// Validate json data exists, including metadata
|
||||
$jsonData = $this->zip->getFromName('data.json') ?: '';
|
||||
$importData = json_decode($jsonData, true);
|
||||
if (!$importData) {
|
||||
throw new ZipExportException(trans('errors.import_zip_cant_decode_data'));
|
||||
}
|
||||
|
||||
return $importData;
|
||||
}
|
||||
|
||||
public function fileExists(string $fileName): bool
|
||||
{
|
||||
return $this->zip->statName("files/{$fileName}") !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|resource
|
||||
*/
|
||||
public function streamFile(string $fileName)
|
||||
{
|
||||
return $this->zip->getStream("files/{$fileName}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sniff the mime type from the file of given name.
|
||||
*/
|
||||
public function sniffFileMime(string $fileName): string
|
||||
{
|
||||
$stream = $this->streamFile($fileName);
|
||||
$sniffContent = fread($stream, 2000);
|
||||
|
||||
return (new WebSafeMimeSniffer())->sniff($sniffContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipExportException
|
||||
*/
|
||||
public function decodeDataToExportModel(): ZipExportBook|ZipExportChapter|ZipExportPage
|
||||
{
|
||||
$data = $this->readData();
|
||||
if (isset($data['book'])) {
|
||||
return ZipExportBook::fromArray($data['book']);
|
||||
} else if (isset($data['chapter'])) {
|
||||
return ZipExportChapter::fromArray($data['chapter']);
|
||||
} else if (isset($data['page'])) {
|
||||
return ZipExportPage::fromArray($data['page']);
|
||||
}
|
||||
|
||||
throw new ZipExportException("Could not identify content in ZIP file data.");
|
||||
}
|
||||
}
|
||||
159
app/Exports/ZipExports/ZipExportReferences.php
Normal file
159
app/Exports/ZipExports/ZipExportReferences.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportAttachment;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportBook;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportImage;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportModel;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
use BookStack\Uploads\Attachment;
|
||||
use BookStack\Uploads\Image;
|
||||
|
||||
class ZipExportReferences
|
||||
{
|
||||
/** @var ZipExportPage[] */
|
||||
protected array $pages = [];
|
||||
/** @var ZipExportChapter[] */
|
||||
protected array $chapters = [];
|
||||
/** @var ZipExportBook[] */
|
||||
protected array $books = [];
|
||||
|
||||
/** @var ZipExportAttachment[] */
|
||||
protected array $attachments = [];
|
||||
|
||||
/** @var ZipExportImage[] */
|
||||
protected array $images = [];
|
||||
|
||||
public function __construct(
|
||||
protected ZipReferenceParser $parser,
|
||||
) {
|
||||
}
|
||||
|
||||
public function addPage(ZipExportPage $page): void
|
||||
{
|
||||
if ($page->id) {
|
||||
$this->pages[$page->id] = $page;
|
||||
}
|
||||
|
||||
foreach ($page->attachments as $attachment) {
|
||||
if ($attachment->id) {
|
||||
$this->attachments[$attachment->id] = $attachment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function addChapter(ZipExportChapter $chapter): void
|
||||
{
|
||||
if ($chapter->id) {
|
||||
$this->chapters[$chapter->id] = $chapter;
|
||||
}
|
||||
|
||||
foreach ($chapter->pages as $page) {
|
||||
$this->addPage($page);
|
||||
}
|
||||
}
|
||||
|
||||
public function addBook(ZipExportBook $book): void
|
||||
{
|
||||
if ($book->id) {
|
||||
$this->books[$book->id] = $book;
|
||||
}
|
||||
|
||||
foreach ($book->pages as $page) {
|
||||
$this->addPage($page);
|
||||
}
|
||||
|
||||
foreach ($book->chapters as $chapter) {
|
||||
$this->addChapter($chapter);
|
||||
}
|
||||
}
|
||||
|
||||
public function buildReferences(ZipExportFiles $files): void
|
||||
{
|
||||
$createHandler = function (ZipExportModel $zipModel) use ($files) {
|
||||
return function (Model $model) use ($files, $zipModel) {
|
||||
return $this->handleModelReference($model, $zipModel, $files);
|
||||
};
|
||||
};
|
||||
|
||||
// Parse page content first
|
||||
foreach ($this->pages as $page) {
|
||||
$handler = $createHandler($page);
|
||||
$page->html = $this->parser->parseLinks($page->html ?? '', $handler);
|
||||
if ($page->markdown) {
|
||||
$page->markdown = $this->parser->parseLinks($page->markdown, $handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse chapter description HTML
|
||||
foreach ($this->chapters as $chapter) {
|
||||
if ($chapter->description_html) {
|
||||
$handler = $createHandler($chapter);
|
||||
$chapter->description_html = $this->parser->parseLinks($chapter->description_html, $handler);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse book description HTML
|
||||
foreach ($this->books as $book) {
|
||||
if ($book->description_html) {
|
||||
$handler = $createHandler($book);
|
||||
$book->description_html = $this->parser->parseLinks($book->description_html, $handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function handleModelReference(Model $model, ZipExportModel $exportModel, ZipExportFiles $files): ?string
|
||||
{
|
||||
// Handle attachment references
|
||||
// No permission check needed here since they would only already exist in this
|
||||
// reference context if already allowed via their entity access.
|
||||
if ($model instanceof Attachment) {
|
||||
if (isset($this->attachments[$model->id])) {
|
||||
return "[[bsexport:attachment:{$model->id}]]";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle image references
|
||||
if ($model instanceof Image) {
|
||||
// Only handle gallery and drawio images
|
||||
if ($model->type !== 'gallery' && $model->type !== 'drawio') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle simple links outside of page content
|
||||
if (!($exportModel instanceof ZipExportPage) && isset($this->images[$model->id])) {
|
||||
return "[[bsexport:image:{$model->id}]]";
|
||||
}
|
||||
|
||||
// Find and include images if in visibility
|
||||
$page = $model->getPage();
|
||||
if ($page && userCan('view', $page)) {
|
||||
if (!isset($this->images[$model->id])) {
|
||||
$exportImage = ZipExportImage::fromModel($model, $files);
|
||||
$this->images[$model->id] = $exportImage;
|
||||
$exportModel->images[] = $exportImage;
|
||||
}
|
||||
return "[[bsexport:image:{$model->id}]]";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle entity references
|
||||
if ($model instanceof Book && isset($this->books[$model->id])) {
|
||||
return "[[bsexport:book:{$model->id}]]";
|
||||
} else if ($model instanceof Chapter && isset($this->chapters[$model->id])) {
|
||||
return "[[bsexport:chapter:{$model->id}]]";
|
||||
} else if ($model instanceof Page && isset($this->pages[$model->id])) {
|
||||
return "[[bsexport:page:{$model->id}]]";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
57
app/Exports/ZipExports/ZipExportValidator.php
Normal file
57
app/Exports/ZipExports/ZipExportValidator.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\Exceptions\ZipExportException;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportBook;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
|
||||
class ZipExportValidator
|
||||
{
|
||||
public function __construct(
|
||||
protected ZipExportReader $reader,
|
||||
) {
|
||||
}
|
||||
|
||||
public function validate(): array
|
||||
{
|
||||
try {
|
||||
$importData = $this->reader->readData();
|
||||
} catch (ZipExportException $exception) {
|
||||
return ['format' => $exception->getMessage()];
|
||||
}
|
||||
|
||||
$helper = new ZipValidationHelper($this->reader);
|
||||
|
||||
if (isset($importData['book'])) {
|
||||
$modelErrors = ZipExportBook::validate($helper, $importData['book']);
|
||||
$keyPrefix = 'book';
|
||||
} else if (isset($importData['chapter'])) {
|
||||
$modelErrors = ZipExportChapter::validate($helper, $importData['chapter']);
|
||||
$keyPrefix = 'chapter';
|
||||
} else if (isset($importData['page'])) {
|
||||
$modelErrors = ZipExportPage::validate($helper, $importData['page']);
|
||||
$keyPrefix = 'page';
|
||||
} else {
|
||||
return ['format' => trans('errors.import_zip_no_data')];
|
||||
}
|
||||
|
||||
return $this->flattenModelErrors($modelErrors, $keyPrefix);
|
||||
}
|
||||
|
||||
protected function flattenModelErrors(array $errors, string $keyPrefix): array
|
||||
{
|
||||
$flattened = [];
|
||||
|
||||
foreach ($errors as $key => $error) {
|
||||
if (is_array($error)) {
|
||||
$flattened = array_merge($flattened, $this->flattenModelErrors($error, $keyPrefix . '.' . $key));
|
||||
} else {
|
||||
$flattened[$keyPrefix . '.' . $key] = $error;
|
||||
}
|
||||
}
|
||||
|
||||
return $flattened;
|
||||
}
|
||||
}
|
||||
37
app/Exports/ZipExports/ZipFileReferenceRule.php
Normal file
37
app/Exports/ZipExports/ZipFileReferenceRule.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class ZipFileReferenceRule implements ValidationRule
|
||||
{
|
||||
public function __construct(
|
||||
protected ZipValidationHelper $context,
|
||||
protected array $acceptedMimes,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (!$this->context->zipReader->fileExists($value)) {
|
||||
$fail('validation.zip_file')->translate();
|
||||
}
|
||||
|
||||
if (!empty($this->acceptedMimes)) {
|
||||
$fileMime = $this->context->zipReader->sniffFileMime($value);
|
||||
if (!in_array($fileMime, $this->acceptedMimes)) {
|
||||
$fail('validation.zip_file_mime')->translate([
|
||||
'attribute' => $attribute,
|
||||
'validTypes' => implode(',', $this->acceptedMimes),
|
||||
'foundType' => $fileMime
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
161
app/Exports/ZipExports/ZipImportReferences.php
Normal file
161
app/Exports/ZipExports/ZipImportReferences.php
Normal file
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\BaseRepo;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportBook;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
use BookStack\Uploads\Attachment;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageResizer;
|
||||
|
||||
class ZipImportReferences
|
||||
{
|
||||
/** @var Page[] */
|
||||
protected array $pages = [];
|
||||
/** @var Chapter[] */
|
||||
protected array $chapters = [];
|
||||
/** @var Book[] */
|
||||
protected array $books = [];
|
||||
/** @var Attachment[] */
|
||||
protected array $attachments = [];
|
||||
/** @var Image[] */
|
||||
protected array $images = [];
|
||||
|
||||
/** @var array<string, Model> */
|
||||
protected array $referenceMap = [];
|
||||
|
||||
/** @var array<int, ZipExportPage> */
|
||||
protected array $zipExportPageMap = [];
|
||||
/** @var array<int, ZipExportChapter> */
|
||||
protected array $zipExportChapterMap = [];
|
||||
/** @var array<int, ZipExportBook> */
|
||||
protected array $zipExportBookMap = [];
|
||||
|
||||
public function __construct(
|
||||
protected ZipReferenceParser $parser,
|
||||
protected BaseRepo $baseRepo,
|
||||
protected PageRepo $pageRepo,
|
||||
protected ImageResizer $imageResizer,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function addReference(string $type, Model $model, ?int $importId): void
|
||||
{
|
||||
if ($importId) {
|
||||
$key = $type . ':' . $importId;
|
||||
$this->referenceMap[$key] = $model;
|
||||
}
|
||||
}
|
||||
|
||||
public function addPage(Page $page, ZipExportPage $exportPage): void
|
||||
{
|
||||
$this->pages[] = $page;
|
||||
$this->zipExportPageMap[$page->id] = $exportPage;
|
||||
$this->addReference('page', $page, $exportPage->id);
|
||||
}
|
||||
|
||||
public function addChapter(Chapter $chapter, ZipExportChapter $exportChapter): void
|
||||
{
|
||||
$this->chapters[] = $chapter;
|
||||
$this->zipExportChapterMap[$chapter->id] = $exportChapter;
|
||||
$this->addReference('chapter', $chapter, $exportChapter->id);
|
||||
}
|
||||
|
||||
public function addBook(Book $book, ZipExportBook $exportBook): void
|
||||
{
|
||||
$this->books[] = $book;
|
||||
$this->zipExportBookMap[$book->id] = $exportBook;
|
||||
$this->addReference('book', $book, $exportBook->id);
|
||||
}
|
||||
|
||||
public function addAttachment(Attachment $attachment, ?int $importId): void
|
||||
{
|
||||
$this->attachments[] = $attachment;
|
||||
$this->addReference('attachment', $attachment, $importId);
|
||||
}
|
||||
|
||||
public function addImage(Image $image, ?int $importId): void
|
||||
{
|
||||
$this->images[] = $image;
|
||||
$this->addReference('image', $image, $importId);
|
||||
}
|
||||
|
||||
protected function handleReference(string $type, int $id): ?string
|
||||
{
|
||||
$key = $type . ':' . $id;
|
||||
$model = $this->referenceMap[$key] ?? null;
|
||||
if ($model instanceof Entity) {
|
||||
return $model->getUrl();
|
||||
} else if ($model instanceof Image) {
|
||||
if ($model->type === 'gallery') {
|
||||
$this->imageResizer->loadGalleryThumbnailsForImage($model, false);
|
||||
return $model->thumbs['display'] ?? $model->url;
|
||||
}
|
||||
|
||||
return $model->url;
|
||||
} else if ($model instanceof Attachment) {
|
||||
return $model->getUrl(false);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function replaceReferences(): void
|
||||
{
|
||||
foreach ($this->books as $book) {
|
||||
$exportBook = $this->zipExportBookMap[$book->id];
|
||||
$content = $exportBook->description_html ?? '';
|
||||
$parsed = $this->parser->parseReferences($content, $this->handleReference(...));
|
||||
|
||||
$this->baseRepo->update($book, [
|
||||
'description_html' => $parsed,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($this->chapters as $chapter) {
|
||||
$exportChapter = $this->zipExportChapterMap[$chapter->id];
|
||||
$content = $exportChapter->description_html ?? '';
|
||||
$parsed = $this->parser->parseReferences($content, $this->handleReference(...));
|
||||
|
||||
$this->baseRepo->update($chapter, [
|
||||
'description_html' => $parsed,
|
||||
]);
|
||||
}
|
||||
|
||||
foreach ($this->pages as $page) {
|
||||
$exportPage = $this->zipExportPageMap[$page->id];
|
||||
$contentType = $exportPage->markdown ? 'markdown' : 'html';
|
||||
$content = $exportPage->markdown ?: ($exportPage->html ?: '');
|
||||
$parsed = $this->parser->parseReferences($content, $this->handleReference(...));
|
||||
|
||||
$this->pageRepo->setContentFromInput($page, [
|
||||
$contentType => $parsed,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return Image[]
|
||||
*/
|
||||
public function images(): array
|
||||
{
|
||||
return $this->images;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Attachment[]
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return $this->attachments;
|
||||
}
|
||||
}
|
||||
364
app/Exports/ZipExports/ZipImportRunner.php
Normal file
364
app/Exports/ZipExports/ZipImportRunner.php
Normal file
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Exceptions\ZipExportException;
|
||||
use BookStack\Exceptions\ZipImportException;
|
||||
use BookStack\Exports\Import;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportAttachment;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportBook;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportChapter;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportImage;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportPage;
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportTag;
|
||||
use BookStack\Uploads\Attachment;
|
||||
use BookStack\Uploads\AttachmentService;
|
||||
use BookStack\Uploads\FileStorage;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class ZipImportRunner
|
||||
{
|
||||
protected array $tempFilesToCleanup = [];
|
||||
|
||||
public function __construct(
|
||||
protected FileStorage $storage,
|
||||
protected PageRepo $pageRepo,
|
||||
protected ChapterRepo $chapterRepo,
|
||||
protected BookRepo $bookRepo,
|
||||
protected ImageService $imageService,
|
||||
protected AttachmentService $attachmentService,
|
||||
protected ZipImportReferences $references,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the import.
|
||||
* Performs re-validation on zip, validation on parent provided, and permissions for importing
|
||||
* the planned content, before running the import process.
|
||||
* Returns the top-level entity item which was imported.
|
||||
* @throws ZipImportException
|
||||
*/
|
||||
public function run(Import $import, ?Entity $parent = null): Entity
|
||||
{
|
||||
$zipPath = $this->getZipPath($import);
|
||||
$reader = new ZipExportReader($zipPath);
|
||||
|
||||
$errors = (new ZipExportValidator($reader))->validate();
|
||||
if ($errors) {
|
||||
throw new ZipImportException([
|
||||
trans('errors.import_validation_failed'),
|
||||
...$errors,
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$exportModel = $reader->decodeDataToExportModel();
|
||||
} catch (ZipExportException $e) {
|
||||
throw new ZipImportException([$e->getMessage()]);
|
||||
}
|
||||
|
||||
// Validate parent type
|
||||
if ($exportModel instanceof ZipExportBook && ($parent !== null)) {
|
||||
throw new ZipImportException(["Must not have a parent set for a Book import."]);
|
||||
} else if ($exportModel instanceof ZipExportChapter && !($parent instanceof Book)) {
|
||||
throw new ZipImportException(["Parent book required for chapter import."]);
|
||||
} else if ($exportModel instanceof ZipExportPage && !($parent instanceof Book || $parent instanceof Chapter)) {
|
||||
throw new ZipImportException(["Parent book or chapter required for page import."]);
|
||||
}
|
||||
|
||||
$this->ensurePermissionsPermitImport($exportModel, $parent);
|
||||
|
||||
if ($exportModel instanceof ZipExportBook) {
|
||||
$entity = $this->importBook($exportModel, $reader);
|
||||
} else if ($exportModel instanceof ZipExportChapter) {
|
||||
$entity = $this->importChapter($exportModel, $parent, $reader);
|
||||
} else if ($exportModel instanceof ZipExportPage) {
|
||||
$entity = $this->importPage($exportModel, $parent, $reader);
|
||||
} else {
|
||||
throw new ZipImportException(['No importable data found in import data.']);
|
||||
}
|
||||
|
||||
$this->references->replaceReferences();
|
||||
|
||||
$reader->close();
|
||||
$this->cleanup();
|
||||
|
||||
return $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert any files which have been stored during this import process.
|
||||
* Considers files only, and avoids the database under the
|
||||
* assumption that the database may already have been
|
||||
* reverted as part of a transaction rollback.
|
||||
*/
|
||||
public function revertStoredFiles(): void
|
||||
{
|
||||
foreach ($this->references->images() as $image) {
|
||||
$this->imageService->destroyFileAtPath($image->type, $image->path);
|
||||
}
|
||||
|
||||
foreach ($this->references->attachments() as $attachment) {
|
||||
if (!$attachment->external) {
|
||||
$this->attachmentService->deleteFileInStorage($attachment);
|
||||
}
|
||||
}
|
||||
|
||||
$this->cleanup();
|
||||
}
|
||||
|
||||
protected function cleanup(): void
|
||||
{
|
||||
foreach ($this->tempFilesToCleanup as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
|
||||
$this->tempFilesToCleanup = [];
|
||||
}
|
||||
|
||||
protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader): Book
|
||||
{
|
||||
$book = $this->bookRepo->create([
|
||||
'name' => $exportBook->name,
|
||||
'description_html' => $exportBook->description_html ?? '',
|
||||
'image' => $exportBook->cover ? $this->zipFileToUploadedFile($exportBook->cover, $reader) : null,
|
||||
'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []),
|
||||
]);
|
||||
|
||||
if ($book->cover) {
|
||||
$this->references->addImage($book->cover, null);
|
||||
}
|
||||
|
||||
$children = [
|
||||
...$exportBook->chapters,
|
||||
...$exportBook->pages,
|
||||
];
|
||||
|
||||
usort($children, function (ZipExportPage|ZipExportChapter $a, ZipExportPage|ZipExportChapter $b) {
|
||||
return ($a->priority ?? 0) - ($b->priority ?? 0);
|
||||
});
|
||||
|
||||
foreach ($children as $child) {
|
||||
if ($child instanceof ZipExportChapter) {
|
||||
$this->importChapter($child, $book, $reader);
|
||||
} else if ($child instanceof ZipExportPage) {
|
||||
$this->importPage($child, $book, $reader);
|
||||
}
|
||||
}
|
||||
|
||||
$this->references->addBook($book, $exportBook);
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
protected function importChapter(ZipExportChapter $exportChapter, Book $parent, ZipExportReader $reader): Chapter
|
||||
{
|
||||
$chapter = $this->chapterRepo->create([
|
||||
'name' => $exportChapter->name,
|
||||
'description_html' => $exportChapter->description_html ?? '',
|
||||
'tags' => $this->exportTagsToInputArray($exportChapter->tags ?? []),
|
||||
], $parent);
|
||||
|
||||
$exportPages = $exportChapter->pages;
|
||||
usort($exportPages, function (ZipExportPage $a, ZipExportPage $b) {
|
||||
return ($a->priority ?? 0) - ($b->priority ?? 0);
|
||||
});
|
||||
|
||||
foreach ($exportPages as $exportPage) {
|
||||
$this->importPage($exportPage, $chapter, $reader);
|
||||
}
|
||||
|
||||
$this->references->addChapter($chapter, $exportChapter);
|
||||
|
||||
return $chapter;
|
||||
}
|
||||
|
||||
protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, ZipExportReader $reader): Page
|
||||
{
|
||||
$page = $this->pageRepo->getNewDraftPage($parent);
|
||||
|
||||
foreach ($exportPage->attachments as $exportAttachment) {
|
||||
$this->importAttachment($exportAttachment, $page, $reader);
|
||||
}
|
||||
|
||||
foreach ($exportPage->images as $exportImage) {
|
||||
$this->importImage($exportImage, $page, $reader);
|
||||
}
|
||||
|
||||
$this->pageRepo->publishDraft($page, [
|
||||
'name' => $exportPage->name,
|
||||
'markdown' => $exportPage->markdown,
|
||||
'html' => $exportPage->html,
|
||||
'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []),
|
||||
]);
|
||||
|
||||
$this->references->addPage($page, $exportPage);
|
||||
|
||||
return $page;
|
||||
}
|
||||
|
||||
protected function importAttachment(ZipExportAttachment $exportAttachment, Page $page, ZipExportReader $reader): Attachment
|
||||
{
|
||||
if ($exportAttachment->file) {
|
||||
$file = $this->zipFileToUploadedFile($exportAttachment->file, $reader);
|
||||
$attachment = $this->attachmentService->saveNewUpload($file, $page->id);
|
||||
$attachment->name = $exportAttachment->name;
|
||||
$attachment->save();
|
||||
} else {
|
||||
$attachment = $this->attachmentService->saveNewFromLink(
|
||||
$exportAttachment->name,
|
||||
$exportAttachment->link ?? '',
|
||||
$page->id,
|
||||
);
|
||||
}
|
||||
|
||||
$this->references->addAttachment($attachment, $exportAttachment->id);
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
protected function importImage(ZipExportImage $exportImage, Page $page, ZipExportReader $reader): Image
|
||||
{
|
||||
$mime = $reader->sniffFileMime($exportImage->file);
|
||||
$extension = explode('/', $mime)[1];
|
||||
|
||||
$file = $this->zipFileToUploadedFile($exportImage->file, $reader);
|
||||
$image = $this->imageService->saveNewFromUpload(
|
||||
$file,
|
||||
$exportImage->type,
|
||||
$page->id,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
$exportImage->name . '.' . $extension,
|
||||
);
|
||||
|
||||
$image->name = $exportImage->name;
|
||||
$image->save();
|
||||
|
||||
$this->references->addImage($image, $exportImage->id);
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
protected function exportTagsToInputArray(array $exportTags): array
|
||||
{
|
||||
$tags = [];
|
||||
|
||||
/** @var ZipExportTag $tag */
|
||||
foreach ($exportTags as $tag) {
|
||||
$tags[] = ['name' => $tag->name, 'value' => $tag->value ?? ''];
|
||||
}
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile
|
||||
{
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'bszipextract');
|
||||
$fileStream = $reader->streamFile($fileName);
|
||||
$tempStream = fopen($tempPath, 'wb');
|
||||
stream_copy_to_stream($fileStream, $tempStream);
|
||||
fclose($tempStream);
|
||||
|
||||
$this->tempFilesToCleanup[] = $tempPath;
|
||||
|
||||
return new UploadedFile($tempPath, $fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ZipImportException
|
||||
*/
|
||||
protected function ensurePermissionsPermitImport(ZipExportPage|ZipExportChapter|ZipExportBook $exportModel, Book|Chapter|null $parent = null): void
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
$chapters = [];
|
||||
$pages = [];
|
||||
$images = [];
|
||||
$attachments = [];
|
||||
|
||||
if ($exportModel instanceof ZipExportBook) {
|
||||
if (!userCan('book-create-all')) {
|
||||
$errors[] = trans('errors.import_perms_books');
|
||||
}
|
||||
array_push($pages, ...$exportModel->pages);
|
||||
array_push($chapters, ...$exportModel->chapters);
|
||||
} else if ($exportModel instanceof ZipExportChapter) {
|
||||
$chapters[] = $exportModel;
|
||||
} else if ($exportModel instanceof ZipExportPage) {
|
||||
$pages[] = $exportModel;
|
||||
}
|
||||
|
||||
foreach ($chapters as $chapter) {
|
||||
array_push($pages, ...$chapter->pages);
|
||||
}
|
||||
|
||||
if (count($chapters) > 0) {
|
||||
$permission = 'chapter-create' . ($parent ? '' : '-all');
|
||||
if (!userCan($permission, $parent)) {
|
||||
$errors[] = trans('errors.import_perms_chapters');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pages as $page) {
|
||||
array_push($attachments, ...$page->attachments);
|
||||
array_push($images, ...$page->images);
|
||||
}
|
||||
|
||||
if (count($pages) > 0) {
|
||||
if ($parent) {
|
||||
if (!userCan('page-create', $parent)) {
|
||||
$errors[] = trans('errors.import_perms_pages');
|
||||
}
|
||||
} else {
|
||||
$hasPermission = userCan('page-create-all') || userCan('page-create-own');
|
||||
if (!$hasPermission) {
|
||||
$errors[] = trans('errors.import_perms_pages');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count($images) > 0) {
|
||||
if (!userCan('image-create-all')) {
|
||||
$errors[] = trans('errors.import_perms_images');
|
||||
}
|
||||
}
|
||||
|
||||
if (count($attachments) > 0) {
|
||||
if (!userCan('attachment-create-all')) {
|
||||
$errors[] = trans('errors.import_perms_attachments');
|
||||
}
|
||||
}
|
||||
|
||||
if (count($errors)) {
|
||||
throw new ZipImportException($errors);
|
||||
}
|
||||
}
|
||||
|
||||
protected function getZipPath(Import $import): string
|
||||
{
|
||||
if (!$this->storage->isRemote()) {
|
||||
return $this->storage->getSystemPath($import->path);
|
||||
}
|
||||
|
||||
$tempFilePath = tempnam(sys_get_temp_dir(), 'bszip-import-');
|
||||
$tempFile = fopen($tempFilePath, 'wb');
|
||||
$stream = $this->storage->getReadStream($import->path);
|
||||
stream_copy_to_stream($stream, $tempFile);
|
||||
fclose($tempFile);
|
||||
|
||||
$this->tempFilesToCleanup[] = $tempFilePath;
|
||||
|
||||
return $tempFilePath;
|
||||
}
|
||||
}
|
||||
140
app/Exports/ZipExports/ZipReferenceParser.php
Normal file
140
app/Exports/ZipExports/ZipReferenceParser.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\References\ModelResolvers\AttachmentModelResolver;
|
||||
use BookStack\References\ModelResolvers\BookLinkModelResolver;
|
||||
use BookStack\References\ModelResolvers\ChapterLinkModelResolver;
|
||||
use BookStack\References\ModelResolvers\CrossLinkModelResolver;
|
||||
use BookStack\References\ModelResolvers\ImageModelResolver;
|
||||
use BookStack\References\ModelResolvers\PageLinkModelResolver;
|
||||
use BookStack\References\ModelResolvers\PagePermalinkModelResolver;
|
||||
use BookStack\Uploads\ImageStorage;
|
||||
|
||||
class ZipReferenceParser
|
||||
{
|
||||
/**
|
||||
* @var CrossLinkModelResolver[]|null
|
||||
*/
|
||||
protected ?array $modelResolvers = null;
|
||||
|
||||
public function __construct(
|
||||
protected EntityQueries $queries
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and replace references in the given content.
|
||||
* Calls the handler for each model link detected and replaces the link
|
||||
* with the handler return value if provided.
|
||||
* Returns the resulting content with links replaced.
|
||||
* @param callable(Model):(string|null) $handler
|
||||
*/
|
||||
public function parseLinks(string $content, callable $handler): string
|
||||
{
|
||||
$linkRegex = $this->getLinkRegex();
|
||||
$matches = [];
|
||||
preg_match_all($linkRegex, $content, $matches);
|
||||
|
||||
if (count($matches) < 2) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
foreach ($matches[1] as $link) {
|
||||
$model = $this->linkToModel($link);
|
||||
if ($model) {
|
||||
$result = $handler($model);
|
||||
if ($result !== null) {
|
||||
$content = str_replace($link, $result, $content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and replace references in the given content.
|
||||
* Calls the handler for each reference detected and replaces the link
|
||||
* with the handler return value if provided.
|
||||
* Returns the resulting content string with references replaced.
|
||||
* @param callable(string $type, int $id):(string|null) $handler
|
||||
*/
|
||||
public function parseReferences(string $content, callable $handler): string
|
||||
{
|
||||
$referenceRegex = '/\[\[bsexport:([a-z]+):(\d+)]]/';
|
||||
$matches = [];
|
||||
preg_match_all($referenceRegex, $content, $matches);
|
||||
|
||||
if (count($matches) < 3) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
for ($i = 0; $i < count($matches[0]); $i++) {
|
||||
$referenceText = $matches[0][$i];
|
||||
$type = strtolower($matches[1][$i]);
|
||||
$id = intval($matches[2][$i]);
|
||||
$result = $handler($type, $id);
|
||||
if ($result !== null) {
|
||||
$content = str_replace($referenceText, $result, $content);
|
||||
}
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Attempt to resolve the given link to a model using the instance model resolvers.
|
||||
*/
|
||||
protected function linkToModel(string $link): ?Model
|
||||
{
|
||||
foreach ($this->getModelResolvers() as $resolver) {
|
||||
$model = $resolver->resolve($link);
|
||||
if (!is_null($model)) {
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function getModelResolvers(): array
|
||||
{
|
||||
if (isset($this->modelResolvers)) {
|
||||
return $this->modelResolvers;
|
||||
}
|
||||
|
||||
$this->modelResolvers = [
|
||||
new PagePermalinkModelResolver($this->queries->pages),
|
||||
new PageLinkModelResolver($this->queries->pages),
|
||||
new ChapterLinkModelResolver($this->queries->chapters),
|
||||
new BookLinkModelResolver($this->queries->books),
|
||||
new ImageModelResolver(),
|
||||
new AttachmentModelResolver(),
|
||||
];
|
||||
|
||||
return $this->modelResolvers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the regex to identify links we should handle in content.
|
||||
*/
|
||||
protected function getLinkRegex(): string
|
||||
{
|
||||
$urls = [rtrim(url('/'), '/')];
|
||||
$imageUrl = rtrim(ImageStorage::getPublicUrl('/'), '/');
|
||||
if ($urls[0] !== $imageUrl) {
|
||||
$urls[] = $imageUrl;
|
||||
}
|
||||
|
||||
|
||||
$urlBaseRegex = implode('|', array_map(function ($url) {
|
||||
return preg_quote($url, '/');
|
||||
}, $urls));
|
||||
|
||||
return "/(({$urlBaseRegex}).*?)[\\t\\n\\f>\"'=?#()]/";
|
||||
}
|
||||
}
|
||||
26
app/Exports/ZipExports/ZipUniqueIdRule.php
Normal file
26
app/Exports/ZipExports/ZipUniqueIdRule.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class ZipUniqueIdRule implements ValidationRule
|
||||
{
|
||||
public function __construct(
|
||||
protected ZipValidationHelper $context,
|
||||
protected string $modelType,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($this->context->hasIdBeenUsed($this->modelType, $value)) {
|
||||
$fail('validation.zip_unique')->translate(['attribute' => $attribute]);
|
||||
}
|
||||
}
|
||||
}
|
||||
77
app/Exports/ZipExports/ZipValidationHelper.php
Normal file
77
app/Exports/ZipExports/ZipValidationHelper.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Exports\ZipExports;
|
||||
|
||||
use BookStack\Exports\ZipExports\Models\ZipExportModel;
|
||||
use Illuminate\Validation\Factory;
|
||||
|
||||
class ZipValidationHelper
|
||||
{
|
||||
protected Factory $validationFactory;
|
||||
|
||||
/**
|
||||
* Local store of validated IDs (in format "<type>:<id>". Example: "book:2")
|
||||
* which we can use to check uniqueness.
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
protected array $validatedIds = [];
|
||||
|
||||
public function __construct(
|
||||
public ZipExportReader $zipReader,
|
||||
) {
|
||||
$this->validationFactory = app(Factory::class);
|
||||
}
|
||||
|
||||
public function validateData(array $data, array $rules): array
|
||||
{
|
||||
$messages = $this->validationFactory->make($data, $rules)->errors()->messages();
|
||||
|
||||
foreach ($messages as $key => $message) {
|
||||
$messages[$key] = implode("\n", $message);
|
||||
}
|
||||
|
||||
return $messages;
|
||||
}
|
||||
|
||||
public function fileReferenceRule(array $acceptedMimes = []): ZipFileReferenceRule
|
||||
{
|
||||
return new ZipFileReferenceRule($this, $acceptedMimes);
|
||||
}
|
||||
|
||||
public function uniqueIdRule(string $type): ZipUniqueIdRule
|
||||
{
|
||||
return new ZipUniqueIdRule($this, $type);
|
||||
}
|
||||
|
||||
public function hasIdBeenUsed(string $type, mixed $id): bool
|
||||
{
|
||||
$key = $type . ':' . $id;
|
||||
if (isset($this->validatedIds[$key])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->validatedIds[$key] = true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an array of relation data arrays that are expected
|
||||
* to be for the given ZipExportModel.
|
||||
* @param class-string<ZipExportModel> $model
|
||||
*/
|
||||
public function validateRelations(array $relations, string $model): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($relations as $key => $relationData) {
|
||||
if (is_array($relationData)) {
|
||||
$results[$key] = $model::validate($this, $relationData);
|
||||
} else {
|
||||
$results[$key] = [trans('validation.zip_model_expected', ['type' => gettype($relationData)])];
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
@@ -152,10 +152,8 @@ abstract class Controller extends BaseController
|
||||
|
||||
/**
|
||||
* Log an activity in the system.
|
||||
*
|
||||
* @param string|Loggable $detail
|
||||
*/
|
||||
protected function logActivity(string $type, $detail = ''): void
|
||||
protected function logActivity(string $type, string|Loggable $detail = ''): void
|
||||
{
|
||||
Activity::add($type, $detail);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ class RangeSupportedStream
|
||||
if ($start < 0 || $start > $end) {
|
||||
$this->responseStatus = 416;
|
||||
$this->responseHeaders['Content-Range'] = sprintf('bytes */%s', $this->fileSize);
|
||||
} elseif ($end - $start < $this->fileSize - 1) {
|
||||
} else {
|
||||
$this->responseLength = $end < $this->fileSize ? $end - $start + 1 : -1;
|
||||
$this->responseOffset = $start;
|
||||
$this->responseStatus = 206;
|
||||
|
||||
22
app/References/ModelResolvers/AttachmentModelResolver.php
Normal file
22
app/References/ModelResolvers/AttachmentModelResolver.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\References\ModelResolvers;
|
||||
|
||||
use BookStack\Uploads\Attachment;
|
||||
|
||||
class AttachmentModelResolver implements CrossLinkModelResolver
|
||||
{
|
||||
public function resolve(string $link): ?Attachment
|
||||
{
|
||||
$pattern = '/^' . preg_quote(url('/attachments'), '/') . '\/(\d+)/';
|
||||
$matches = [];
|
||||
$match = preg_match($pattern, $link, $matches);
|
||||
if (!$match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = intval($matches[1]);
|
||||
|
||||
return Attachment::query()->find($id);
|
||||
}
|
||||
}
|
||||
58
app/References/ModelResolvers/ImageModelResolver.php
Normal file
58
app/References/ModelResolvers/ImageModelResolver.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\References\ModelResolvers;
|
||||
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageStorage;
|
||||
|
||||
class ImageModelResolver implements CrossLinkModelResolver
|
||||
{
|
||||
protected ?string $pattern = null;
|
||||
|
||||
public function resolve(string $link): ?Image
|
||||
{
|
||||
$pattern = $this->getUrlPattern();
|
||||
$matches = [];
|
||||
$match = preg_match($pattern, $link, $matches);
|
||||
if (!$match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$path = $matches[2];
|
||||
|
||||
// Strip thumbnail element from path if existing
|
||||
$originalPathSplit = array_filter(explode('/', $path), function (string $part) {
|
||||
$resizedDir = (str_starts_with($part, 'thumbs-') || str_starts_with($part, 'scaled-'));
|
||||
$missingExtension = !str_contains($part, '.');
|
||||
|
||||
return !($resizedDir && $missingExtension);
|
||||
});
|
||||
|
||||
// Build a database-format image path and search for the image entry
|
||||
$fullPath = '/uploads/images/' . ltrim(implode('/', $originalPathSplit), '/');
|
||||
|
||||
return Image::query()->where('path', '=', $fullPath)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the regex pattern to identify image URLs.
|
||||
* Caches the pattern since it requires looking up to settings/config.
|
||||
*/
|
||||
protected function getUrlPattern(): string
|
||||
{
|
||||
if ($this->pattern) {
|
||||
return $this->pattern;
|
||||
}
|
||||
|
||||
$urls = [url('/uploads/images')];
|
||||
$baseImageUrl = ImageStorage::getPublicUrl('/uploads/images');
|
||||
if ($baseImageUrl !== $urls[0]) {
|
||||
$urls[] = $baseImageUrl;
|
||||
}
|
||||
|
||||
$imageUrlRegex = implode('|', array_map(fn ($url) => preg_quote($url, '/'), $urls));
|
||||
$this->pattern = '/^(' . $imageUrlRegex . ')\/(.+)/';
|
||||
|
||||
return $this->pattern;
|
||||
}
|
||||
}
|
||||
13
app/Search/Options/ExactSearchOption.php
Normal file
13
app/Search/Options/ExactSearchOption.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search\Options;
|
||||
|
||||
class ExactSearchOption extends SearchOption
|
||||
{
|
||||
public function toString(): string
|
||||
{
|
||||
$escaped = str_replace('\\', '\\\\', $this->value);
|
||||
$escaped = str_replace('"', '\"', $escaped);
|
||||
return ($this->negated ? '-' : '') . '"' . $escaped . '"';
|
||||
}
|
||||
}
|
||||
37
app/Search/Options/FilterSearchOption.php
Normal file
37
app/Search/Options/FilterSearchOption.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search\Options;
|
||||
|
||||
class FilterSearchOption extends SearchOption
|
||||
{
|
||||
protected string $name;
|
||||
|
||||
public function __construct(
|
||||
string $value,
|
||||
string $name,
|
||||
bool $negated = false,
|
||||
) {
|
||||
parent::__construct($value, $negated);
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
$valueText = ($this->value ? ':' . $this->value : '');
|
||||
$filterBrace = '{' . $this->name . $valueText . '}';
|
||||
return ($this->negated ? '-' : '') . $filterBrace;
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public static function fromContentString(string $value, bool $negated = false): self
|
||||
{
|
||||
$explodedFilter = explode(':', $value, 2);
|
||||
$filterValue = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
|
||||
$filterName = $explodedFilter[0];
|
||||
return new self($filterValue, $filterName, $negated);
|
||||
}
|
||||
}
|
||||
26
app/Search/Options/SearchOption.php
Normal file
26
app/Search/Options/SearchOption.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search\Options;
|
||||
|
||||
abstract class SearchOption
|
||||
{
|
||||
public function __construct(
|
||||
public string $value,
|
||||
public bool $negated = false,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the key used for this option when used in a map.
|
||||
* Null indicates to use the index of the containing array.
|
||||
*/
|
||||
public function getKey(): string|null
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the search string representation for this search option.
|
||||
*/
|
||||
abstract public function toString(): string;
|
||||
}
|
||||
37
app/Search/Options/TagSearchOption.php
Normal file
37
app/Search/Options/TagSearchOption.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search\Options;
|
||||
|
||||
class TagSearchOption extends SearchOption
|
||||
{
|
||||
/**
|
||||
* Acceptable operators to be used within a tag search option.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
|
||||
|
||||
public function toString(): string
|
||||
{
|
||||
return ($this->negated ? '-' : '') . "[{$this->value}]";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{name: string, operator: string, value: string}
|
||||
*/
|
||||
public function getParts(): array
|
||||
{
|
||||
$operatorRegex = implode('|', array_map(fn($op) => preg_quote($op), $this->queryOperators));
|
||||
preg_match('/^(.*?)((' . $operatorRegex . ')(.*?))?$/', $this->value, $tagSplit);
|
||||
|
||||
$extractedOperator = count($tagSplit) > 2 ? $tagSplit[3] : '';
|
||||
$tagOperator = in_array($extractedOperator, $this->queryOperators) ? $extractedOperator : '=';
|
||||
$tagValue = count($tagSplit) > 3 ? $tagSplit[4] : '';
|
||||
|
||||
return [
|
||||
'name' => $tagSplit[1],
|
||||
'operator' => $tagOperator,
|
||||
'value' => $tagValue,
|
||||
];
|
||||
}
|
||||
}
|
||||
11
app/Search/Options/TermSearchOption.php
Normal file
11
app/Search/Options/TermSearchOption.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search\Options;
|
||||
|
||||
class TermSearchOption extends SearchOption
|
||||
{
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -9,21 +9,18 @@ use Illuminate\Http\Request;
|
||||
|
||||
class SearchApiController extends ApiController
|
||||
{
|
||||
protected SearchRunner $searchRunner;
|
||||
protected SearchResultsFormatter $resultsFormatter;
|
||||
|
||||
protected $rules = [
|
||||
'all' => [
|
||||
'query' => ['required'],
|
||||
'page' => ['integer', 'min:1'],
|
||||
'count' => ['integer', 'min:1', 'max:100'],
|
||||
'query' => ['required'],
|
||||
'page' => ['integer', 'min:1'],
|
||||
'count' => ['integer', 'min:1', 'max:100'],
|
||||
],
|
||||
];
|
||||
|
||||
public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter)
|
||||
{
|
||||
$this->searchRunner = $searchRunner;
|
||||
$this->resultsFormatter = $resultsFormatter;
|
||||
public function __construct(
|
||||
protected SearchRunner $searchRunner,
|
||||
protected SearchResultsFormatter $resultsFormatter
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,16 +47,16 @@ class SearchApiController extends ApiController
|
||||
$this->resultsFormatter->format($results['results']->all(), $options);
|
||||
|
||||
$data = (new ApiEntityListFormatter($results['results']->all()))
|
||||
->withType()->withTags()
|
||||
->withType()->withTags()->withParents()
|
||||
->withField('preview_html', function (Entity $entity) {
|
||||
return [
|
||||
'name' => (string) $entity->getAttribute('preview_name'),
|
||||
'name' => (string) $entity->getAttribute('preview_name'),
|
||||
'content' => (string) $entity->getAttribute('preview_content'),
|
||||
];
|
||||
})->format();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'data' => $data,
|
||||
'total' => $results['total'],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class SearchIndex
|
||||
{
|
||||
$this->deleteEntityTerms($entity);
|
||||
$terms = $this->entityToTermDataArray($entity);
|
||||
SearchTerm::query()->insert($terms);
|
||||
$this->insertTerms($terms);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,10 +46,7 @@ class SearchIndex
|
||||
array_push($terms, ...$entityTerms);
|
||||
}
|
||||
|
||||
$chunkedTerms = array_chunk($terms, 500);
|
||||
foreach ($chunkedTerms as $termChunk) {
|
||||
SearchTerm::query()->insert($termChunk);
|
||||
}
|
||||
$this->insertTerms($terms);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,6 +96,19 @@ class SearchIndex
|
||||
$entity->searchTerms()->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the given terms into the database.
|
||||
* Chunks through the given terms to remain within database limits.
|
||||
* @param array[] $terms
|
||||
*/
|
||||
protected function insertTerms(array $terms): void
|
||||
{
|
||||
$chunkedTerms = array_chunk($terms, 500);
|
||||
foreach ($chunkedTerms as $termChunk) {
|
||||
SearchTerm::query()->insert($termChunk);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a scored term array from the given text, where the keys are the terms
|
||||
* and the values are their scores.
|
||||
|
||||
82
app/Search/SearchOptionSet.php
Normal file
82
app/Search/SearchOptionSet.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search;
|
||||
|
||||
use BookStack\Search\Options\SearchOption;
|
||||
|
||||
/**
|
||||
* @template T of SearchOption
|
||||
*/
|
||||
class SearchOptionSet
|
||||
{
|
||||
/**
|
||||
* @var T[]
|
||||
*/
|
||||
protected array $options = [];
|
||||
|
||||
public function __construct(array $options = [])
|
||||
{
|
||||
$this->options = $options;
|
||||
}
|
||||
|
||||
public function toValueArray(): array
|
||||
{
|
||||
return array_map(fn(SearchOption $option) => $option->value, $this->options);
|
||||
}
|
||||
|
||||
public function toValueMap(): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($this->options as $index => $option) {
|
||||
$key = $option->getKey() ?? $index;
|
||||
$map[$key] = $option->value;
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
public function merge(SearchOptionSet $set): self
|
||||
{
|
||||
return new self(array_merge($this->options, $set->options));
|
||||
}
|
||||
|
||||
public function filterEmpty(): self
|
||||
{
|
||||
$filteredOptions = array_values(array_filter($this->options, fn (SearchOption $option) => !empty($option->value)));
|
||||
return new self($filteredOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<SearchOption> $class
|
||||
*/
|
||||
public static function fromValueArray(array $values, string $class): self
|
||||
{
|
||||
$options = array_map(fn($val) => new $class($val), $values);
|
||||
return new self($options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return T[]
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return $this->options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return self<T>
|
||||
*/
|
||||
public function negated(): self
|
||||
{
|
||||
$values = array_values(array_filter($this->options, fn (SearchOption $option) => $option->negated));
|
||||
return new self($values);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return self<T>
|
||||
*/
|
||||
public function nonNegated(): self
|
||||
{
|
||||
$values = array_values(array_filter($this->options, fn (SearchOption $option) => !$option->negated));
|
||||
return new self($values);
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,39 @@
|
||||
|
||||
namespace BookStack\Search;
|
||||
|
||||
use BookStack\Search\Options\ExactSearchOption;
|
||||
use BookStack\Search\Options\FilterSearchOption;
|
||||
use BookStack\Search\Options\SearchOption;
|
||||
use BookStack\Search\Options\TagSearchOption;
|
||||
use BookStack\Search\Options\TermSearchOption;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SearchOptions
|
||||
{
|
||||
public array $searches = [];
|
||||
public array $exacts = [];
|
||||
public array $tags = [];
|
||||
public array $filters = [];
|
||||
/** @var SearchOptionSet<TermSearchOption> */
|
||||
public SearchOptionSet $searches;
|
||||
/** @var SearchOptionSet<ExactSearchOption> */
|
||||
public SearchOptionSet $exacts;
|
||||
/** @var SearchOptionSet<TagSearchOption> */
|
||||
public SearchOptionSet $tags;
|
||||
/** @var SearchOptionSet<FilterSearchOption> */
|
||||
public SearchOptionSet $filters;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->searches = new SearchOptionSet();
|
||||
$this->exacts = new SearchOptionSet();
|
||||
$this->tags = new SearchOptionSet();
|
||||
$this->filters = new SearchOptionSet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new instance from a search string.
|
||||
*/
|
||||
public static function fromString(string $search): self
|
||||
{
|
||||
$decoded = static::decode($search);
|
||||
$instance = new SearchOptions();
|
||||
foreach ($decoded as $type => $value) {
|
||||
$instance->$type = $value;
|
||||
}
|
||||
|
||||
$instance = new self();
|
||||
$instance->addOptionsFromString($search);
|
||||
return $instance;
|
||||
}
|
||||
|
||||
@@ -41,46 +54,64 @@ class SearchOptions
|
||||
}
|
||||
|
||||
$instance = new SearchOptions();
|
||||
$inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
|
||||
$inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags', 'extras']);
|
||||
|
||||
$parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? '');
|
||||
$instance->searches = array_filter($parsedStandardTerms['terms']);
|
||||
$instance->exacts = array_filter($parsedStandardTerms['exacts']);
|
||||
|
||||
array_push($instance->exacts, ...array_filter($inputs['exact'] ?? []));
|
||||
|
||||
$instance->tags = array_filter($inputs['tags'] ?? []);
|
||||
$inputExacts = array_filter($inputs['exact'] ?? []);
|
||||
$instance->searches = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['terms']), TermSearchOption::class);
|
||||
$instance->exacts = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['exacts']), ExactSearchOption::class);
|
||||
$instance->exacts = $instance->exacts->merge(SearchOptionSet::fromValueArray($inputExacts, ExactSearchOption::class));
|
||||
$instance->tags = SearchOptionSet::fromValueArray(array_filter($inputs['tags'] ?? []), TagSearchOption::class);
|
||||
|
||||
$cleanedFilters = [];
|
||||
foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
|
||||
if (empty($filterVal)) {
|
||||
continue;
|
||||
}
|
||||
$instance->filters[$filterKey] = $filterVal === 'true' ? '' : $filterVal;
|
||||
$cleanedFilterVal = $filterVal === 'true' ? '' : $filterVal;
|
||||
$cleanedFilters[] = new FilterSearchOption($cleanedFilterVal, $filterKey);
|
||||
}
|
||||
|
||||
if (isset($inputs['types']) && count($inputs['types']) < 4) {
|
||||
$instance->filters['type'] = implode('|', $inputs['types']);
|
||||
$cleanedFilters[] = new FilterSearchOption(implode('|', $inputs['types']), 'type');
|
||||
}
|
||||
|
||||
$instance->filters = new SearchOptionSet($cleanedFilters);
|
||||
|
||||
// Parse and merge in extras if provided
|
||||
if (!empty($inputs['extras'])) {
|
||||
$extras = static::fromString($inputs['extras']);
|
||||
$instance->searches = $instance->searches->merge($extras->searches);
|
||||
$instance->exacts = $instance->exacts->merge($extras->exacts);
|
||||
$instance->tags = $instance->tags->merge($extras->tags);
|
||||
$instance->filters = $instance->filters->merge($extras->filters);
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a search string into an array of terms.
|
||||
* Decode a search string and add its contents to this instance.
|
||||
*/
|
||||
protected static function decode(string $searchString): array
|
||||
protected function addOptionsFromString(string $searchString): void
|
||||
{
|
||||
/** @var array<string, SearchOption[]> $terms */
|
||||
$terms = [
|
||||
'searches' => [],
|
||||
'exacts' => [],
|
||||
'tags' => [],
|
||||
'filters' => [],
|
||||
];
|
||||
|
||||
$patterns = [
|
||||
'exacts' => '/"((?:\\\\.|[^"\\\\])*)"/',
|
||||
'tags' => '/\[(.*?)\]/',
|
||||
'filters' => '/\{(.*?)\}/',
|
||||
'exacts' => '/-?"((?:\\\\.|[^"\\\\])*)"/',
|
||||
'tags' => '/-?\[(.*?)\]/',
|
||||
'filters' => '/-?\{(.*?)\}/',
|
||||
];
|
||||
|
||||
$constructors = [
|
||||
'exacts' => fn(string $value, bool $negated) => new ExactSearchOption($value, $negated),
|
||||
'tags' => fn(string $value, bool $negated) => new TagSearchOption($value, $negated),
|
||||
'filters' => fn(string $value, bool $negated) => FilterSearchOption::fromContentString($value, $negated),
|
||||
];
|
||||
|
||||
// Parse special terms
|
||||
@@ -88,34 +119,32 @@ class SearchOptions
|
||||
$matches = [];
|
||||
preg_match_all($pattern, $searchString, $matches);
|
||||
if (count($matches) > 0) {
|
||||
$terms[$termType] = $matches[1];
|
||||
foreach ($matches[1] as $index => $value) {
|
||||
$negated = str_starts_with($matches[0][$index], '-');
|
||||
$terms[$termType][] = $constructors[$termType]($value, $negated);
|
||||
}
|
||||
$searchString = preg_replace($pattern, '', $searchString);
|
||||
}
|
||||
}
|
||||
|
||||
// Unescape exacts and backslash escapes
|
||||
foreach ($terms['exacts'] as $index => $exact) {
|
||||
$terms['exacts'][$index] = static::decodeEscapes($exact);
|
||||
foreach ($terms['exacts'] as $exact) {
|
||||
$exact->value = static::decodeEscapes($exact->value);
|
||||
}
|
||||
|
||||
// Parse standard terms
|
||||
$parsedStandardTerms = static::parseStandardTermString($searchString);
|
||||
array_push($terms['searches'], ...$parsedStandardTerms['terms']);
|
||||
array_push($terms['exacts'], ...$parsedStandardTerms['exacts']);
|
||||
$this->searches = $this->searches
|
||||
->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['terms'], TermSearchOption::class))
|
||||
->filterEmpty();
|
||||
$this->exacts = $this->exacts
|
||||
->merge(new SearchOptionSet($terms['exacts']))
|
||||
->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['exacts'], ExactSearchOption::class))
|
||||
->filterEmpty();
|
||||
|
||||
// Split filter values out
|
||||
$splitFilters = [];
|
||||
foreach ($terms['filters'] as $filter) {
|
||||
$explodedFilter = explode(':', $filter, 2);
|
||||
$splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
|
||||
}
|
||||
$terms['filters'] = $splitFilters;
|
||||
|
||||
// Filter down terms where required
|
||||
$terms['exacts'] = array_filter($terms['exacts']);
|
||||
$terms['searches'] = array_filter($terms['searches']);
|
||||
|
||||
return $terms;
|
||||
// Add tags & filters
|
||||
$this->tags = $this->tags->merge(new SearchOptionSet($terms['tags']));
|
||||
$this->filters = $this->filters->merge(new SearchOptionSet($terms['filters']));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,7 +204,9 @@ class SearchOptions
|
||||
*/
|
||||
public function setFilter(string $filterName, string $filterValue = ''): void
|
||||
{
|
||||
$this->filters[$filterName] = $filterValue;
|
||||
$this->filters = $this->filters->merge(
|
||||
new SearchOptionSet([new FilterSearchOption($filterValue, $filterName)])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,22 +214,43 @@ class SearchOptions
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
$parts = $this->searches;
|
||||
$options = [
|
||||
...$this->searches->all(),
|
||||
...$this->exacts->all(),
|
||||
...$this->tags->all(),
|
||||
...$this->filters->all(),
|
||||
];
|
||||
|
||||
foreach ($this->exacts as $term) {
|
||||
$escaped = str_replace('\\', '\\\\', $term);
|
||||
$escaped = str_replace('"', '\"', $escaped);
|
||||
$parts[] = '"' . $escaped . '"';
|
||||
}
|
||||
|
||||
foreach ($this->tags as $term) {
|
||||
$parts[] = "[{$term}]";
|
||||
}
|
||||
|
||||
foreach ($this->filters as $filterName => $filterVal) {
|
||||
$parts[] = '{' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
|
||||
}
|
||||
$parts = array_map(fn(SearchOption $o) => $o->toString(), $options);
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the search options that don't have UI controls provided for.
|
||||
* Provided back as a key => value array with the keys being expected
|
||||
* input names for a search form, and values being the option value.
|
||||
*/
|
||||
public function getAdditionalOptionsString(): string
|
||||
{
|
||||
$options = [];
|
||||
|
||||
// Handle filters without UI support
|
||||
$userFilters = ['updated_by', 'created_by', 'owned_by'];
|
||||
$unsupportedFilters = ['is_template', 'sort_by'];
|
||||
foreach ($this->filters->all() as $filter) {
|
||||
if (in_array($filter->getKey(), $userFilters, true) && $filter->value !== null && $filter->value !== 'me') {
|
||||
$options[] = $filter;
|
||||
} else if (in_array($filter->getKey(), $unsupportedFilters, true)) {
|
||||
$options[] = $filter;
|
||||
}
|
||||
}
|
||||
|
||||
// Negated items
|
||||
array_push($options, ...$this->exacts->negated()->all());
|
||||
array_push($options, ...$this->tags->negated()->all());
|
||||
array_push($options, ...$this->filters->negated()->all());
|
||||
|
||||
return implode(' ', array_map(fn(SearchOption $o) => $o->toString(), $options));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,12 @@ class SearchResultsFormatter
|
||||
* Update the given entity model to set attributes used for previews of the item
|
||||
* primarily within search result lists.
|
||||
*/
|
||||
protected function setSearchPreview(Entity $entity, SearchOptions $options)
|
||||
protected function setSearchPreview(Entity $entity, SearchOptions $options): void
|
||||
{
|
||||
$textProperty = $entity->textField;
|
||||
$textContent = $entity->$textProperty;
|
||||
$terms = array_merge($options->exacts, $options->searches);
|
||||
$relevantSearchOptions = $options->exacts->merge($options->searches);
|
||||
$terms = $relevantSearchOptions->toValueArray();
|
||||
|
||||
$originalContentByNewAttribute = [
|
||||
'preview_name' => $entity->name,
|
||||
|
||||
@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Search\Options\TagSearchOption;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
@@ -16,31 +17,21 @@ use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use SplObjectStorage;
|
||||
use WeakMap;
|
||||
|
||||
class SearchRunner
|
||||
{
|
||||
/**
|
||||
* Acceptable operators to be used in a query.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
|
||||
|
||||
/**
|
||||
* Retain a cache of score adjusted terms for specific search options.
|
||||
* From PHP>=8 this can be made into a WeakMap instead.
|
||||
*
|
||||
* @var SplObjectStorage
|
||||
*/
|
||||
protected $termAdjustmentCache;
|
||||
protected WeakMap $termAdjustmentCache;
|
||||
|
||||
public function __construct(
|
||||
protected EntityProvider $entityProvider,
|
||||
protected PermissionApplicator $permissions,
|
||||
protected EntityQueries $entityQueries,
|
||||
) {
|
||||
$this->termAdjustmentCache = new SplObjectStorage();
|
||||
$this->termAdjustmentCache = new WeakMap();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,10 +46,11 @@ class SearchRunner
|
||||
$entityTypes = array_keys($this->entityProvider->all());
|
||||
$entityTypesToSearch = $entityTypes;
|
||||
|
||||
$filterMap = $searchOpts->filters->toValueMap();
|
||||
if ($entityType !== 'all') {
|
||||
$entityTypesToSearch = [$entityType];
|
||||
} elseif (isset($searchOpts->filters['type'])) {
|
||||
$entityTypesToSearch = explode('|', $searchOpts->filters['type']);
|
||||
} elseif (isset($filterMap['type'])) {
|
||||
$entityTypesToSearch = explode('|', $filterMap['type']);
|
||||
}
|
||||
|
||||
$results = collect();
|
||||
@@ -97,7 +89,8 @@ class SearchRunner
|
||||
{
|
||||
$opts = SearchOptions::fromString($searchString);
|
||||
$entityTypes = ['page', 'chapter'];
|
||||
$entityTypesToSearch = isset($opts->filters['type']) ? explode('|', $opts->filters['type']) : $entityTypes;
|
||||
$filterMap = $opts->filters->toValueMap();
|
||||
$entityTypesToSearch = isset($filterMap['type']) ? explode('|', $filterMap['type']) : $entityTypes;
|
||||
|
||||
$results = collect();
|
||||
foreach ($entityTypesToSearch as $entityType) {
|
||||
@@ -161,24 +154,26 @@ class SearchRunner
|
||||
$this->applyTermSearch($entityQuery, $searchOpts, $entityType);
|
||||
|
||||
// Handle exact term matching
|
||||
foreach ($searchOpts->exacts as $inputTerm) {
|
||||
$entityQuery->where(function (EloquentBuilder $query) use ($inputTerm, $entityModelInstance) {
|
||||
$inputTerm = str_replace('\\', '\\\\', $inputTerm);
|
||||
foreach ($searchOpts->exacts->all() as $exact) {
|
||||
$filter = function (EloquentBuilder $query) use ($exact, $entityModelInstance) {
|
||||
$inputTerm = str_replace('\\', '\\\\', $exact->value);
|
||||
$query->where('name', 'like', '%' . $inputTerm . '%')
|
||||
->orWhere($entityModelInstance->textField, 'like', '%' . $inputTerm . '%');
|
||||
});
|
||||
};
|
||||
|
||||
$exact->negated ? $entityQuery->whereNot($filter) : $entityQuery->where($filter);
|
||||
}
|
||||
|
||||
// Handle tag searches
|
||||
foreach ($searchOpts->tags as $inputTerm) {
|
||||
$this->applyTagSearch($entityQuery, $inputTerm);
|
||||
foreach ($searchOpts->tags->all() as $tagOption) {
|
||||
$this->applyTagSearch($entityQuery, $tagOption);
|
||||
}
|
||||
|
||||
// Handle filters
|
||||
foreach ($searchOpts->filters as $filterTerm => $filterValue) {
|
||||
$functionName = Str::camel('filter_' . $filterTerm);
|
||||
foreach ($searchOpts->filters->all() as $filterOption) {
|
||||
$functionName = Str::camel('filter_' . $filterOption->getKey());
|
||||
if (method_exists($this, $functionName)) {
|
||||
$this->$functionName($entityQuery, $entityModelInstance, $filterValue);
|
||||
$this->$functionName($entityQuery, $entityModelInstance, $filterOption->value, $filterOption->negated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +185,7 @@ class SearchRunner
|
||||
*/
|
||||
protected function applyTermSearch(EloquentBuilder $entityQuery, SearchOptions $options, string $entityType): void
|
||||
{
|
||||
$terms = $options->searches;
|
||||
$terms = $options->searches->toValueArray();
|
||||
if (count($terms) === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -209,8 +204,8 @@ class SearchRunner
|
||||
$subQuery->where('entity_type', '=', $entityType);
|
||||
$subQuery->where(function (Builder $query) use ($terms) {
|
||||
foreach ($terms as $inputTerm) {
|
||||
$inputTerm = str_replace('\\', '\\\\', $inputTerm);
|
||||
$query->orWhere('term', 'like', $inputTerm . '%');
|
||||
$escapedTerm = str_replace('\\', '\\\\', $inputTerm);
|
||||
$query->orWhere('term', 'like', $escapedTerm . '%');
|
||||
}
|
||||
});
|
||||
$subQuery->groupBy('entity_type', 'entity_id');
|
||||
@@ -264,7 +259,7 @@ class SearchRunner
|
||||
$whenStatements = [];
|
||||
$whenBindings = [];
|
||||
|
||||
foreach ($options->searches as $term) {
|
||||
foreach ($options->searches->toValueArray() as $term) {
|
||||
$whenStatements[] = 'WHEN term LIKE ? THEN ?';
|
||||
$whenBindings[] = $term . '%';
|
||||
$whenBindings[] = $term;
|
||||
@@ -310,179 +305,165 @@ class SearchRunner
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available query operators as a regex escaped list.
|
||||
* Apply a tag search term onto an entity query.
|
||||
*/
|
||||
protected function getRegexEscapedOperators(): string
|
||||
protected function applyTagSearch(EloquentBuilder $query, TagSearchOption $option): void
|
||||
{
|
||||
$escapedOperators = [];
|
||||
foreach ($this->queryOperators as $operator) {
|
||||
$escapedOperators[] = preg_quote($operator);
|
||||
}
|
||||
$filter = function (EloquentBuilder $query) use ($option): void {
|
||||
$tagParts = $option->getParts();
|
||||
if (empty($tagParts['operator']) || empty($tagParts['value'])) {
|
||||
$query->where('name', '=', $tagParts['name']);
|
||||
return;
|
||||
}
|
||||
|
||||
return implode('|', $escapedOperators);
|
||||
if (!empty($tagParts['name'])) {
|
||||
$query->where('name', '=', $tagParts['name']);
|
||||
}
|
||||
|
||||
if (is_numeric($tagParts['value']) && $tagParts['operator'] !== 'like') {
|
||||
// We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
|
||||
// search the value as a string which prevents being able to do number-based operations
|
||||
// on the tag values. We ensure it has a numeric value and then cast it just to be sure.
|
||||
/** @var Connection $connection */
|
||||
$connection = $query->getConnection();
|
||||
$quotedValue = (float) trim($connection->getPdo()->quote($tagParts['value']), "'");
|
||||
$query->whereRaw("value {$tagParts['operator']} {$quotedValue}");
|
||||
} else if ($tagParts['operator'] === 'like') {
|
||||
$query->where('value', $tagParts['operator'], str_replace('\\', '\\\\', $tagParts['value']));
|
||||
} else {
|
||||
$query->where('value', $tagParts['operator'], $tagParts['value']);
|
||||
}
|
||||
};
|
||||
|
||||
$option->negated ? $query->whereDoesntHave('tags', $filter) : $query->whereHas('tags', $filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a tag search term onto a entity query.
|
||||
*/
|
||||
protected function applyTagSearch(EloquentBuilder $query, string $tagTerm): EloquentBuilder
|
||||
protected function applyNegatableWhere(EloquentBuilder $query, bool $negated, string $column, string $operator, mixed $value): void
|
||||
{
|
||||
preg_match('/^(.*?)((' . $this->getRegexEscapedOperators() . ')(.*?))?$/', $tagTerm, $tagSplit);
|
||||
$query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
|
||||
$tagName = $tagSplit[1];
|
||||
$tagOperator = count($tagSplit) > 2 ? $tagSplit[3] : '';
|
||||
$tagValue = count($tagSplit) > 3 ? $tagSplit[4] : '';
|
||||
$validOperator = in_array($tagOperator, $this->queryOperators);
|
||||
if (!empty($tagOperator) && !empty($tagValue) && $validOperator) {
|
||||
if (!empty($tagName)) {
|
||||
$query->where('name', '=', $tagName);
|
||||
}
|
||||
if (is_numeric($tagValue) && $tagOperator !== 'like') {
|
||||
// We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
|
||||
// search the value as a string which prevents being able to do number-based operations
|
||||
// on the tag values. We ensure it has a numeric value and then cast it just to be sure.
|
||||
/** @var Connection $connection */
|
||||
$connection = $query->getConnection();
|
||||
$tagValue = (float) trim($connection->getPdo()->quote($tagValue), "'");
|
||||
$query->whereRaw("value {$tagOperator} {$tagValue}");
|
||||
} else {
|
||||
if ($tagOperator === 'like') {
|
||||
$tagValue = str_replace('\\', '\\\\', $tagValue);
|
||||
}
|
||||
$query->where('value', $tagOperator, $tagValue);
|
||||
}
|
||||
} else {
|
||||
$query->where('name', '=', $tagName);
|
||||
}
|
||||
});
|
||||
|
||||
return $query;
|
||||
if ($negated) {
|
||||
$query->whereNot($column, $operator, $value);
|
||||
} else {
|
||||
$query->where($column, $operator, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom entity search filters.
|
||||
*/
|
||||
protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, $input): void
|
||||
protected function filterUpdatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
|
||||
{
|
||||
try {
|
||||
$date = date_create($input);
|
||||
$query->where('updated_at', '>=', $date);
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
$date = date_create($input);
|
||||
$this->applyNegatableWhere($query, $negated, 'updated_at', '>=', $date);
|
||||
}
|
||||
|
||||
protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, $input): void
|
||||
protected function filterUpdatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
|
||||
{
|
||||
try {
|
||||
$date = date_create($input);
|
||||
$query->where('updated_at', '<', $date);
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
$date = date_create($input);
|
||||
$this->applyNegatableWhere($query, $negated, 'updated_at', '<', $date);
|
||||
}
|
||||
|
||||
protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, $input): void
|
||||
protected function filterCreatedAfter(EloquentBuilder $query, Entity $model, string $input, bool $negated): void
|
||||
{
|
||||
try {
|
||||
$date = date_create($input);
|
||||
$query->where('created_at', '>=', $date);
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
$date = date_create($input);
|
||||
$this->applyNegatableWhere($query, $negated, 'created_at', '>=', $date);
|
||||
}
|
||||
|
||||
protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, $input)
|
||||
protected function filterCreatedBefore(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
{
|
||||
try {
|
||||
$date = date_create($input);
|
||||
$query->where('created_at', '<', $date);
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
$date = date_create($input);
|
||||
$this->applyNegatableWhere($query, $negated, 'created_at', '<', $date);
|
||||
}
|
||||
|
||||
protected function filterCreatedBy(EloquentBuilder $query, Entity $model, $input)
|
||||
protected function filterCreatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
{
|
||||
$userSlug = $input === 'me' ? user()->slug : trim($input);
|
||||
$user = User::query()->where('slug', '=', $userSlug)->first(['id']);
|
||||
if ($user) {
|
||||
$query->where('created_by', '=', $user->id);
|
||||
$this->applyNegatableWhere($query, $negated, 'created_by', '=', $user->id);
|
||||
}
|
||||
}
|
||||
|
||||
protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, $input)
|
||||
protected function filterUpdatedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
{
|
||||
$userSlug = $input === 'me' ? user()->slug : trim($input);
|
||||
$user = User::query()->where('slug', '=', $userSlug)->first(['id']);
|
||||
if ($user) {
|
||||
$query->where('updated_by', '=', $user->id);
|
||||
$this->applyNegatableWhere($query, $negated, 'updated_by', '=', $user->id);
|
||||
}
|
||||
}
|
||||
|
||||
protected function filterOwnedBy(EloquentBuilder $query, Entity $model, $input)
|
||||
protected function filterOwnedBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
{
|
||||
$userSlug = $input === 'me' ? user()->slug : trim($input);
|
||||
$user = User::query()->where('slug', '=', $userSlug)->first(['id']);
|
||||
if ($user) {
|
||||
$query->where('owned_by', '=', $user->id);
|
||||
$this->applyNegatableWhere($query, $negated, 'owned_by', '=', $user->id);
|
||||
}
|
||||
}
|
||||
|
||||
protected function filterInName(EloquentBuilder $query, Entity $model, $input)
|
||||
protected function filterInName(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
{
|
||||
$query->where('name', 'like', '%' . $input . '%');
|
||||
$this->applyNegatableWhere($query, $negated, 'name', 'like', '%' . $input . '%');
|
||||
}
|
||||
|
||||
protected function filterInTitle(EloquentBuilder $query, Entity $model, $input)
|
||||
protected function filterInTitle(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
{
|
||||
$this->filterInName($query, $model, $input);
|
||||
$this->filterInName($query, $model, $input, $negated);
|
||||
}
|
||||
|
||||
protected function filterInBody(EloquentBuilder $query, Entity $model, $input)
|
||||
protected function filterInBody(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
{
|
||||
$query->where($model->textField, 'like', '%' . $input . '%');
|
||||
$this->applyNegatableWhere($query, $negated, $model->textField, 'like', '%' . $input . '%');
|
||||
}
|
||||
|
||||
protected function filterIsRestricted(EloquentBuilder $query, Entity $model, $input)
|
||||
protected function filterIsRestricted(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
{
|
||||
$query->whereHas('permissions');
|
||||
$negated ? $query->whereDoesntHave('permissions') : $query->whereHas('permissions');
|
||||
}
|
||||
|
||||
protected function filterViewedByMe(EloquentBuilder $query, Entity $model, $input)
|
||||
protected function filterViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
{
|
||||
$query->whereHas('views', function ($query) {
|
||||
$filter = function ($query) {
|
||||
$query->where('user_id', '=', user()->id);
|
||||
});
|
||||
};
|
||||
|
||||
$negated ? $query->whereDoesntHave('views', $filter) : $query->whereHas('views', $filter);
|
||||
}
|
||||
|
||||
protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, $input)
|
||||
protected function filterNotViewedByMe(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
{
|
||||
$query->whereDoesntHave('views', function ($query) {
|
||||
$filter = function ($query) {
|
||||
$query->where('user_id', '=', user()->id);
|
||||
});
|
||||
};
|
||||
|
||||
$negated ? $query->whereHas('views', $filter) : $query->whereDoesntHave('views', $filter);
|
||||
}
|
||||
|
||||
protected function filterIsTemplate(EloquentBuilder $query, Entity $model, $input)
|
||||
protected function filterIsTemplate(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
{
|
||||
if ($model instanceof Page) {
|
||||
$query->where('template', '=', true);
|
||||
$this->applyNegatableWhere($query, $negated, 'template', '=', true);
|
||||
}
|
||||
}
|
||||
|
||||
protected function filterSortBy(EloquentBuilder $query, Entity $model, $input)
|
||||
protected function filterSortBy(EloquentBuilder $query, Entity $model, string $input, bool $negated)
|
||||
{
|
||||
$functionName = Str::camel('sort_by_' . $input);
|
||||
if (method_exists($this, $functionName)) {
|
||||
$this->$functionName($query, $model);
|
||||
$this->$functionName($query, $model, $negated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorting filter options.
|
||||
*/
|
||||
protected function sortByLastCommented(EloquentBuilder $query, Entity $model)
|
||||
protected function sortByLastCommented(EloquentBuilder $query, Entity $model, bool $negated)
|
||||
{
|
||||
$commentsTable = DB::getTablePrefix() . 'comments';
|
||||
$morphClass = str_replace('\\', '\\\\', $model->getMorphClass());
|
||||
$commentQuery = DB::raw('(SELECT c1.entity_id, c1.entity_type, c1.created_at as last_commented FROM ' . $commentsTable . ' c1 LEFT JOIN ' . $commentsTable . ' c2 ON (c1.entity_id = c2.entity_id AND c1.entity_type = c2.entity_type AND c1.created_at < c2.created_at) WHERE c1.entity_type = \'' . $morphClass . '\' AND c2.created_at IS NULL) as comments');
|
||||
|
||||
$query->join($commentQuery, $model->getTable() . '.id', '=', 'comments.entity_id')->orderBy('last_commented', 'desc');
|
||||
$query->join($commentQuery, $model->getTable() . '.id', '=', DB::raw('comments.entity_id'))
|
||||
->orderBy('last_commented', $negated ? 'asc' : 'desc');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ use Illuminate\Http\Request;
|
||||
|
||||
class SettingController extends Controller
|
||||
{
|
||||
protected array $settingCategories = ['features', 'customization', 'registration'];
|
||||
|
||||
/**
|
||||
* Handle requests to the settings index path.
|
||||
*/
|
||||
@@ -31,7 +29,7 @@ class SettingController extends Controller
|
||||
// Get application version
|
||||
$version = trim(file_get_contents(base_path('version')));
|
||||
|
||||
return view('settings.' . $category, [
|
||||
return view('settings.categories.' . $category, [
|
||||
'category' => $category,
|
||||
'version' => $version,
|
||||
'guestUser' => User::getGuest(),
|
||||
@@ -59,7 +57,7 @@ class SettingController extends Controller
|
||||
|
||||
protected function ensureCategoryExists(string $category): void
|
||||
{
|
||||
if (!in_array($category, $this->settingCategories)) {
|
||||
if (!view()->exists('settings.categories.' . $category)) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class LocaleManager
|
||||
protected array $localeMap = [
|
||||
'ar' => 'ar',
|
||||
'bg' => 'bg_BG',
|
||||
'bn' => 'bn_BD',
|
||||
'bs' => 'bs_BA',
|
||||
'ca' => 'ca',
|
||||
'cs' => 'cs_CZ',
|
||||
@@ -41,6 +42,7 @@ class LocaleManager
|
||||
'hr' => 'hr_HR',
|
||||
'hu' => 'hu_HU',
|
||||
'id' => 'id_ID',
|
||||
'is' => 'is_IS',
|
||||
'it' => 'it_IT',
|
||||
'ja' => 'ja',
|
||||
'ka' => 'ka_GE',
|
||||
@@ -60,6 +62,7 @@ class LocaleManager
|
||||
'sq' => 'sq_AL',
|
||||
'sr' => 'sr_RS',
|
||||
'sv' => 'sv_SE',
|
||||
'tk' => 'tk_TM',
|
||||
'tr' => 'tr_TR',
|
||||
'uk' => 'uk_UA',
|
||||
'uz' => 'uz_UZ',
|
||||
|
||||
@@ -4,62 +4,13 @@ namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem as Storage;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Flysystem\WhitespacePathNormalizer;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class AttachmentService
|
||||
{
|
||||
protected FilesystemManager $fileSystem;
|
||||
|
||||
/**
|
||||
* AttachmentService constructor.
|
||||
*/
|
||||
public function __construct(FilesystemManager $fileSystem)
|
||||
{
|
||||
$this->fileSystem = $fileSystem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage that will be used for storing files.
|
||||
*/
|
||||
protected function getStorageDisk(): Storage
|
||||
{
|
||||
return $this->fileSystem->disk($this->getStorageDiskName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the storage disk to use.
|
||||
*/
|
||||
protected function getStorageDiskName(): string
|
||||
{
|
||||
$storageType = config('filesystems.attachments');
|
||||
|
||||
// Change to our secure-attachment disk if any of the local options
|
||||
// are used to prevent escaping that location.
|
||||
if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') {
|
||||
$storageType = 'local_secure_attachments';
|
||||
}
|
||||
|
||||
return $storageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the originally provided path to fit any disk-specific requirements.
|
||||
* This also ensures the path is kept to the expected root folders.
|
||||
*/
|
||||
protected function adjustPathForStorageDisk(string $path): string
|
||||
{
|
||||
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path));
|
||||
|
||||
if ($this->getStorageDiskName() === 'local_secure_attachments') {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return 'uploads/files/' . $path;
|
||||
public function __construct(
|
||||
protected FileStorage $storage,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,7 +20,7 @@ class AttachmentService
|
||||
*/
|
||||
public function streamAttachmentFromStorage(Attachment $attachment)
|
||||
{
|
||||
return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($attachment->path));
|
||||
return $this->storage->getReadStream($attachment->path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +28,7 @@ class AttachmentService
|
||||
*/
|
||||
public function getAttachmentFileSize(Attachment $attachment): int
|
||||
{
|
||||
return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($attachment->path));
|
||||
return $this->storage->getSize($attachment->path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,16 +116,18 @@ class AttachmentService
|
||||
*/
|
||||
public function updateFile(Attachment $attachment, array $requestData): Attachment
|
||||
{
|
||||
$attachment->name = $requestData['name'];
|
||||
$link = trim($requestData['link'] ?? '');
|
||||
if (isset($requestData['name'])) {
|
||||
$attachment->name = $requestData['name'];
|
||||
}
|
||||
|
||||
$link = trim($requestData['link'] ?? '');
|
||||
if (!empty($link)) {
|
||||
if (!$attachment->external) {
|
||||
$this->deleteFileInStorage($attachment);
|
||||
$attachment->external = true;
|
||||
$attachment->extension = '';
|
||||
}
|
||||
$attachment->path = $requestData['link'];
|
||||
$attachment->path = $link;
|
||||
}
|
||||
|
||||
$attachment->save();
|
||||
@@ -200,15 +153,9 @@ class AttachmentService
|
||||
* Delete a file from the filesystem it sits on.
|
||||
* Cleans any empty leftover folders.
|
||||
*/
|
||||
protected function deleteFileInStorage(Attachment $attachment)
|
||||
public function deleteFileInStorage(Attachment $attachment): void
|
||||
{
|
||||
$storage = $this->getStorageDisk();
|
||||
$dirPath = $this->adjustPathForStorageDisk(dirname($attachment->path));
|
||||
|
||||
$storage->delete($this->adjustPathForStorageDisk($attachment->path));
|
||||
if (count($storage->allFiles($dirPath)) === 0) {
|
||||
$storage->deleteDirectory($dirPath);
|
||||
}
|
||||
$this->storage->delete($attachment->path);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,32 +165,20 @@ class AttachmentService
|
||||
*/
|
||||
protected function putFileInStorage(UploadedFile $uploadedFile): string
|
||||
{
|
||||
$storage = $this->getStorageDisk();
|
||||
$basePath = 'uploads/files/' . date('Y-m-M') . '/';
|
||||
|
||||
$uploadFileName = Str::random(16) . '-' . $uploadedFile->getClientOriginalExtension();
|
||||
while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
|
||||
$uploadFileName = Str::random(3) . $uploadFileName;
|
||||
}
|
||||
|
||||
$attachmentStream = fopen($uploadedFile->getRealPath(), 'r');
|
||||
$attachmentPath = $basePath . $uploadFileName;
|
||||
|
||||
try {
|
||||
$storage->writeStream($this->adjustPathForStorageDisk($attachmentPath), $attachmentStream);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Error when attempting file upload:' . $e->getMessage());
|
||||
|
||||
throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $attachmentPath]));
|
||||
}
|
||||
|
||||
return $attachmentPath;
|
||||
return $this->storage->uploadFile(
|
||||
$uploadedFile,
|
||||
$basePath,
|
||||
$uploadedFile->getClientOriginalExtension(),
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file validation rules for attachments.
|
||||
*/
|
||||
public function getFileValidationRules(): array
|
||||
public static function getFileValidationRules(): array
|
||||
{
|
||||
return ['file', 'max:' . (config('app.upload_limit') * 1000)];
|
||||
}
|
||||
|
||||
@@ -171,16 +171,16 @@ class AttachmentApiController extends ApiController
|
||||
{
|
||||
return [
|
||||
'create' => [
|
||||
'name' => ['required', 'min:1', 'max:255', 'string'],
|
||||
'name' => ['required', 'string', 'min:1', 'max:255'],
|
||||
'uploaded_to' => ['required', 'integer', 'exists:pages,id'],
|
||||
'file' => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()),
|
||||
'link' => ['required_without:file', 'min:1', 'max:2000', 'safe_url'],
|
||||
'link' => ['required_without:file', 'string', 'min:1', 'max:2000', 'safe_url'],
|
||||
],
|
||||
'update' => [
|
||||
'name' => ['min:1', 'max:255', 'string'],
|
||||
'name' => ['string', 'min:1', 'max:255'],
|
||||
'uploaded_to' => ['integer', 'exists:pages,id'],
|
||||
'file' => $this->attachmentService->getFileValidationRules(),
|
||||
'link' => ['min:1', 'max:2000', 'safe_url'],
|
||||
'link' => ['string', 'min:1', 'max:2000', 'safe_url'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
132
app/Uploads/FileStorage.php
Normal file
132
app/Uploads/FileStorage.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Uploads;
|
||||
|
||||
use BookStack\Exceptions\FileUploadException;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem as Storage;
|
||||
use Illuminate\Filesystem\FilesystemAdapter;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use League\Flysystem\WhitespacePathNormalizer;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class FileStorage
|
||||
{
|
||||
public function __construct(
|
||||
protected FilesystemManager $fileSystem,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return resource|null
|
||||
*/
|
||||
public function getReadStream(string $path)
|
||||
{
|
||||
return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($path));
|
||||
}
|
||||
|
||||
public function getSize(string $path): int
|
||||
{
|
||||
return $this->getStorageDisk()->size($this->adjustPathForStorageDisk($path));
|
||||
}
|
||||
|
||||
public function delete(string $path, bool $removeEmptyDir = false): void
|
||||
{
|
||||
$storage = $this->getStorageDisk();
|
||||
$adjustedPath = $this->adjustPathForStorageDisk($path);
|
||||
$dir = dirname($adjustedPath);
|
||||
|
||||
$storage->delete($adjustedPath);
|
||||
if ($removeEmptyDir && count($storage->allFiles($dir)) === 0) {
|
||||
$storage->deleteDirectory($dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws FileUploadException
|
||||
*/
|
||||
public function uploadFile(UploadedFile $file, string $subDirectory, string $suffix, string $extension): string
|
||||
{
|
||||
$storage = $this->getStorageDisk();
|
||||
$basePath = trim($subDirectory, '/') . '/';
|
||||
|
||||
$uploadFileName = Str::random(16) . ($suffix ? "-{$suffix}" : '') . ($extension ? ".{$extension}" : '');
|
||||
while ($storage->exists($this->adjustPathForStorageDisk($basePath . $uploadFileName))) {
|
||||
$uploadFileName = Str::random(3) . $uploadFileName;
|
||||
}
|
||||
|
||||
$fileStream = fopen($file->getRealPath(), 'r');
|
||||
$filePath = $basePath . $uploadFileName;
|
||||
|
||||
try {
|
||||
$storage->writeStream($this->adjustPathForStorageDisk($filePath), $fileStream);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Error when attempting file upload:' . $e->getMessage());
|
||||
|
||||
throw new FileUploadException(trans('errors.path_not_writable', ['filePath' => $filePath]));
|
||||
}
|
||||
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the configured storage is remote from the host of this app.
|
||||
*/
|
||||
public function isRemote(): bool
|
||||
{
|
||||
return $this->getStorageDiskName() === 's3';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actual path on system for the given relative file path.
|
||||
*/
|
||||
public function getSystemPath(string $filePath): string
|
||||
{
|
||||
if ($this->isRemote()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return storage_path('uploads/files/' . ltrim($this->adjustPathForStorageDisk($filePath), '/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage that will be used for storing files.
|
||||
*/
|
||||
protected function getStorageDisk(): Storage
|
||||
{
|
||||
return $this->fileSystem->disk($this->getStorageDiskName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the storage disk to use.
|
||||
*/
|
||||
protected function getStorageDiskName(): string
|
||||
{
|
||||
$storageType = trim(strtolower(config('filesystems.attachments')));
|
||||
|
||||
// Change to our secure-attachment disk if any of the local options
|
||||
// are used to prevent escaping that location.
|
||||
if ($storageType === 'local' || $storageType === 'local_secure' || $storageType === 'local_secure_restricted') {
|
||||
$storageType = 'local_secure_attachments';
|
||||
}
|
||||
|
||||
return $storageType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the originally provided path to fit any disk-specific requirements.
|
||||
* This also ensures the path is kept to the expected root folders.
|
||||
*/
|
||||
protected function adjustPathForStorageDisk(string $path): string
|
||||
{
|
||||
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/files/', '', $path));
|
||||
|
||||
if ($this->getStorageDiskName() === 'local_secure_attachments') {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return 'uploads/files/' . $path;
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,7 @@ class ImageRepo
|
||||
*/
|
||||
public function updateImageFile(Image $image, UploadedFile $file): void
|
||||
{
|
||||
if ($file->getClientOriginalExtension() !== pathinfo($image->path, PATHINFO_EXTENSION)) {
|
||||
if (strtolower($file->getClientOriginalExtension()) !== strtolower(pathinfo($image->path, PATHINFO_EXTENSION))) {
|
||||
throw new ImageUploadException(trans('errors.image_upload_replace_type'));
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ use Exception;
|
||||
use GuzzleHttp\Psr7\Utils;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Intervention\Image\Decoders\BinaryImageDecoder;
|
||||
use Intervention\Image\Drivers\Gd\Decoders\NativeObjectDecoder;
|
||||
use Intervention\Image\Drivers\Gd\Driver;
|
||||
use Intervention\Image\Encoders\AutoEncoder;
|
||||
use Intervention\Image\Encoders\PngEncoder;
|
||||
use Intervention\Image\Interfaces\ImageInterface as InterventionImage;
|
||||
use Intervention\Image\ImageManager;
|
||||
use Intervention\Image\Origin;
|
||||
|
||||
class ImageResizer
|
||||
{
|
||||
@@ -99,7 +101,7 @@ class ImageResizer
|
||||
}
|
||||
|
||||
// If not in cache and thumbnail does not exist, generate thumb and cache path
|
||||
$thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio);
|
||||
$thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio, $this->getExtension($image));
|
||||
$disk->put($thumbFilePath, $thumbData, true);
|
||||
Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
|
||||
|
||||
@@ -120,7 +122,7 @@ class ImageResizer
|
||||
?string $format = null,
|
||||
): string {
|
||||
try {
|
||||
$thumb = $this->interventionFromImageData($imageData);
|
||||
$thumb = $this->interventionFromImageData($imageData, $format);
|
||||
} catch (Exception $e) {
|
||||
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
|
||||
}
|
||||
@@ -154,11 +156,23 @@ class ImageResizer
|
||||
* Performs some manual library usage to ensure image is specifically loaded
|
||||
* from given binary data instead of data being misinterpreted.
|
||||
*/
|
||||
protected function interventionFromImageData(string $imageData): InterventionImage
|
||||
protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage
|
||||
{
|
||||
$manager = new ImageManager(new Driver());
|
||||
|
||||
return $manager->read($imageData, BinaryImageDecoder::class);
|
||||
// Ensure gif images are decoded natively instead of deferring to intervention GIF
|
||||
// handling since we don't need the added animation support.
|
||||
$isGif = $fileType === 'gif';
|
||||
$decoder = $isGif ? NativeObjectDecoder::class : BinaryImageDecoder::class;
|
||||
$input = $isGif ? @imagecreatefromstring($imageData) : $imageData;
|
||||
|
||||
$image = $manager->read($input, $decoder);
|
||||
|
||||
if ($isGif) {
|
||||
$image->setOrigin(new Origin('image/gif'));
|
||||
}
|
||||
|
||||
return $image;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,7 +223,15 @@ class ImageResizer
|
||||
*/
|
||||
protected function isGif(Image $image): bool
|
||||
{
|
||||
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
|
||||
return $this->getExtension($image) === 'gif';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the extension for the given image, normalised to lower-case.
|
||||
*/
|
||||
protected function getExtension(Image $image): string
|
||||
{
|
||||
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,9 +33,10 @@ class ImageService
|
||||
int $uploadedTo = 0,
|
||||
int $resizeWidth = null,
|
||||
int $resizeHeight = null,
|
||||
bool $keepRatio = true
|
||||
bool $keepRatio = true,
|
||||
string $imageName = '',
|
||||
): Image {
|
||||
$imageName = $uploadedFile->getClientOriginalName();
|
||||
$imageName = $imageName ?: $uploadedFile->getClientOriginalName();
|
||||
$imageData = file_get_contents($uploadedFile->getRealPath());
|
||||
|
||||
if ($resizeWidth !== null || $resizeHeight !== null) {
|
||||
@@ -133,6 +134,19 @@ class ImageService
|
||||
return $disk->get($image->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw data content from an image.
|
||||
*
|
||||
* @throws Exception
|
||||
* @returns ?resource
|
||||
*/
|
||||
public function getImageStream(Image $image): mixed
|
||||
{
|
||||
$disk = $this->storage->getDisk();
|
||||
|
||||
return $disk->stream($image->path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an image along with its revisions, thumbnails and remaining folders.
|
||||
*
|
||||
@@ -140,11 +154,19 @@ class ImageService
|
||||
*/
|
||||
public function destroy(Image $image): void
|
||||
{
|
||||
$disk = $this->storage->getDisk($image->type);
|
||||
$disk->destroyAllMatchingNameFromPath($image->path);
|
||||
$this->destroyFileAtPath($image->type, $image->path);
|
||||
$image->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the underlying image file at the given path.
|
||||
*/
|
||||
public function destroyFileAtPath(string $type, string $path): void
|
||||
{
|
||||
$disk = $this->storage->getDisk($type);
|
||||
$disk->destroyAllMatchingNameFromPath($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete gallery and drawings that are not within HTML content of pages or page revisions.
|
||||
* Checks based off of only the image name.
|
||||
|
||||
@@ -110,10 +110,20 @@ class ImageStorage
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a public facing url for an image by checking relevant environment variables.
|
||||
* Gets a public facing url for an image or location at the given path.
|
||||
*/
|
||||
public static function getPublicUrl(string $filePath): string
|
||||
{
|
||||
return static::getPublicBaseUrl() . '/' . ltrim($filePath, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public base URL used for images.
|
||||
* Will not include any path element of the image file, just the base part
|
||||
* from where the path is then expected to start from.
|
||||
* If s3-style store is in use it will default to guessing a public bucket URL.
|
||||
*/
|
||||
public function getPublicUrl(string $filePath): string
|
||||
protected static function getPublicBaseUrl(): string
|
||||
{
|
||||
$storageUrl = config('filesystems.url');
|
||||
|
||||
@@ -131,6 +141,6 @@ class ImageStorage
|
||||
|
||||
$basePath = $storageUrl ?: url('/');
|
||||
|
||||
return rtrim($basePath, '/') . $filePath;
|
||||
return rtrim($basePath, '/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,15 @@ class ImageStorageDisk
|
||||
return $this->filesystem->get($this->adjustPathForDisk($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a stream to the file at the given path.
|
||||
* @returns ?resource
|
||||
*/
|
||||
public function stream(string $path): mixed
|
||||
{
|
||||
return $this->filesystem->readStream($this->adjustPathForDisk($path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the given image data at the given path. Can choose to set
|
||||
* the image as public which will update its visibility after saving.
|
||||
|
||||
@@ -21,7 +21,7 @@ class RoleApiController extends ApiController
|
||||
'display_name' => ['required', 'string', 'min:3', 'max:180'],
|
||||
'description' => ['string', 'max:180'],
|
||||
'mfa_enforced' => ['boolean'],
|
||||
'external_auth_id' => ['string'],
|
||||
'external_auth_id' => ['string', 'max:180'],
|
||||
'permissions' => ['array'],
|
||||
'permissions.*' => ['string'],
|
||||
],
|
||||
@@ -29,7 +29,7 @@ class RoleApiController extends ApiController
|
||||
'display_name' => ['string', 'min:3', 'max:180'],
|
||||
'description' => ['string', 'max:180'],
|
||||
'mfa_enforced' => ['boolean'],
|
||||
'external_auth_id' => ['string'],
|
||||
'external_auth_id' => ['string', 'max:180'],
|
||||
'permissions' => ['array'],
|
||||
'permissions.*' => ['string'],
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user