Compare commits

..

393 Commits

Author SHA1 Message Date
Dan Brown
26aadffb20
Updated version and assets for release v24.10
Some checks failed
test-js / build (push) Has been cancelled
test-migrations / build (8.1) (push) Has been cancelled
analyse-php / build (push) Has been cancelled
lint-js / build (push) Has been cancelled
lint-php / build (push) Has been cancelled
test-migrations / build (8.2) (push) Has been cancelled
test-migrations / build (8.3) (push) Has been cancelled
test-php / build (8.1) (push) Has been cancelled
test-php / build (8.2) (push) Has been cancelled
test-php / build (8.3) (push) Has been cancelled
2024-10-09 10:48:34 +01:00
Dan Brown
a5f48e3202
Merge branch 'development' into release 2024-10-09 10:46:07 +01:00
Dan Brown
b0dda6e6a7
Updated version and assets for release v24.05.4 2024-08-29 16:04:51 +01:00
Dan Brown
d4025d95e7
Merge branch 'development' into release 2024-08-29 16:04:37 +01:00
Dan Brown
d6021f4d22
Updated version and assets for release v24.05.3 2024-07-14 17:14:21 +01:00
Dan Brown
b9a3290731
Merge branch 'development' into release 2024-07-14 17:13:10 +01:00
Dan Brown
48f235ea5a
Updated version and assets for release v24.05.2 2024-06-10 11:44:06 +01:00
Dan Brown
047771b9f4
Merge branch 'development' into release 2024-06-10 11:43:05 +01:00
Dan Brown
b5375114d3
Updated version and assets for release v24.05.1 2024-05-21 11:07:36 +01:00
Dan Brown
fc13e56cea
Merge branch 'development' into release 2024-05-21 11:07:10 +01:00
Dan Brown
77fc37ac25
Updated version and assets for release v24.05 2024-05-11 15:49:29 +01:00
Dan Brown
3424351e84
Merge branch 'development' into release 2024-05-11 15:48:49 +01:00
Dan Brown
606f9d92d0
Updated version and assets for release v24.02.3 2024-04-05 15:20:08 +01:00
Dan Brown
a5e25abb9c
Merge branch 'v24-02' into release 2024-04-05 15:19:34 +01:00
Dan Brown
b310e87e4c
Updated version and assets for release v24.02.2 2024-03-11 14:30:48 +00:00
Dan Brown
425baf9d6e
Merge branch 'development' into release 2024-03-10 18:46:05 +00:00
Dan Brown
825c369ad9
Updated version and assets for release v24.02 2024-02-28 13:35:36 +00:00
Dan Brown
10bab70438
Merge branch 'development' into release 2024-02-28 13:35:23 +00:00
Dan Brown
350e0b281b
Updated version and assets for release v23.12.3 2024-02-26 12:05:02 +00:00
Dan Brown
08805ea3c8
Merge branch 'v23-12' into release 2024-02-26 12:04:25 +00:00
Dan Brown
9441e32c69
Updated version and assets for release v23.12.2 2024-01-24 10:37:20 +00:00
Dan Brown
530fc37067
Merge branch 'v23-12' into release 2024-01-24 10:36:52 +00:00
Dan Brown
369e499dce
Updated version and assets for release v23.12.1 2024-01-16 12:16:06 +00:00
Dan Brown
655815de6d
Merge branch 'development' into release 2024-01-16 12:15:50 +00:00
Dan Brown
457adc1fee
Updated version and assets for release v23.12 2023-12-29 12:16:07 +00:00
Dan Brown
e86a90967e
Merge branch 'development' into release 2023-12-29 12:15:34 +00:00
Dan Brown
5d08f7cf14
Updated version and assets for release v23.10.4 2023-11-20 14:19:46 +00:00
Dan Brown
8744eb2d62
Merge branch 'v23-10' into release 2023-11-20 14:02:23 +00:00
Dan Brown
d8383cfa80
Updated version and assets for release v23.10.2 2023-11-07 15:22:34 +00:00
Dan Brown
4626278447
Merge branch 'development' into release 2023-11-07 15:22:11 +00:00
Dan Brown
c61af9c22b
Updated version and assets for release v23.10.1 2023-11-02 14:44:53 +00:00
Dan Brown
72521d0906
Merge branch 'development' into release 2023-11-02 14:35:49 +00:00
Dan Brown
7e44b195c5
Updated version and assets for release v23.10 2023-10-30 12:15:59 +00:00
Dan Brown
5b45eac5e1
Merge branch 'development' into release 2023-10-30 12:14:23 +00:00
Dan Brown
c1d30341e7
Updated version and assets for release v23.08.3 2023-09-15 13:49:40 +01:00
Dan Brown
80d2b4913b
Merge branch 'v23-08' into release 2023-09-15 13:49:12 +01:00
Dan Brown
3f473528b1
Updated version and assets for release v23.08.2 2023-09-04 12:06:50 +01:00
Dan Brown
d0dcd4f61b
Merge branch 'development' into release 2023-09-04 12:06:15 +01:00
Dan Brown
bde66a1396
Updated version and assets for release v23.08.1 2023-09-03 17:40:19 +01:00
Dan Brown
4de5a2d9bf
Merge branch 'development' into release 2023-09-03 17:39:56 +01:00
Dan Brown
27bf4299cf
Updated version and assets for release v23.08 2023-08-30 12:38:48 +01:00
Dan Brown
164f01bb25
Merge branch 'development' into release 2023-08-30 12:38:22 +01:00
Dan Brown
f563a005f5
Updated version and assets for release v23.06.2 2023-07-12 22:34:25 +01:00
Dan Brown
a14d8e30cc
Merge branch 'development' into release 2023-07-12 22:34:15 +01:00
Dan Brown
a9194ffb63
Updated version and assets for release v23.06.1 2023-07-05 13:04:51 +01:00
Dan Brown
2f9c1b7127
Merge branch 'development' into release 2023-07-05 13:04:30 +01:00
Dan Brown
bbea76668b
Updated version and assets for release v23.06 2023-06-30 11:06:19 +01:00
Dan Brown
becc630acf
Merge branch 'development' into release 2023-06-30 11:05:57 +01:00
Dan Brown
4ac8ecad6b
Updated version and assets for release v23.05.2 2023-05-23 12:36:46 +01:00
Dan Brown
903e88c700
Merge branch 'development' into release 2023-05-23 12:36:29 +01:00
Dan Brown
ed96aa820e
Updated version and assets for release v23.05.1 2023-05-08 16:05:50 +01:00
Dan Brown
63ec079b7b
Merge branch 'development' into release 2023-05-08 16:04:51 +01:00
Dan Brown
d485fcb3db
Updated version and assets for release v23.05 2023-05-03 11:05:33 +01:00
Dan Brown
0f895668a4
Merge branch 'development' into release 2023-05-03 11:03:29 +01:00
Dan Brown
6c577ac3bf
Updated version and assets for release v23.02.3 2023-04-07 18:07:32 +01:00
Dan Brown
31cc2423d2
Merge branch 'v23.02-branch' into release 2023-04-07 18:07:09 +01:00
Dan Brown
c9ed32e518
Updated version and assets for release v23.02.2 2023-03-25 12:27:32 +00:00
Dan Brown
6b4c3a0969
Merge branch 'v23.02-branch' into release 2023-03-25 12:27:05 +00:00
Dan Brown
2dad92d1bd
Updated version and assets for release v23.02.1 2023-02-27 19:26:13 +00:00
Dan Brown
c1fb7ab7dc
Merge branch 'development' into release 2023-02-27 19:23:33 +00:00
Dan Brown
98315f3899
Updated version and assets for release v23.02 2023-02-26 11:03:49 +00:00
Dan Brown
8c82aaabd6
Merge branch 'development' into release 2023-02-26 11:02:56 +00:00
Dan Brown
ce9b536b78
Updated version and assets for release v23.01.1 2023-02-02 12:29:26 +00:00
Dan Brown
d9c50e5bc1
Merge branch 'development' into release 2023-02-02 12:29:07 +00:00
Dan Brown
bf075f7dd8
Updated version and assets for release v23.01 2023-01-31 11:59:51 +00:00
Dan Brown
a4fd673285
Merge branch 'development' into release 2023-01-31 11:59:28 +00:00
Dan Brown
e794c977bc
Updated version and assets for release v22.11.1 2022-12-16 23:49:14 +00:00
Dan Brown
0b088ef1d3
Merge branch 'development' into release 2022-12-16 23:48:35 +00:00
Dan Brown
bf6a6af683
Updated version and assets for release v22.11 2022-11-30 12:30:21 +00:00
Dan Brown
914790fd99
Merge branch 'development' into release 2022-11-30 12:29:52 +00:00
Dan Brown
edb0c6a9e8
Updated version and assets for release v22.10.2 2022-11-02 15:22:13 +00:00
Dan Brown
84049de696
Merge branch 'v22-10' into release 2022-11-02 15:19:33 +00:00
Dan Brown
da0531e63b
Updated version and assets for release v22.10.1 2022-10-21 21:52:32 +01:00
Dan Brown
421dc75f4e
Merge branch 'development' into release 2022-10-21 21:52:16 +01:00
Dan Brown
8ae91df038
Updated version and assets for release v22.10 2022-10-21 11:16:45 +01:00
Dan Brown
64b41dd626
Merge branch 'development' into release 2022-10-21 11:16:25 +01:00
Dan Brown
ebd6e4d3a2
Updated version and assets for release v22.09.1 2022-09-20 13:19:34 +01:00
Dan Brown
80374aea5c
Merge branch 'development' into release 2022-09-20 13:19:03 +01:00
Dan Brown
2ac9efae7d
Updated version and assets for release v22.09 2022-09-08 12:41:09 +01:00
Dan Brown
a11d565ba4
Merge branch 'development' into release 2022-09-08 12:40:57 +01:00
Dan Brown
1fdf854ea7
Updated version and assets for release v22.07.3 2022-08-11 15:17:06 +01:00
Dan Brown
e9c9792cb9
Merge branch 'development' into release 2022-08-11 15:16:34 +01:00
Dan Brown
5ae524c25a
Updated version and assets for release v22.07.2 2022-08-09 13:55:52 +01:00
Dan Brown
0d7287fc8b
Merge branch 'development' into release 2022-08-09 13:55:40 +01:00
Dan Brown
e77c96f6b7
Updated version and assets for release v22.07.1 2022-08-02 11:47:25 +01:00
Dan Brown
9b8a10dd3a
Merge branch 'development' into release 2022-08-02 11:47:08 +01:00
Dan Brown
49200ca5ce
Updated version and assets for release v22.07 2022-07-28 14:53:15 +01:00
Dan Brown
34aa4dbf10
Merge branch 'development' into release 2022-07-28 14:53:01 +01:00
Dan Brown
5ee79d16c9
Updated version and assets for release v22.06.2 2022-06-28 11:57:37 +01:00
Dan Brown
a1ea4006e0
Merge branch 'development' into release 2022-06-28 11:57:24 +01:00
Dan Brown
9078188939
Updated version and assets for release v22.06.1 2022-06-25 14:33:07 +01:00
Dan Brown
ed0aad1a7a
Merge branch 'development' into release 2022-06-25 14:32:49 +01:00
Dan Brown
5c59cfb020
Updated version and assets for release v22.06 2022-06-24 11:50:56 +01:00
Dan Brown
3ca15ad68a
Merge branch 'development' into release 2022-06-24 11:45:29 +01:00
Dan Brown
60014989f5
Updated version and assets for release v22.04.2 2022-05-09 16:10:16 +01:00
Dan Brown
57b10f195e
Merge branch 'development' into release 2022-05-09 16:09:54 +01:00
Dan Brown
b1e95eb39f
Updated version and assets for release v22.04.1 2022-05-04 21:26:58 +01:00
Dan Brown
b3da77b8f9
Merge branch 'development' into release 2022-05-04 21:26:31 +01:00
Dan Brown
1a345b74bb
Updated version and assets for release v22.04 2022-04-29 15:55:32 +01:00
Dan Brown
8ffc3a4abf
Merge branch 'development' into release 2022-04-29 15:55:05 +01:00
Dan Brown
7233c1c7b2
Updated version and assets for release v22.03.1 2022-03-30 19:37:07 +01:00
Dan Brown
1309a01131
Merge branch 'development' into release 2022-03-30 19:36:45 +01:00
Dan Brown
0333185b6d
Updated version and assets for release v22.03 2022-03-30 13:49:17 +01:00
Dan Brown
83f89f64e8
Merge branch 'development' into release 2022-03-30 13:49:05 +01:00
Dan Brown
11a1a6fb16
Updated version and assets for release v22.02.3 2022-03-07 15:12:22 +00:00
Dan Brown
882c609296
Merge branch 'development' into release 2022-03-07 15:12:09 +00:00
Dan Brown
176a0dcd59
Updated version and assets for release v22.02.2 2022-03-01 22:45:41 +00:00
Dan Brown
94b0f70bfa
Merge branch 'development' into release 2022-03-01 22:45:12 +00:00
Dan Brown
08b2a77d41
Updated version and assets for release v22.02.1 2022-02-27 17:46:06 +00:00
Dan Brown
3e8e9a23cf
Merge branch 'development' into release 2022-02-27 17:45:49 +00:00
Dan Brown
58b83b64c8
Updated version and assets for release v22.02 2022-02-26 12:01:44 +00:00
Dan Brown
dfe4cde6ee
Merge branch 'development' into release 2022-02-26 12:00:46 +00:00
Dan Brown
d11144d9e2
Updated version and assets for release v21.12.5 2022-02-06 15:49:23 +00:00
Dan Brown
f96b0ea5f3
Merge branch 'development' into release 2022-02-06 15:48:55 +00:00
Dan Brown
815f8d79ed
Updated version and assets for release v21.12.4 2022-02-01 11:52:24 +00:00
Dan Brown
b62dab32e0
Merge branch 'development' into release 2022-02-01 11:51:48 +00:00
Dan Brown
262f863981
Updated version and assets for release v21.12.3 2022-01-24 22:49:42 +00:00
Dan Brown
a4c94390a1
Merge branch 'master' into release 2022-01-24 22:49:31 +00:00
Dan Brown
53f3cca85d
Updated version and assets for release v21.12.2 2022-01-10 18:23:44 +00:00
Dan Brown
ed08bbcecc
Merge branch 'master' into release 2022-01-10 18:23:19 +00:00
Dan Brown
de97ebf9b7
Updated version and assets for release v21.12.1 2022-01-06 12:20:37 +00:00
Dan Brown
f492a660a8
Merge branch 'master' into release 2022-01-06 12:20:26 +00:00
Dan Brown
09436836a5
Updated version and assets for release v21.12 2021-12-22 17:04:18 +00:00
Dan Brown
bb455d7788
Merge branch 'master' into release 2021-12-22 17:03:50 +00:00
Dan Brown
009212ab80
Updated version and assets for release v21.11.3 2021-12-15 14:08:37 +00:00
Dan Brown
ba9cb591c8
Merge branch 'master' into release 2021-12-15 14:08:17 +00:00
Dan Brown
d00ac2f34e
Updated version and assets for release v21.11.2 2021-11-30 14:30:19 +00:00
Dan Brown
bd4dc6d463
Merge branch 'master' into release 2021-11-30 14:29:53 +00:00
Dan Brown
d91180a909
Updated version and assets for release v21.11.1 2021-11-23 20:44:36 +00:00
Dan Brown
bc2913a5cb
Merge branch 'master' into release 2021-11-23 20:44:12 +00:00
Dan Brown
4802394562
Updated version and assets for release v21.11 2021-11-16 13:22:24 +00:00
Dan Brown
1755556468
Merge branch 'master' into release 2021-11-16 13:21:44 +00:00
Dan Brown
01cdbdb7ae
Updated version and assets for release v21.10.3 2021-11-01 13:31:10 +00:00
Dan Brown
fc8bbf3eab
Merge branch 'master' into release 2021-11-01 13:30:36 +00:00
Dan Brown
3cdab19319
Updated version and assets for release v21.10.2 2021-10-28 15:57:04 +01:00
Dan Brown
5661d20e87
Merge branch 'master' into release 2021-10-28 15:56:49 +01:00
Dan Brown
91f80123e8
Merge branch 'master' into release 2021-10-27 12:35:00 +01:00
Dan Brown
7a0636d0f8
Updated version and assets for release v21.10.1 2021-10-27 12:31:40 +01:00
Dan Brown
0fe5bdfbac
Updated version and assets for release v21.10 2021-10-25 15:59:23 +01:00
Dan Brown
f88687e977
Merge branch 'master' into release 2021-10-25 15:58:59 +01:00
Dan Brown
68d437d05b
Updated version and assets for release v21.08.6 2021-10-15 14:34:44 +01:00
Dan Brown
1e56aaea04
Merge branch 'master' into release 2021-10-15 14:34:23 +01:00
Dan Brown
dab170a6fe
Updated version and assets for release v21.08.5 2021-10-08 22:25:36 +01:00
Dan Brown
a8de717d9b
Merge branch 'master' into release 2021-10-08 22:25:05 +01:00
Dan Brown
78fe95b6fc
Updated version and assets for release v21.08.4 2021-10-04 16:25:24 +01:00
Dan Brown
e0c24e41aa
Merge branch 'master' into release 2021-10-04 16:24:54 +01:00
Dan Brown
fa8553839b
Updated version and assets for release v21.08.3 2021-09-12 16:31:02 +01:00
Dan Brown
b8fcefc794
Merge branch 'master' into release 2021-09-12 16:30:35 +01:00
Dan Brown
88bcb68fcb
Updated version and assets for release v21.08.2 2021-09-04 15:07:20 +01:00
Dan Brown
7c000553ae
Merge branch 'master' into release 2021-09-04 15:06:33 +01:00
Dan Brown
391fa35c80
Updated version and assets for release v21.08.1 2021-09-02 21:13:09 +01:00
Dan Brown
c6773a8c9f
Merge branch 'master' into release 2021-09-02 21:12:06 +01:00
Dan Brown
9b226e7d39
Updated version and assets for release v21.08 2021-08-31 22:07:53 +01:00
Dan Brown
9865446267
Merge branch 'master' into release 2021-08-31 22:07:23 +01:00
Dan Brown
926abbe776
Updated version and assets for release v21.05.4 2021-08-04 21:29:10 +01:00
Dan Brown
4fabef3a57
Merge branch 'v21.05.x' into release 2021-08-04 21:28:45 +01:00
Dan Brown
5ef4cd80c3
Updated version and assets for release v21.05.3 2021-07-03 11:59:52 +01:00
Dan Brown
e01f23583f
Merge branch 'v21.05.x' into release 2021-07-03 11:59:21 +01:00
Dan Brown
7792cb3915
Updated version and assets for release v21.05.2 2021-06-13 14:26:34 +01:00
Dan Brown
be26253a18
Merge branch 'master' into release 2021-06-13 14:25:39 +01:00
Dan Brown
1bdd1f8189
Updated version for release v21.05.1 2021-06-04 23:09:42 +01:00
Dan Brown
fa62c79b17
Merge branch 'master' into release 2021-06-04 23:08:59 +01:00
Dan Brown
d7d8fa1e5b
Updated version and assets for release v21.05 2021-05-30 16:17:56 +01:00
Dan Brown
18562f1e10
Merge branch 'master' into release 2021-05-30 16:17:44 +01:00
Dan Brown
86090a694f
Updated version and assets for release v21.04.6 2021-05-24 13:06:03 +01:00
Dan Brown
1ee8287c73
Merge branch 'v21.04.x' into release 2021-05-24 13:05:34 +01:00
Dan Brown
8eb98cd591
Updated version and assets for release v21.04.5 2021-05-15 17:56:29 +01:00
Dan Brown
0f9ba21b05
Merge branch 'v21.04.x' into release 2021-05-15 17:56:03 +01:00
Dan Brown
834f8e7046
Updated version and assets for release v21.04.4 2021-05-09 14:46:05 +01:00
Dan Brown
32e3399334
Merge branch 'master' into release 2021-05-09 14:45:36 +01:00
Dan Brown
2d8698a218
Updated version and assets for release v21.04.3 2021-04-27 22:01:37 +01:00
Dan Brown
454fb883a2
Merge branch 'master' into release 2021-04-27 22:01:15 +01:00
Dan Brown
6f4a6ab8ea
Updated version for release v21.04.2 2021-04-20 22:37:05 +01:00
Dan Brown
9c4b6f36f1
Merge branch 'master' into release 2021-04-20 22:36:35 +01:00
Dan Brown
78886b1e67
Updated version and assets for release v21.04.1 2021-04-19 22:26:19 +01:00
Dan Brown
d9debaf032
Merge branch 'master' into release 2021-04-19 22:25:29 +01:00
Dan Brown
d4360d6347
Updated version and assets for release v21.04 2021-04-09 21:18:32 +01:00
Dan Brown
175b1785c0
Merge branch 'master' into release 2021-04-09 21:18:09 +01:00
Dan Brown
c8740c0171
Updated version for release v0.31.8 2021-03-13 15:32:54 +00:00
Dan Brown
91ee895a74
Merge branch 'v0.31.x' into release 2021-03-13 15:32:06 +00:00
Dan Brown
a045e46571
Updated version for release v0.31.7 2021-03-02 21:19:17 +00:00
Dan Brown
44eaa65c3b
Merge branch 'v0.31.x' into release 2021-03-02 21:18:31 +00:00
Dan Brown
0a22af7b14
Updated version for release v0.31.6 2021-02-06 14:41:19 +00:00
Dan Brown
b54702ab08
Merge branch 'v0.31.x' into release 2021-02-06 14:40:47 +00:00
Dan Brown
c4fdcfc5d1
Updated version for release v0.31.5 2021-02-02 20:58:06 +00:00
Dan Brown
cb8117e8df
Merge branch 'v0.31.x' into release 2021-02-02 20:57:41 +00:00
Dan Brown
5a218d5056
Updated version and assets for release v0.31.4 2021-01-16 17:50:45 +00:00
Dan Brown
8dbc5cf9c6
Merge branch 'master' into release 2021-01-16 17:50:11 +00:00
Dan Brown
71e81615a3
Updated version for release v0.31.3 2021-01-10 23:29:58 +00:00
Dan Brown
611d37da04
Merge branch 'master' into release 2021-01-10 23:29:11 +00:00
Dan Brown
0e799a3857
Updated version and assets for release v0.31.2 2021-01-10 14:05:16 +00:00
Dan Brown
b91d6e2bfa
Merge branch 'master' into release 2021-01-10 14:04:59 +00:00
Dan Brown
ea16ad7e94
Updated version and assets for release v0.31.1 2021-01-04 18:41:55 +00:00
Dan Brown
ba6eb54552
Merge branch 'master' into release 2021-01-04 18:41:26 +00:00
Dan Brown
f705e7683b
Updated assets for release v0.31.0 again 2021-01-03 22:33:36 +00:00
Dan Brown
dc996adb20
Merge branch 'master' into release 2021-01-03 22:32:40 +00:00
Dan Brown
a64c638ccc
Updated version and assets for release v0.31.0 2021-01-03 21:52:37 +00:00
Dan Brown
359c067279
Merge branch 'master' into release 2021-01-03 21:52:00 +00:00
Dan Brown
66a746e297
Updated version for release v0.30.7 2020-12-18 14:13:40 +00:00
Dan Brown
a4d43ee24b
Merge branch 'v0.30.x' into release 2020-12-18 14:13:19 +00:00
Dan Brown
f7793a70a9
Updated version for release v0.30.6 2020-12-17 21:07:06 +00:00
Dan Brown
ceba3d31fb
Merge branch 'v0.30.x' into release 2020-12-17 21:03:20 +00:00
Dan Brown
eecc08edde
Updated version for release v0.30.5 2020-12-06 21:05:43 +00:00
Dan Brown
eb19aadc75
Merge branch 'v0.30.x' into release 2020-12-06 21:05:11 +00:00
Dan Brown
06c81e69b9
Updated version and assets for release v0.30.4 2020-10-31 16:52:33 +00:00
Dan Brown
3dc3d4a639
Merge branch 'master' into release 2020-10-31 16:51:54 +00:00
Dan Brown
94c59c1e3d
Updated version and assets for release v0.30.3 2020-10-13 22:50:52 +01:00
Dan Brown
4d2205853a
Merge branch 'master' into release 2020-10-13 22:50:30 +01:00
Dan Brown
751772b87a
Updated version and assets for release v0.30.2 2020-09-30 22:44:58 +01:00
Dan Brown
76e30869e1
Merge branch 'master' into release 2020-09-30 22:44:17 +01:00
Dan Brown
3edc9fe9eb
Updated version and assets for release v0.30.1 2020-09-26 17:51:37 +01:00
Dan Brown
616c62703e
Merge branch 'master' into release 2020-09-26 17:50:25 +01:00
Dan Brown
ecd56917e7
Updated version and assets for release v0.30.0 2020-09-20 10:33:18 +01:00
Dan Brown
e22c9cae91
Merge branch 'master' into release 2020-09-20 10:30:10 +01:00
Dan Brown
29ddb6e1b9
Updated version and assets for release v0.29.3 2020-05-12 22:34:01 +01:00
Dan Brown
2ff90e2ff0
Merge branch 'master' into release 2020-05-12 22:33:27 +01:00
Dan Brown
04ecc128a2
Updated version and assets for release v0.29.2 2020-05-02 11:49:21 +01:00
Dan Brown
87d1d3423b
Merge branch 'master' into release 2020-05-02 11:48:48 +01:00
Dan Brown
4818192a2a
Updated version and assets for release v0.29.1 2020-04-28 12:30:31 +01:00
Dan Brown
965dd97f54
Merge branch 'master' into release 2020-04-28 12:30:09 +01:00
Dan Brown
195b74926c
Updated version and assets for release v0.29.0 2020-04-13 16:10:23 +01:00
Dan Brown
2120db12b2
Merge branch 'master' into release 2020-04-13 16:10:11 +01:00
Dan Brown
ed563fef28
Updated version and assets for release v0.28.3 2020-03-14 22:31:42 +00:00
Dan Brown
0d31a8e3f1
Merge branch 'master' into release 2020-03-14 22:31:11 +00:00
Dan Brown
b8354b974b
Updated version and assets for release v0.28.2 2020-02-15 22:36:08 +00:00
Dan Brown
034c1e289d
Merge branch 'master' into release 2020-02-15 22:35:46 +00:00
Dan Brown
f31605a3de
Updated version and assets for release v0.28.1 2020-02-15 22:08:06 +00:00
Dan Brown
e7cc75c74d
Merge branch 'master' into release 2020-02-15 22:07:17 +00:00
Dan Brown
4b79d5e4e8
Updated version and assets for release v0.28.0 2020-02-03 22:44:45 +00:00
Dan Brown
34854915b3
Merge branch 'master' into release 2020-02-03 22:43:58 +00:00
Dan Brown
af6f34b529
Updated version and assets for release v0.27.5 2019-10-16 16:35:50 +01:00
Dan Brown
fb82a2b896
Merge branch 'patching-v0.27' into release 2019-10-16 16:35:10 +01:00
Dan Brown
5b464938b6
Updated version and assets for release v0.27.4 2019-09-07 13:30:08 +01:00
Dan Brown
81f954890d
Merge branch 'patching-v0.27' into release 2019-09-07 13:29:53 +01:00
Dan Brown
0e2bbcec62
Updated version and assets for release v0.27.3 2019-09-03 21:50:12 +01:00
Dan Brown
fdd339f525
Merge branch 'master' into release 2019-09-03 21:49:46 +01:00
Dan Brown
8cf7d6a83d
Updated version and assets for release v0.27.2 2019-09-01 12:12:23 +01:00
Dan Brown
58a5008718
Merge branch 'master' into release 2019-09-01 12:12:10 +01:00
Dan Brown
c44a8df55d
Updated version and assets for release v0.27.1 2019-09-01 11:13:50 +01:00
Dan Brown
ff1494c519
Merge branch 'master' into release 2019-09-01 11:13:18 +01:00
Dan Brown
b8ce8fd852
Updated assets for release v0.27 2019-08-31 14:16:14 +01:00
Dan Brown
75e7454a5f
Merge branch 'master' into release and set version 2019-08-31 14:15:18 +01:00
Dan Brown
2558ea8931
Updated version for release v0.26.4 2019-08-06 21:42:09 +01:00
Dan Brown
ac0f47a4b2
Merge branch 'v0.26' into release 2019-08-06 21:41:06 +01:00
Dan Brown
4f16129869
Updated version for release v0.26.3 2019-07-10 20:21:22 +01:00
Dan Brown
64a8037fdd
Merge branch 'v0.26' into release 2019-07-10 20:19:54 +01:00
Dan Brown
7502ba1bc8
Updated version and assets for release v0.26.2 2019-05-27 13:48:20 +01:00
Dan Brown
33a04697ef
Merge branch 'master' into release 2019-05-27 13:47:47 +01:00
Dan Brown
b70a5c0cdb
Updated version and assets for release v0.26.1 2019-05-07 23:05:47 +01:00
Dan Brown
9443ae9f40
Merge branch 'master' into release 2019-05-07 23:05:10 +01:00
Dan Brown
220c2a4102
Updated version and assets for release v0.26.0 2019-05-06 18:58:56 +01:00
Dan Brown
e9914eb301
Merge branch 'master' into release 2019-05-06 18:57:58 +01:00
Dan Brown
934512d09c
Updated version and assets for release v0.25.5 2019-03-24 19:45:17 +00:00
Dan Brown
9102c90986
Merge branch 'master' into release 2019-03-24 19:45:00 +00:00
Dan Brown
c3e74219c4
Updated version and assets for release v0.25.4 2019-03-21 19:46:19 +00:00
Dan Brown
13c9d7bc2d
Merge branch 'master' into release 2019-03-21 19:43:48 +00:00
Dan Brown
119b539586
Updated version and assets for release v0.25.3 2019-03-21 00:03:26 +00:00
Dan Brown
29a5c180f0
Merge branch 'master' into release 2019-03-21 00:02:33 +00:00
Dan Brown
7906602291
Updated version and assets for release v0.25.2 2019-03-10 13:45:21 +00:00
Dan Brown
6dafe773ff
Merge branch 'master' into release 2019-03-10 13:44:29 +00:00
Dan Brown
25bc28a1be
Updated version and assets for release v0.25.1 2019-01-20 15:42:32 +00:00
Dan Brown
4c561c7fa0
Merge branch 'master' into release 2019-01-20 15:41:24 +00:00
Dan Brown
95b3e78573
Updated version and assets for release v0.25.0 2019-01-12 22:48:53 +00:00
Dan Brown
63a345bc93
Merge branch 'master' into release 2019-01-12 22:47:07 +00:00
Dan Brown
e093a172cb
Updated assets and version for release v0.24.3 2018-11-27 21:52:20 +00:00
Dan Brown
4b01f8934b
Merge branch 'master' into release 2018-11-27 21:51:32 +00:00
Dan Brown
bc116b45b5
Re-updated assets for release v0.24.2 2018-11-10 16:10:22 +00:00
Dan Brown
a059960b9e
Merge branch 'master' into release 2018-11-10 16:09:14 +00:00
Dan Brown
7770966fed
Updated assets for release v0.24.2 2018-11-10 16:01:55 +00:00
Dan Brown
d7adcf6c69
Merge branch 'master' into release 2018-11-10 16:01:01 +00:00
Dan Brown
04a364dcc3
Incremented version for v0.24.1 2018-09-24 16:34:16 +01:00
Dan Brown
db83ac7eaa
Merge branch 'master' into release 2018-09-24 16:32:30 +01:00
Dan Brown
3ca9dddf61
Merge branch 'master' into release 2018-09-24 15:59:39 +01:00
Dan Brown
bf74f53ca7
Updated assets for release and incremented version 2018-09-24 12:18:27 +01:00
Dan Brown
9d67efb4a4
Merge branch 'master' into release 2018-09-24 12:08:21 +01:00
Dan Brown
3a39b9f440
Merge pull request #1022 from BookStackApp/revert-983-master
Revert "Update german translation"
2018-09-22 18:33:29 +01:00
Dan Brown
27f7aab375
Revert "Update german translation" 2018-09-22 18:33:15 +01:00
Dan Brown
337da0c467
Merge pull request #983 from vriic/master
Update german translation
2018-09-22 18:27:04 +01:00
Nikolai Nikolajevic
f56b3560c4 Update german translation 2018-08-23 16:17:46 +02:00
Dan Brown
02dfe11ce6
Increment version for release v0.23.2 2018-08-19 15:33:23 +01:00
Dan Brown
83d06beb70
Merge branch 'master' into release 2018-08-19 15:33:10 +01:00
Dan Brown
a8cfc059c8
Updated version for release v0.23.1 2018-08-12 14:22:53 +01:00
Dan Brown
1614b2bab0
Merge branch 'master' into release 2018-08-12 14:22:17 +01:00
Dan Brown
4bdec0d214
Updated version and assets for release v0.23 2018-07-29 20:28:49 +01:00
Dan Brown
6a7d7e7c2b
Merge branch 'master' into release 2018-07-29 20:26:00 +01:00
Dan Brown
30d4674657
Updated assets for release v0.22 2018-05-28 14:19:14 +01:00
Dan Brown
9f961f95f8
Merge branch 'master' into release 2018-05-28 14:19:04 +01:00
Dan Brown
bab99a26ec
Updated assets and version for v0.21 release 2018-04-22 20:21:22 +01:00
Dan Brown
9a7fecd269
Merge branch 'master' into release 2018-04-22 20:19:02 +01:00
Dan Brown
a8dc0d449b
Updated the version because i'm such a plonker
And forgot to do this last release.
I wonder if there's a simple commit hook that could prevent the same two
versions twice in a row?
2018-03-30 15:41:46 +01:00
Dan Brown
a0381f76bf
Merge branch 'v0.20' into release 2018-03-30 15:33:23 +01:00
Dan Brown
6102f66daa
Updated assets for release v0.20.1 2018-03-25 16:58:14 +01:00
Dan Brown
c6134d162d
Merge branch 'master' into release 2018-03-25 16:54:48 +01:00
Dan Brown
2046f9b9de
Updated assets for release v0.20.0 2018-02-11 18:20:17 +00:00
Dan Brown
ac3ba594a4
Merge branch 'master' into release and updated version 2018-02-11 18:19:38 +00:00
Dan Brown
22df25a480
Updated assets and version for v0.19.0 2017-12-10 18:21:07 +00:00
Dan Brown
8b30c7f02e
Merge branch 'master' into release 2017-12-10 18:19:20 +00:00
Dan Brown
757cdddc7c
Updated version and JS for release v0.18.5 2017-11-11 18:33:04 +00:00
Dan Brown
df95e99680
Updated assets and version for release v0.18.4 2017-10-15 19:28:29 +01:00
Dan Brown
5a6d544db7
Merge branch 'master' into release 2017-10-15 19:27:50 +01:00
Dan Brown
16117d329c
Merge branch 'master' into release, Updated version 2017-10-06 21:05:45 +01:00
Dan Brown
e90da18ada
Updated assets and version for v0.18.2 release 2017-10-01 18:12:59 +01:00
Dan Brown
a08d80e1cc
Merge branch 'master' into release 2017-10-01 18:12:07 +01:00
Dan Brown
6258175922
Updated assets and version for v0.18.1 release 2017-09-20 21:36:17 +01:00
Dan Brown
15736777a0
Merge branch 'master' into release 2017-09-20 21:35:33 +01:00
Dan Brown
75915e8a94
Updated assets for release v0.18 2017-09-10 17:07:57 +01:00
Dan Brown
9bde0ae4ea
Merge branch 'master' into release 2017-09-10 17:05:05 +01:00
Dan Brown
0c802d1f86
Updated assets and version for release v0.17.4 2017-07-28 13:04:21 +01:00
Dan Brown
b7a96c6466
Merge branch 'master' into release 2017-07-28 13:03:36 +01:00
Dan Brown
4b645a82c7
Updated version for release 2017-07-22 17:27:01 +01:00
Dan Brown
d599b77b6f
Merge branch 'master' into release 2017-07-22 17:26:44 +01:00
Dan Brown
26e93dc8c1
Updated assets and version for release v0.17.2 2017-07-22 16:49:07 +01:00
Dan Brown
a4c9a8491b
Merge branch 'master' into release 2017-07-22 16:46:57 +01:00
Dan Brown
70ee636d87
Updated css and version for release 2017-07-10 20:52:32 +01:00
Dan Brown
b35f6dbb03
Merge branch 'master' into release 2017-07-10 20:51:25 +01:00
Dan Brown
67d9e24d8f
Merge branch 'master' into release
Also updated assets, Version number
2017-07-02 22:52:26 +01:00
Dan Brown
3903fda6ca
Incremented version 2017-06-04 15:38:49 +01:00
Dan Brown
441e46ebaa
Merge branch 'v0.16' into release 2017-06-04 15:38:29 +01:00
Dan Brown
1f4260f359
Updated version for release v0.16.2 2017-05-07 19:35:51 +01:00
Dan Brown
dc0bf8ad4e
Merge branch 'master' into release 2017-05-07 19:35:34 +01:00
Dan Brown
102e326e6a
Updated JS and version for release v0.16.1 2017-04-30 19:51:23 +01:00
Dan Brown
2b25bf6f3b
Merge branch 'master' into release 2017-04-30 19:50:29 +01:00
Dan Brown
f93280696d
Updated assets for release v0.16 2017-04-23 20:42:28 +01:00
Dan Brown
1787391b07
Merge branch 'master' into release 2017-04-23 20:41:45 +01:00
Dan Brown
a74a8ee483
Updated version for v0.15.3 2017-03-23 22:22:16 +00:00
Dan Brown
7fa5405cb7
Merge branch 'master' into release 2017-03-23 22:21:04 +00:00
Dan Brown
6725ddcc41
Updated version for release v0.15.2 2017-03-05 15:50:52 +00:00
Dan Brown
bce941db3f
Merge branch 'master' into release 2017-03-05 15:49:47 +00:00
Dan Brown
6d926048ec
Updated to version v0.15.1 2017-02-27 16:59:10 +00:00
Dan Brown
5335c973b4
Merge branch 'master' into release 2017-02-27 16:58:20 +00:00
Dan Brown
15c3e5c96e
Updated assets for release v0.15 2017-02-27 14:58:02 +00:00
Dan Brown
a5d5904969
Merge branch 'master' into release 2017-02-27 14:57:38 +00:00
Dan Brown
598758b991
Updated version for v0.14.3 2017-02-05 21:23:27 +00:00
Dan Brown
9926e23bc8
Merge branch 'v0.14' into release 2017-02-05 21:21:54 +00:00
Dan Brown
5d3264bc63
Updated assets for release v0.14.2 2017-02-01 22:27:04 +00:00
Dan Brown
d71f819f95
Merge branch 'v0.14' into release 2017-02-01 22:22:38 +00:00
Dan Brown
ee13509760
Updated version number 2017-01-23 22:28:31 +00:00
Dan Brown
82d7bb1f32
Merge branch 'master' into release 2017-01-23 22:28:02 +00:00
Dan Brown
cdfda508d8
Updated assets for release v0.14 2017-01-22 12:36:10 +00:00
Dan Brown
da941e584f
Merge branch 'master' into release ready for v0.14 2017-01-22 12:31:27 +00:00
Dan Brown
65874d7b96
Updated assets for release v0.13.1 2016-11-27 19:42:33 +00:00
Dan Brown
ac9b8f405c
Merge fixes from master for release v0.13.1 2016-11-27 19:41:12 +00:00
Dan Brown
8d1419a12e
Update assets and version for release v0.13 2016-11-13 12:29:52 +00:00
Dan Brown
04f7a7d301
Merge branch 'master' into release 2016-11-13 12:26:56 +00:00
Dan Brown
c10d2a1493
Updated assets for release v0.12.2 2016-10-30 13:19:19 +00:00
Dan Brown
97bbf79ffd
Merge branch 'v0.12' into release 2016-10-30 13:18:23 +00:00
Dan Brown
f7b01ae53d
Updated assets for release v0.12.1 2016-09-06 20:50:15 +01:00
Dan Brown
d704e1dbba
Merge branch 'master' into release 2016-09-06 20:49:15 +01:00
Dan Brown
ef2ff5e093
Updated assets for release v0.12 2016-09-05 19:49:42 +01:00
Dan Brown
7caed3b0db
Merge branch 'master' into release 2016-09-05 19:35:21 +01:00
Dan Brown
45641d0754
Updated assets for release v0.11.2 2016-08-21 14:56:29 +01:00
Dan Brown
4b1d08ba99
Merge branch 'v0.11' into release 2016-08-21 14:55:11 +01:00
Dan Brown
160fa99ba4 Updated assets for release v0.11.1 2016-08-14 12:40:55 +01:00
Dan Brown
d2a5ab49ed Merge branch 'v0.11' into release 2016-08-14 12:37:48 +01:00
Dan Brown
c6404d8917 Updated assets for release v0.11 2016-07-03 10:56:16 +01:00
Dan Brown
7113807f12 Merge branch 'master' into release 2016-07-03 10:52:04 +01:00
Dan Brown
be711215e8 Updated assets for release v0.10 2016-05-22 15:12:47 +01:00
Dan Brown
7e3b404240 Merge branch 'master' into release for version v0.10 2016-05-22 15:11:50 +01:00
Dan Brown
e86901ca20 Updated assets for release v0.9.3 2016-05-03 21:13:02 +01:00
Dan Brown
bdfa61c8b2 Merge branch 'v0.9' into release 2016-05-03 21:11:01 +01:00
Dan Brown
2cc36787f5 Updated assets for release 0.9.2 2016-04-15 19:57:02 +01:00
Dan Brown
448ac61b48 Merge branch 'master' into release 2016-04-15 19:52:59 +01:00
Dan Brown
753f6394f7 Merge branch 'master' into release 2016-04-12 20:09:14 +01:00
Dan Brown
b1faf65934 Updated assets for release 0.9.0 2016-04-09 15:49:02 +01:00
Dan Brown
09f478bd74 Merge branch 'master' into release 2016-04-09 15:47:14 +01:00
Dan Brown
a0497feddd Updated assets for release 0.8.2 2016-03-30 21:44:30 +01:00
Dan Brown
789693bde9 Merge branch 'v0.8' into release 2016-03-30 21:32:46 +01:00
Dan Brown
1fe933e4ea Merge branch 'master' into release 2016-03-13 15:38:06 +00:00
Dan Brown
724b4b5a70 Updated assets for release 0.8.0 2016-03-13 15:15:14 +00:00
Dan Brown
1778a56146 Merge branch 'master' into release 2016-03-13 15:13:23 +00:00
Dan Brown
744865fcb2 Updated assets for release 0.7.6 2016-03-06 13:28:44 +00:00
Dan Brown
7f8c8b448d Merged branch master into release 2016-03-06 13:26:29 +00:00
Dan Brown
a67c53826d Updated assets for release 0.7.5 2016-02-25 21:24:09 +00:00
Dan Brown
14b131e850 Merge branch 'master' into release 2016-02-25 21:23:06 +00:00
Dan Brown
9b55a52b85 Updated assets for release 0.7.4 2016-02-11 22:35:01 +00:00
Dan Brown
db1d10e80f Merge branch 'master' into release 2016-02-11 22:29:29 +00:00
Dan Brown
1be576966f Updated assets for release 0.7.3 2016-02-08 20:47:33 +00:00
Dan Brown
b97e792c5f Merge branch 'master' into release 2016-02-08 20:45:48 +00:00
Dan Brown
8dec674cc3 Merge branch 'master' into release 2016-02-02 07:35:20 +00:00
Dan Brown
f784c03746 Merge branch 'master' into release 2016-02-01 18:31:04 +00:00
Dan Brown
148e172fe8 Updated assets for release 0.7 2016-01-31 18:03:55 +00:00
Dan Brown
56ae86646f Merge branch 'master' into release 2016-01-31 18:01:25 +00:00
Dan Brown
1d2b6fdfa2 Add updated assets 2016-01-02 14:50:59 +00:00
Dan Brown
4fc75beed4 Merge branch 'master' into release 2016-01-02 14:49:05 +00:00
Dan Brown
3b3bc0c4bf Updated compiled assets 2015-12-31 17:26:22 +00:00
Dan Brown
910faab88e Merge branch 'master' into release 2015-12-31 17:22:03 +00:00
Dan Brown
f184d763ad Added build folder to release 2015-12-16 17:53:53 +00:00
Dan Brown
a91d42634d Merge branch 'master' into release 2015-12-16 17:29:34 +00:00
Dan Brown
f517ef3616 Added new asset structure 2015-12-16 17:27:53 +00:00
Dan Brown
e99507ddcf Merge branch 'master' into release 2015-12-16 17:21:21 +00:00
Dan Brown
d2cacf1945 Release update 2015-12-01 21:30:21 +00:00
Dan Brown
448ac1405b Merge branch 'master' into release 2015-12-01 21:15:08 +00:00
Dan Brown
6ad21ce885 Added built assets for release 2015-11-30 21:59:34 +00:00
821 changed files with 11671 additions and 31367 deletions

View File

@ -56,7 +56,6 @@ APP_PROXIES=null
# Database details
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
# An ipv6 address can be used via the square bracket format ([::1]).
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=database_database

View File

@ -449,38 +449,3 @@ Avishay Rapp (AvishayRapp) :: Hebrew
matthias4217 :: French
Berke BOYLU2 (berkeboylu2) :: Turkish
etwas7B :: German
Mohammed srhiri (m.sghiri20) :: Arabic
YongMin Kim (kym0118) :: Korean
Rivo Zängov (Eraser) :: Estonian
Francisco Rafael Fonseca (chicoraf) :: Portuguese, Brazilian
ИEØ_ΙΙØZ (NEO_IIOZ) :: Chinese Traditional
madnjpn (madnjpn.) :: Georgian
Ásgeir Shiny Ásgeirsson (AsgeirShiny) :: Icelandic
Mohammad Aftab Uddin (chirohorit) :: Bengali
Yannis Karlaftis (meliseus) :: Greek
felixxx :: German Informal
randi (randi65535) :: Korean
test65428 :: Greek
zeronell :: Chinese Simplified
julien Vinber (julienVinber) :: French
Hyunwoo Park (oksure) :: Korean
aram.rafeq.7 (aramrafeq2) :: Kurdish
Raphael Moreno (RaphaelMoreno) :: Portuguese, Brazilian
yn (user99) :: Arabic
Pavel Zlatarov (pzlatarov) :: Bulgarian
ingelres :: French
mabdullah :: Arabic
Skrabák Csaba (kekcsi) :: Hungarian
Evert Meulie (Evert) :: Norwegian Bokmal
Jasper Backer (jasperb) :: Dutch
Alexandar Cavdarovski (ace.200112) :: Swedish
구닥다리TV (yjj8353) :: Korean
Onur Oskay (o.oskay) :: Turkish
Sébastien Merveille (SebastienMerv) :: French
Maxim Kouznetsov (masya.work) :: Hebrew
neodvisnost :: Slovenian
Soubi Agatsuma (bisouya) :: Hebrew
Ilya Shaulov (ishaulov) :: Russian
Konstantin Bobkov (b.konstantv) :: Russian
Ruben Sutter (rubensutter) :: German
jellium :: French

View File

@ -11,9 +11,9 @@ on:
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@v2

View File

@ -11,14 +11,14 @@ on:
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.3
php-version: 8.1
tools: phpcs
- name: Run formatting check

View File

@ -13,12 +13,12 @@ on:
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['8.2', '8.3', '8.4']
php: ['8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@v2

View File

@ -13,12 +13,12 @@ on:
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-24.04
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['8.2', '8.3', '8.4']
php: ['8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v1
- name: Setup PHP
uses: shivammathur/setup-php@v2

7
.gitignore vendored
View File

@ -8,10 +8,10 @@ Homestead.yaml
.idea
npm-debug.log
yarn-error.log
/public/dist
/public/dist/*.map
/public/plugins
/public/css
/public/js
/public/css/*.map
/public/js/*.map
/public/bower
/public/build/
/public/favicon.ico
@ -32,4 +32,3 @@ webpack-stats.json
phpstan.neon
esbuild-meta.json
.phpactor.json
/*.zip

View File

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2015-2025, Dan Brown and the BookStack project contributors.
Copyright (c) 2015-2024, Dan Brown and the BookStack Project contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -8,15 +8,27 @@ use Illuminate\Database\Eloquent\Model;
class ExternalBaseUserProvider implements UserProvider
{
public function __construct(
protected string $model
) {
/**
* The user model.
*
* @var string
*/
protected $model;
/**
* LdapUserProvider constructor.
*/
public function __construct(string $model)
{
$this->model = $model;
}
/**
* Create a new instance of the model.
*
* @return Model
*/
public function createModel(): Model
public function createModel()
{
$class = '\\' . ltrim($this->model, '\\');
@ -25,8 +37,12 @@ class ExternalBaseUserProvider implements UserProvider
/**
* Retrieve a user by their unique identifier.
*
* @param mixed $identifier
*
* @return Authenticatable|null
*/
public function retrieveById(mixed $identifier): ?Authenticatable
public function retrieveById($identifier)
{
return $this->createModel()->newQuery()->find($identifier);
}
@ -34,9 +50,12 @@ class ExternalBaseUserProvider implements UserProvider
/**
* Retrieve a user by their unique identifier and "remember me" token.
*
* @param mixed $identifier
* @param string $token
*
* @return Authenticatable|null
*/
public function retrieveByToken(mixed $identifier, $token): null
public function retrieveByToken($identifier, $token)
{
return null;
}
@ -56,8 +75,12 @@ class ExternalBaseUserProvider implements UserProvider
/**
* Retrieve a user by the given credentials.
*
* @param array $credentials
*
* @return Authenticatable|null
*/
public function retrieveByCredentials(array $credentials): ?Authenticatable
public function retrieveByCredentials(array $credentials)
{
// Search current user base by looking up a uid
$model = $this->createModel();
@ -69,15 +92,15 @@ class ExternalBaseUserProvider implements UserProvider
/**
* Validate a user against the given credentials.
*
* @param Authenticatable $user
* @param array $credentials
*
* @return bool
*/
public function validateCredentials(Authenticatable $user, array $credentials): bool
public function validateCredentials(Authenticatable $user, array $credentials)
{
// Should be done in the guard.
return false;
}
public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false)
{
// No action to perform, any passwords are external in the auth system
}
}

View File

@ -54,7 +54,7 @@ class Ldap
*
* @return \LDAP\Result|array|false
*/
public function search($ldapConnection, string $baseDn, string $filter, array $attributes = [])
public function search($ldapConnection, string $baseDn, string $filter, array $attributes = null)
{
return ldap_search($ldapConnection, $baseDn, $filter, $attributes);
}
@ -66,7 +66,7 @@ class Ldap
*
* @return \LDAP\Result|array|false
*/
public function read($ldapConnection, string $baseDn, string $filter, array $attributes = [])
public function read($ldapConnection, string $baseDn, string $filter, array $attributes = null)
{
return ldap_read($ldapConnection, $baseDn, $filter, $attributes);
}
@ -87,7 +87,7 @@ class Ldap
*
* @param resource|\LDAP\Connection $ldapConnection
*/
public function searchAndGetEntries($ldapConnection, string $baseDn, string $filter, array $attributes = []): array|false
public function searchAndGetEntries($ldapConnection, string $baseDn, string $filter, array $attributes = null): array|false
{
$search = $this->search($ldapConnection, $baseDn, $filter, $attributes);
@ -99,7 +99,7 @@ class Ldap
*
* @param resource|\LDAP\Connection $ldapConnection
*/
public function bind($ldapConnection, ?string $bindRdn = null, ?string $bindPassword = null): bool
public function bind($ldapConnection, string $bindRdn = null, string $bindPassword = null): bool
{
return ldap_bind($ldapConnection, $bindRdn, $bindPassword);
}

View File

@ -71,26 +71,6 @@ 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.
@ -101,25 +81,21 @@ class LdapService
{
$idAttr = $this->config['id_attribute'];
$emailAttr = $this->config['email_attribute'];
$displayNameAttrs = explode('|', $this->config['display_name_attribute']);
$displayNameAttr = $this->config['display_name_attribute'];
$thumbnailAttr = $this->config['thumbnail_attribute'];
$user = $this->getUserWithAttributes($userName, array_filter([
'cn', 'dn', $idAttr, $emailAttr, ...$displayNameAttrs, $thumbnailAttr,
'cn', 'dn', $idAttr, $emailAttr, $displayNameAttr, $thumbnailAttr,
]));
if (is_null($user)) {
return null;
}
$nameDefault = $this->getUserResponseProperty($user, 'cn', null);
if (is_null($nameDefault)) {
$nameDefault = ldap_explode_dn($user['dn'], 1)[0] ?? $user['dn'];
}
$userCn = $this->getUserResponseProperty($user, 'cn', null);
$formatted = [
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
'name' => $this->getUserDisplayName($user, $displayNameAttrs, $nameDefault),
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
'dn' => $user['dn'],
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,

View File

@ -5,7 +5,6 @@ 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;
@ -30,14 +29,10 @@ class LoginService
* a reason to (MFA or Unconfirmed Email).
* Returns a boolean to indicate the current login result.
*
* @throws StoppedAuthenticationException|LoginAttemptInvalidUserException
* @throws StoppedAuthenticationException
*/
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);
@ -63,7 +58,7 @@ class LoginService
*
* @throws Exception
*/
public function reattemptLoginFor(User $user): void
public function reattemptLoginFor(User $user)
{
if ($user->id !== ($this->getLastLoginAttemptUser()->id ?? null)) {
throw new Exception('Login reattempt user does align with current session state');
@ -157,40 +152,16 @@ 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();
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;
}
$this->login($user, $method, $remember);
}
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.

View File

@ -11,9 +11,7 @@ class OidcUserinfoResponse implements ProvidesClaims
public function __construct(ResponseInterface $response, string $issuer, array $keys)
{
$contentTypeHeaderValue = $response->getHeader('Content-Type')[0] ?? '';
$contentType = strtolower(trim(explode(';', $contentTypeHeaderValue, 2)[0]));
$contentType = $response->getHeader('Content-Type')[0];
if ($contentType === 'application/json') {
$this->claims = json_decode($response->getBody()->getContents(), true);
}

View File

@ -92,7 +92,7 @@ class SocialDriverManager
string $driverName,
array $config,
string $socialiteHandler,
?callable $configureForRedirect = null
callable $configureForRedirect = null
) {
$this->validDrivers[] = $driverName;
config()->set('services.' . $driverName, $config);

View File

@ -67,14 +67,6 @@ class ActivityType
const WEBHOOK_UPDATE = 'webhook_update';
const WEBHOOK_DELETE = 'webhook_delete';
const IMPORT_CREATE = 'import_create';
const IMPORT_RUN = 'import_run';
const IMPORT_DELETE = 'import_delete';
const SORT_RULE_CREATE = 'sort_rule_create';
const SORT_RULE_UPDATE = 'sort_rule_update';
const SORT_RULE_DELETE = 'sort_rule_delete';
/**
* Get all the possible values.
*/

View File

@ -5,7 +5,6 @@ namespace BookStack\Activity\Controllers;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Http\Controller;
use BookStack\Sorting\SortUrl;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
@ -66,7 +65,6 @@ class AuditLogController extends Controller
'filters' => $filters,
'listOptions' => $listOptions,
'activityTypes' => $types,
'filterSortUrl' => new SortUrl('settings/audit', array_filter($request->except('page')))
]);
}
}

View File

@ -26,6 +26,7 @@ class Comment extends Model implements Loggable
use HasCreatorAndUpdater;
protected $fillable = ['parent_id'];
protected $appends = ['created', 'updated'];
/**
* Get the entity that this comment belongs to.
@ -53,6 +54,22 @@ class Comment extends Model implements Loggable
return $this->updated_at->timestamp > $this->created_at->timestamp;
}
/**
* Get created date as a relative diff.
*/
public function getCreatedAttribute(): string
{
return $this->created_at->diffForHumans();
}
/**
* Get updated date as a relative diff.
*/
public function getUpdatedAttribute(): string
{
return $this->updated_at->diffForHumans();
}
public function logDescriptor(): string
{
return "Comment #{$this->local_id} (ID: {$this->id}) for {$this->entity_type} (ID: {$this->entity_id})";

View File

@ -7,7 +7,6 @@ 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
{
@ -37,11 +36,7 @@ abstract class BaseNotificationHandler implements NotificationHandler
}
// Send the notification
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()}");
}
$user->notify(new $notification($detail, $initiator));
}
}
}

View File

@ -2,9 +2,7 @@
namespace BookStack\Api;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
class ApiEntityListFormatter
{
@ -22,16 +20,8 @@ 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)
@ -72,28 +62,6 @@ 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[]

View File

@ -42,12 +42,4 @@ class EventServiceProvider extends ServiceProvider
{
return false;
}
/**
* Overrides the registration of Laravel's default email verification system
*/
protected function configureEmailVerification(): void
{
//
}
}

View File

@ -85,12 +85,5 @@ class RouteServiceProvider extends ServiceProvider
RateLimiter::for('public', function (Request $request) {
return Limit::perMinute(10)->by($request->ip());
});
RateLimiter::for('exports', function (Request $request) {
$user = user();
$attempts = $user->isGuest() ? 4 : 10;
$key = $user->isGuest() ? $request->ip() : $user->id;
return Limit::perMinute($attempts)->by($key);
});
}
}

View File

@ -1,7 +1,6 @@
<?php
use BookStack\App\Model;
use BookStack\Facades\Theme;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\SettingService;
use BookStack\Users\Models\User;
@ -43,9 +42,9 @@ function user(): User
* Check if the current user has a permission. If an ownable element
* is passed in the jointPermissions are checked against that particular item.
*/
function userCan(string $permission, ?Model $ownable = null): bool
function userCan(string $permission, Model $ownable = null): bool
{
if (is_null($ownable)) {
if ($ownable === null) {
return user()->can($permission);
}
@ -71,7 +70,7 @@ function userCanOnAny(string $action, string $entityClass = ''): bool
*
* @return mixed|SettingService
*/
function setting(?string $key = null, mixed $default = null): mixed
function setting(string $key = null, $default = null)
{
$settingService = app()->make(SettingService::class);
@ -89,10 +88,43 @@ function setting(?string $key = null, mixed $default = null): mixed
*/
function theme_path(string $path = ''): ?string
{
$theme = Theme::getTheme();
$theme = config('view.theme');
if (!$theme) {
return null;
}
return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path));
}
/**
* Generate a URL with multiple parameters for sorting purposes.
* Works out the logic to set the correct sorting direction
* Discards empty parameters and allows overriding.
*/
function sortUrl(string $path, array $data, array $overrideData = []): string
{
$queryStringSections = [];
$queryData = array_merge($data, $overrideData);
// Change sorting direction is already sorted on current attribute
if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) {
$queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc';
} elseif (isset($overrideData['sort'])) {
$queryData['order'] = 'asc';
}
foreach ($queryData as $name => $value) {
$trimmedVal = trim($value);
if ($trimmedVal === '') {
continue;
}
$queryStringSections[] = urlencode($name) . '=' . urlencode($trimmedVal);
}
if (count($queryStringSections) === 0) {
return url($path);
}
return url($path . '?' . implode('&', $queryStringSections));
}

View File

@ -0,0 +1,37 @@
<?php
/**
* Broadcasting configuration options.
*
* Changes to these config files are not supported by BookStack and may break upon updates.
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
return [
// Default Broadcaster
// This option controls the default broadcaster that will be used by the
// framework when an event needs to be broadcast. This can be set to
// any of the connections defined in the "connections" array below.
'default' => 'null',
// Broadcast Connections
// Here you may define all of the broadcast connections that will be used
// to broadcast events to other systems or over websockets. Samples of
// each available type of connection are provided inside this array.
'connections' => [
// Default options removed since we don't use broadcasting.
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

View File

@ -35,6 +35,10 @@ return [
// Available caches stores
'stores' => [
'apc' => [
'driver' => 'apc',
],
'array' => [
'driver' => 'array',
'serialize' => false,
@ -45,7 +49,6 @@ return [
'table' => 'cache',
'connection' => null,
'lock_connection' => null,
'lock_table' => null,
],
'file' => [

View File

@ -40,16 +40,12 @@ if (env('REDIS_SERVERS', false)) {
// MYSQL
// Split out port from host if set
$mysqlHost = env('DB_HOST', 'localhost');
$mysqlHostExploded = explode(':', $mysqlHost);
$mysqlPort = env('DB_PORT', 3306);
$mysqlHostIpv6 = str_starts_with($mysqlHost, '[');
if ($mysqlHostIpv6 && str_contains($mysqlHost, ']:')) {
$mysqlHost = implode(':', array_slice($mysqlHostExploded, 0, -1));
$mysqlPort = intval(end($mysqlHostExploded));
} else if (!$mysqlHostIpv6 && count($mysqlHostExploded) > 1) {
$mysqlHost = $mysqlHostExploded[0];
$mysqlPort = intval($mysqlHostExploded[1]);
$mysql_host = env('DB_HOST', 'localhost');
$mysql_host_exploded = explode(':', $mysql_host);
$mysql_port = env('DB_PORT', 3306);
if (count($mysql_host_exploded) > 1) {
$mysql_host = $mysql_host_exploded[0];
$mysql_port = intval($mysql_host_exploded[1]);
}
return [
@ -65,12 +61,12 @@ return [
'mysql' => [
'driver' => 'mysql',
'url' => env('DATABASE_URL'),
'host' => $mysqlHost,
'host' => $mysql_host,
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'port' => $mysqlPort,
'port' => $mysql_port,
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
// Prefixes are only semi-supported and may be unstable
@ -92,7 +88,7 @@ return [
'database' => 'bookstack-test',
'username' => env('MYSQL_USER', 'bookstack-test'),
'password' => env('MYSQL_PASSWORD', 'bookstack-test'),
'port' => $mysqlPort,
'port' => $mysql_port,
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',

View File

@ -114,7 +114,6 @@ return [
* @var array
*/
'allowed_protocols' => [
"data://" => ["rules" => []],
'file://' => ['rules' => []],
'http://' => ['rules' => []],
'https://' => ['rules' => []],

View File

@ -33,14 +33,12 @@ return [
'driver' => 'local',
'root' => public_path(),
'visibility' => 'public',
'serve' => false,
'throw' => true,
],
'local_secure_attachments' => [
'driver' => 'local',
'root' => storage_path('uploads/files/'),
'serve' => false,
'throw' => true,
],
@ -48,7 +46,6 @@ return [
'driver' => 'local',
'root' => storage_path('uploads/images/'),
'visibility' => 'public',
'serve' => false,
'throw' => true,
],

View File

@ -38,7 +38,7 @@ return [
'password' => env('MAIL_PASSWORD'),
'verify_peer' => env('MAIL_VERIFY_SSL', true),
'timeout' => null,
'local_domain' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN'),
'tls_required' => ($mailEncryption === 'tls' || $mailEncryption === 'ssl'),
],
@ -64,4 +64,12 @@ return [
],
],
],
// Email markdown configuration
'markdown' => [
'theme' => 'default',
'paths' => [
resource_path('views/vendor/mail'),
],
],
];

View File

@ -23,7 +23,6 @@ return [
'database' => [
'driver' => 'database',
'connection' => null,
'table' => 'jobs',
'queue' => 'default',
'retry_after' => 90,

View File

@ -1,99 +0,0 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Entities\Models\Book;
use BookStack\Sorting\BookSorter;
use BookStack\Sorting\SortRule;
use Illuminate\Console\Command;
class AssignSortRuleCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:assign-sort-rule
{sort-rule=0: ID of the sort rule to apply}
{--all-books : Apply to all books in the system}
{--books-without-sort : Apply to only books without a sort rule already assigned}
{--books-with-sort= : Apply to only books with the sort rule of given id}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Assign a sort rule to content in the system';
/**
* Execute the console command.
*/
public function handle(BookSorter $sorter): int
{
$sortRuleId = intval($this->argument('sort-rule')) ?? 0;
if ($sortRuleId === 0) {
return $this->listSortRules();
}
$rule = SortRule::query()->find($sortRuleId);
if ($this->option('all-books')) {
$query = Book::query();
} else if ($this->option('books-without-sort')) {
$query = Book::query()->whereNull('sort_rule_id');
} else if ($this->option('books-with-sort')) {
$sortId = intval($this->option('books-with-sort')) ?: 0;
if (!$sortId) {
$this->error("Provided --books-with-sort option value is invalid");
return 1;
}
$query = Book::query()->where('sort_rule_id', $sortId);
} else {
$this->error("No option provided to specify target. Run with the -h option to see all available options.");
return 1;
}
if (!$rule) {
$this->error("Sort rule of provided id {$sortRuleId} not found!");
return 1;
}
$count = $query->clone()->count();
$this->warn("This will apply sort rule [{$rule->id}: {$rule->name}] to {$count} book(s) and run the sort on each.");
$confirmed = $this->confirm("Are you sure you want to continue?");
if (!$confirmed) {
return 1;
}
$processed = 0;
$query->chunkById(10, function ($books) use ($rule, $sorter, $count, &$processed) {
$max = min($count, ($processed + 10));
$this->info("Applying to {$processed}-{$max} of {$count} books");
foreach ($books as $book) {
$book->sort_rule_id = $rule->id;
$book->save();
$sorter->runBookAutoSort($book);
}
$processed = $max;
});
$this->info("Sort applied to {$processed} book(s)!");
return 0;
}
protected function listSortRules(): int
{
$rules = SortRule::query()->orderBy('id', 'asc')->get();
$this->error("Sort rule ID required!");
$this->warn("\nAvailable sort rules:");
foreach ($rules as $rule) {
$this->info("{$rule->id}: {$rule->name}");
}
return 1;
}
}

View File

@ -49,7 +49,6 @@ 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'],
@ -78,12 +77,6 @@ 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;

View File

@ -30,7 +30,6 @@ class BookApiController extends ApiController
{
$books = $this->queries
->visibleForList()
->with(['cover:id,name,url'])
->addSelect(['created_by', 'updated_by']);
return $this->apiListingResponse($books, [

View File

@ -70,7 +70,7 @@ class BookController extends Controller
/**
* Show the form for creating a new book.
*/
public function create(?string $shelfSlug = null)
public function create(string $shelfSlug = null)
{
$this->checkPermission('book-create-all');
@ -93,7 +93,7 @@ class BookController extends Controller
* @throws ImageUploadException
* @throws ValidationException
*/
public function store(Request $request, ?string $shelfSlug = null)
public function store(Request $request, string $shelfSlug = null)
{
$this->checkPermission('book-create-all');
$validated = $this->validate($request, [

View File

@ -1,9 +1,9 @@
<?php
namespace BookStack\Exports\Controllers;
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Exports\ExportFormatter;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Http\ApiController;
use Throwable;

View File

@ -1,11 +1,9 @@
<?php
namespace BookStack\Exports\Controllers;
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exports\ExportFormatter;
use BookStack\Exports\ZipExports\ZipExportBuilder;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Http\Controller;
use Throwable;
@ -16,7 +14,6 @@ class BookExportController extends Controller
protected ExportFormatter $exportFormatter,
) {
$this->middleware('can:content-export');
$this->middleware('throttle:exports');
}
/**
@ -66,16 +63,4 @@ 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()->streamedFileDirectly($zip, $bookSlug . '.zip', true);
}
}

View File

@ -1,10 +1,11 @@
<?php
namespace BookStack\Sorting;
namespace BookStack\Entities\Controllers;
use BookStack\Activity\ActivityType;
use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\BookSortMap;
use BookStack\Facades\Activity;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
@ -44,40 +45,25 @@ class BookSortController extends Controller
}
/**
* Update the sort options of a book, setting the auto-sort and/or updating
* child order via mapping.
* Sorts a book using a given mapping array.
*/
public function update(Request $request, BookSorter $sorter, string $bookSlug)
public function update(Request $request, string $bookSlug)
{
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission('book-update', $book);
$loggedActivityForBook = false;
// Sort via map
if ($request->filled('sort-tree')) {
$sortMap = BookSortMap::fromJson($request->get('sort-tree'));
$booksInvolved = $sorter->sortUsingMap($sortMap);
// Rebuild permissions and add activity for involved books.
foreach ($booksInvolved as $bookInvolved) {
Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
if ($bookInvolved->id === $book->id) {
$loggedActivityForBook = true;
}
}
// Return if no map sent
if (!$request->filled('sort-tree')) {
return redirect($book->getUrl());
}
if ($request->filled('auto-sort')) {
$sortSetId = intval($request->get('auto-sort')) ?: null;
if ($sortSetId && SortRule::query()->find($sortSetId) === null) {
$sortSetId = null;
}
$book->sort_rule_id = $sortSetId;
$book->save();
$sorter->runBookAutoSort($book);
if (!$loggedActivityForBook) {
Activity::add(ActivityType::BOOK_SORT, $book);
}
$sortMap = BookSortMap::fromJson($request->get('sort-tree'));
$bookContents = new BookContents($book);
$booksInvolved = $bookContents->sortUsingMap($sortMap);
// Rebuild permissions and add activity for involved books.
foreach ($booksInvolved as $bookInvolved) {
Activity::add(ActivityType::BOOK_SORT, $bookInvolved);
}
return redirect($book->getUrl());

View File

@ -26,7 +26,6 @@ class BookshelfApiController extends ApiController
{
$shelves = $this->queries
->visibleForList()
->with(['cover:id,name,url'])
->addSelect(['created_by', 'updated_by']);
return $this->apiListingResponse($shelves, [

View File

@ -1,9 +1,9 @@
<?php
namespace BookStack\Exports\Controllers;
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Queries\ChapterQueries;
use BookStack\Exports\ExportFormatter;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Http\ApiController;
use Throwable;

View File

@ -1,11 +1,10 @@
<?php
namespace BookStack\Exports\Controllers;
namespace BookStack\Entities\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;
@ -16,7 +15,6 @@ class ChapterExportController extends Controller
protected ExportFormatter $exportFormatter,
) {
$this->middleware('can:content-export');
$this->middleware('throttle:exports');
}
/**
@ -72,16 +70,4 @@ 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()->streamedFileDirectly($zip, $chapterSlug . '.zip', true);
}
}

View File

@ -41,7 +41,7 @@ class PageController extends Controller
*
* @throws Throwable
*/
public function create(string $bookSlug, ?string $chapterSlug = null)
public function create(string $bookSlug, string $chapterSlug = null)
{
if ($chapterSlug) {
$parent = $this->entityQueries->chapters->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
@ -69,7 +69,7 @@ class PageController extends Controller
*
* @throws ValidationException
*/
public function createAsGuest(Request $request, string $bookSlug, ?string $chapterSlug = null)
public function createAsGuest(Request $request, string $bookSlug, string $chapterSlug = null)
{
$this->validate($request, [
'name' => ['required', 'string', 'max:255'],

View File

@ -1,9 +1,9 @@
<?php
namespace BookStack\Exports\Controllers;
namespace BookStack\Entities\Controllers;
use BookStack\Entities\Queries\PageQueries;
use BookStack\Exports\ExportFormatter;
use BookStack\Entities\Tools\ExportFormatter;
use BookStack\Http\ApiController;
use Throwable;

View File

@ -1,12 +1,11 @@
<?php
namespace BookStack\Exports\Controllers;
namespace BookStack\Entities\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;
@ -17,7 +16,6 @@ class PageExportController extends Controller
protected ExportFormatter $exportFormatter,
) {
$this->middleware('can:content-export');
$this->middleware('throttle:exports');
}
/**
@ -76,16 +74,4 @@ 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()->streamedFileDirectly($zip, $pageSlug . '.zip', true);
}
}

View File

@ -43,6 +43,7 @@ class PageRevisionController extends Controller
->selectRaw("IF(markdown = '', false, true) as is_markdown")
->with(['page.book', 'createdBy'])
->reorder('id', $listOptions->getOrder())
->reorder('created_at', $listOptions->getOrder())
->paginate(50);
$this->setPageTitle(trans('entities.pages_revisions_named', ['pageName' => $page->getShortName()]));
@ -51,7 +52,6 @@ class PageRevisionController extends Controller
'revisions' => $revisions,
'page' => $page,
'listOptions' => $listOptions,
'oldestRevisionId' => $page->revisions()->min('id'),
]);
}

View File

@ -2,7 +2,6 @@
namespace BookStack\Entities\Models;
use BookStack\Sorting\SortRule;
use BookStack\Uploads\Image;
use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -17,14 +16,12 @@ use Illuminate\Support\Collection;
* @property string $description
* @property int $image_id
* @property ?int $default_template_id
* @property ?int $sort_rule_id
* @property Image|null $cover
* @property \Illuminate\Database\Eloquent\Collection $chapters
* @property \Illuminate\Database\Eloquent\Collection $pages
* @property \Illuminate\Database\Eloquent\Collection $directPages
* @property \Illuminate\Database\Eloquent\Collection $shelves
* @property ?Page $defaultTemplate
* @property ?SortRule $sortRule
*/
class Book extends Entity implements HasCoverImage
{
@ -85,14 +82,6 @@ class Book extends Entity implements HasCoverImage
return $this->belongsTo(Page::class, 'default_template_id');
}
/**
* Get the sort set assigned to this book, if existing.
*/
public function sortRule(): BelongsTo
{
return $this->belongsTo(SortRule::class);
}
/**
* Get all pages within this book.
*/

View File

@ -60,7 +60,6 @@ class Chapter extends BookChild
/**
* Get the visible pages in this chapter.
* @returns Collection<Page>
*/
public function getVisiblePages(): Collection
{

View File

@ -18,7 +18,7 @@ class QueryPopular
) {
}
public function run(int $count, int $page, array $filterModels): Collection
public function run(int $count, int $page, array $filterModels = null): Collection
{
$query = $this->permissions
->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type')
@ -26,7 +26,7 @@ class QueryPopular
->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc');
if (!empty($filterModels)) {
if ($filterModels) {
$query->whereIn('viewable_type', $this->entityProvider->getMorphClasses($filterModels));
}

View File

@ -4,7 +4,6 @@ namespace BookStack\Entities\Repos;
use BookStack\Activity\TagRepo;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage;
@ -13,7 +12,6 @@ use BookStack\Entities\Queries\PageQueries;
use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater;
use BookStack\Sorting\BookSorter;
use BookStack\Uploads\ImageRepo;
use BookStack\Util\HtmlDescriptionFilter;
use Illuminate\Http\UploadedFile;
@ -26,7 +24,6 @@ class BaseRepo
protected ReferenceUpdater $referenceUpdater,
protected ReferenceStore $referenceStore,
protected PageQueries $pageQueries,
protected BookSorter $bookSorter,
) {
}
@ -137,18 +134,6 @@ class BaseRepo
$entity->save();
}
/**
* Sort the parent of the given entity, if any auto sort actions are set for it.
* Typical ran during create/update/insert events.
*/
public function sortParent(Entity $entity): void
{
if ($entity instanceof BookChild) {
$book = $entity->book;
$this->bookSorter->runBookAutoSort($book);
}
}
protected function updateDescription(Entity $entity, array $input): void
{
if (!in_array(HasHtmlDescription::class, class_uses($entity))) {

View File

@ -8,7 +8,6 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Facades\Activity;
use BookStack\Sorting\SortRule;
use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Http\UploadedFile;
@ -34,12 +33,6 @@ class BookRepo
$this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null));
Activity::add(ActivityType::BOOK_CREATE, $book);
$defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) {
$book->sort_rule_id = $defaultBookSortSetting;
$book->save();
}
return $book;
}

View File

@ -34,8 +34,6 @@ class ChapterRepo
$this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null));
Activity::add(ActivityType::CHAPTER_CREATE, $chapter);
$this->baseRepo->sortParent($chapter);
return $chapter;
}
@ -52,8 +50,6 @@ class ChapterRepo
Activity::add(ActivityType::CHAPTER_UPDATE, $chapter);
$this->baseRepo->sortParent($chapter);
return $chapter;
}
@ -92,8 +88,6 @@ class ChapterRepo
$chapter->rebuildPermissions();
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
$this->baseRepo->sortParent($chapter);
return $parent;
}
}

View File

@ -83,22 +83,10 @@ class PageRepo
$draft->refresh();
Activity::add(ActivityType::PAGE_CREATE, $draft);
$this->baseRepo->sortParent($draft);
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.
*/
@ -129,12 +117,11 @@ class PageRepo
}
Activity::add(ActivityType::PAGE_UPDATE, $page);
$this->baseRepo->sortParent($page);
return $page;
}
protected function updateTemplateStatusAndContentFromInput(Page $page, array $input): void
protected function updateTemplateStatusAndContentFromInput(Page $page, array $input)
{
if (isset($input['template']) && userCan('templates-manage')) {
$page->template = ($input['template'] === 'true');
@ -245,8 +232,6 @@ class PageRepo
Activity::add(ActivityType::PAGE_RESTORE, $page);
Activity::add(ActivityType::REVISION_RESTORE, $revision);
$this->baseRepo->sortParent($page);
return $page;
}
@ -276,8 +261,6 @@ class PageRepo
Activity::add(ActivityType::PAGE_MOVE, $page);
$this->baseRepo->sortParent($page);
return $parent;
}

View File

@ -46,7 +46,7 @@ class RevisionRepo
/**
* Store a new revision in the system for the given page.
*/
public function storeNewForPage(Page $page, ?string $summary = null): PageRevision
public function storeNewForPage(Page $page, string $summary = null): PageRevision
{
$revision = new PageRevision();

View File

@ -8,8 +8,6 @@ use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Sorting\BookSortMap;
use BookStack\Sorting\BookSortMapItem;
use Illuminate\Support\Collection;
class BookContents
@ -105,4 +103,211 @@ class BookContents
return $query->where('book_id', '=', $this->book->id)->get();
}
/**
* Sort the books content using the given sort map.
* Returns a list of books that were involved in the operation.
*
* @returns Book[]
*/
public function sortUsingMap(BookSortMap $sortMap): array
{
// Load models into map
$modelMap = $this->loadModelsFromSortMap($sortMap);
// Sort our changes from our map to be chapters first
// Since they need to be process to ensure book alignment for child page changes.
$sortMapItems = $sortMap->all();
usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
$aScore = $itemA->type === 'page' ? 2 : 1;
$bScore = $itemB->type === 'page' ? 2 : 1;
return $aScore - $bScore;
});
// Perform the sort
foreach ($sortMapItems as $item) {
$this->applySortUpdates($item, $modelMap);
}
/** @var Book[] $booksInvolved */
$booksInvolved = array_values(array_filter($modelMap, function (string $key) {
return str_starts_with($key, 'book:');
}, ARRAY_FILTER_USE_KEY));
// Update permissions of books involved
foreach ($booksInvolved as $book) {
$book->rebuildPermissions();
}
return $booksInvolved;
}
/**
* Using the given sort map item, detect changes for the related model
* and update it if required. Changes where permissions are lacking will
* be skipped and not throw an error.
*
* @param array<string, Entity> $modelMap
*/
protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
{
/** @var BookChild $model */
$model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
if (!$model) {
return;
}
$priorityChanged = $model->priority !== $sortMapItem->sort;
$bookChanged = $model->book_id !== $sortMapItem->parentBookId;
$chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
// Stop if there's no change
if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
return;
}
$currentParentKey = 'book:' . $model->book_id;
if ($model instanceof Page && $model->chapter_id) {
$currentParentKey = 'chapter:' . $model->chapter_id;
}
$currentParent = $modelMap[$currentParentKey] ?? null;
/** @var Book $newBook */
$newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
/** @var ?Chapter $newChapter */
$newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
return;
}
// Action the required changes
if ($bookChanged) {
$model->changeBook($newBook->id);
}
if ($model instanceof Page && $chapterChanged) {
$model->chapter_id = $newChapter->id ?? 0;
}
if ($priorityChanged) {
$model->priority = $sortMapItem->sort;
}
if ($chapterChanged || $priorityChanged) {
$model->save();
}
}
/**
* Check if the current user has permissions to apply the given sorting change.
* Is quite complex since items can gain a different parent change. Acts as a:
* - Update of old parent element (Change of content/order).
* - Update of sorted/moved element.
* - Deletion of element (Relative to parent upon move).
* - Creation of element within parent (Upon move to new parent).
*/
protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
{
// Stop if we can't see the current parent or new book.
if (!$currentParent || !$newBook) {
return false;
}
$hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
if ($model instanceof Chapter) {
$hasPermission = userCan('book-update', $currentParent)
&& userCan('book-update', $newBook)
&& userCan('chapter-update', $model)
&& (!$hasNewParent || userCan('chapter-create', $newBook))
&& (!$hasNewParent || userCan('chapter-delete', $model));
if (!$hasPermission) {
return false;
}
}
if ($model instanceof Page) {
$parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasCurrentParentPermission = userCan($parentPermission, $currentParent);
// This needs to check if there was an intended chapter location in the original sort map
// rather than inferring from the $newChapter since that variable may be null
// due to other reasons (Visibility).
$newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
if (!$newParent) {
return false;
}
$hasPageEditPermission = userCan('page-update', $model);
$newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
$newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasNewParentPermission = userCan($newParentPermission, $newParent);
$hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
$hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
$hasPermission = $hasCurrentParentPermission
&& $newParentInRightLocation
&& $hasNewParentPermission
&& $hasPageEditPermission
&& $hasDeletePermissionIfMoving
&& $hasCreatePermissionIfMoving;
if (!$hasPermission) {
return false;
}
}
return true;
}
/**
* Load models from the database into the given sort map.
*
* @return array<string, Entity>
*/
protected function loadModelsFromSortMap(BookSortMap $sortMap): array
{
$modelMap = [];
$ids = [
'chapter' => [],
'page' => [],
'book' => [],
];
foreach ($sortMap->all() as $sortMapItem) {
$ids[$sortMapItem->type][] = $sortMapItem->id;
$ids['book'][] = $sortMapItem->parentBookId;
if ($sortMapItem->parentChapterId) {
$ids['chapter'][] = $sortMapItem->parentChapterId;
}
}
$pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
/** @var Page $page */
foreach ($pages as $page) {
$modelMap['page:' . $page->id] = $page;
$ids['book'][] = $page->book_id;
if ($page->chapter_id) {
$ids['chapter'][] = $page->chapter_id;
}
}
$chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get();
/** @var Chapter $chapter */
foreach ($chapters as $chapter) {
$modelMap['chapter:' . $chapter->id] = $chapter;
$ids['book'][] = $chapter->book_id;
}
$books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
/** @var Book $book */
foreach ($books as $book) {
$modelMap['book:' . $book->id] = $book;
}
return $modelMap;
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace BookStack\Sorting;
namespace BookStack\Entities\Tools;
class BookSortMap
{

View File

@ -1,6 +1,6 @@
<?php
namespace BookStack\Sorting;
namespace BookStack\Entities\Tools;
class BookSortMapItem
{

View File

@ -18,12 +18,17 @@ use Illuminate\Http\UploadedFile;
class Cloner
{
public function __construct(
protected PageRepo $pageRepo,
protected ChapterRepo $chapterRepo,
protected BookRepo $bookRepo,
protected ImageService $imageService,
) {
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;
}
/**

View File

@ -1,13 +1,11 @@
<?php
namespace BookStack\Exports;
namespace BookStack\Entities\Tools;
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;
@ -317,12 +315,7 @@ class ExportFormatter
public function chapterToMarkdown(Chapter $chapter): string
{
$text = '# ' . $chapter->name . "\n\n";
$description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert();
if ($description) {
$text .= $description . "\n\n";
}
$text .= $chapter->description . "\n\n";
foreach ($chapter->pages as $page) {
$text .= $this->pageToMarkdown($page) . "\n\n";
}
@ -337,12 +330,6 @@ 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";

View File

@ -104,10 +104,10 @@ class PageIncludeParser
if ($currentOffset < $tagStartOffset) {
$previousText = substr($text, $currentOffset, $tagStartOffset - $currentOffset);
$textNode->parentNode->insertBefore($this->doc->createTextNode($previousText), $textNode);
$textNode->parentNode->insertBefore(new DOMText($previousText), $textNode);
}
$node = $textNode->parentNode->insertBefore($this->doc->createTextNode($tagOuterContent), $textNode);
$node = $textNode->parentNode->insertBefore(new DOMText($tagOuterContent), $textNode);
$includeTags[] = new PageIncludeTag($tagInnerContent, $node);
$currentOffset = $tagStartOffset + strlen($tagOuterContent);
}

View File

@ -1,10 +1,10 @@
<?php
namespace BookStack\Exports;
namespace BookStack\Entities\Tools;
use BookStack\Exceptions\PdfExportException;
use Dompdf\Dompdf;
use Knp\Snappy\Pdf as SnappyPdf;
use Dompdf\Dompdf;
use Symfony\Component\Process\Exception\ProcessTimedOutException;
use Symfony\Component\Process\Process;
@ -90,28 +90,18 @@ class PdfGenerator
$process = Process::fromShellCommandline($command);
$process->setTimeout($timeout);
$cleanup = function () use ($inputHtml, $outputPdf) {
foreach ([$inputHtml, $outputPdf] as $file) {
if (file_exists($file)) {
unlink($file);
}
}
};
try {
$process->run();
} catch (ProcessTimedOutException $e) {
$cleanup();
throw new PdfExportException("PDF Export via command failed due to timeout at {$timeout} second(s)");
}
if (!$process->isSuccessful()) {
$cleanup();
throw new PdfExportException("PDF Export via command failed with exit code {$process->getExitCode()}, stdout: {$process->getOutput()}, stderr: {$process->getErrorOutput()}");
}
$pdfContents = file_get_contents($outputPdf);
$cleanup();
unlink($outputPdf);
if ($pdfContents === false) {
throw new PdfExportException("PDF Export via command failed, unable to read PDF output file");

View File

@ -1,7 +0,0 @@
<?php
namespace BookStack\Exceptions;
class LoginAttemptInvalidUserException extends LoginAttemptException
{
}

View File

@ -1,7 +0,0 @@
<?php
namespace BookStack\Exceptions;
class ZipExportException extends \Exception
{
}

View File

@ -1,13 +0,0 @@
<?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);
}
}

View File

@ -1,12 +0,0 @@
<?php
namespace BookStack\Exceptions;
class ZipValidationException extends \Exception
{
public function __construct(
public array $errors
) {
parent::__construct();
}
}

View File

@ -1,110 +0,0 @@
<?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');
}
}

View File

@ -1,66 +0,0 @@
<?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,
};
}
}

View File

@ -1,137 +0,0 @@
<?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);
}
}

View File

@ -1,66 +0,0 @@
<?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;
}
}

View File

@ -1,118 +0,0 @@
<?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;
}
}

View File

@ -1,95 +0,0 @@
<?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;
}
}

View File

@ -1,57 +0,0 @@
<?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;
}
}

View File

@ -1,57 +0,0 @@
<?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;
}

View File

@ -1,104 +0,0 @@
<?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;
}
}

View File

@ -1,51 +0,0 @@
<?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;
}
}

View File

@ -1,117 +0,0 @@
<?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 = [];
$addedNames = [];
try {
$this->files->extractEach(function ($filePath, $fileRef) use ($zip, &$toRemove, &$addedNames) {
$entryName = "files/$fileRef";
$zip->addFile($filePath, $entryName);
$toRemove[] = $filePath;
$addedNames[] = $entryName;
});
} catch (\Exception $exception) {
// Cleanup the files we've processed so far and respond back with error
foreach ($toRemove as $file) {
unlink($file);
}
foreach ($addedNames as $name) {
$zip->deleteName($name);
}
$zip->close();
unlink($zipFile);
throw new ZipExportException("Failed to add files for ZIP export, received error: " . $exception->getMessage());
}
$zip->close();
foreach ($toRemove as $file) {
unlink($file);
}
return $zipFile;
}
}

View File

@ -1,107 +0,0 @@
<?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);
}
}
}

View File

@ -1,111 +0,0 @@
<?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.");
}
}

View File

@ -1,159 +0,0 @@
<?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;
}
}

View File

@ -1,57 +0,0 @@
<?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;
}
}

View File

@ -1,37 +0,0 @@
<?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
]);
}
}
}
}

View File

@ -1,161 +0,0 @@
<?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;
}
}

View File

@ -1,364 +0,0 @@
<?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;
}
}

View File

@ -1,140 +0,0 @@
<?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>\"'=?#()]/";
}
}

View File

@ -1,26 +0,0 @@
<?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]);
}
}
}

View File

@ -1,77 +0,0 @@
<?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;
}
}

View File

@ -152,8 +152,10 @@ abstract class Controller extends BaseController
/**
* Log an activity in the system.
*
* @param string|Loggable $detail
*/
protected function logActivity(string $type, string|Loggable $detail = ''): void
protected function logActivity(string $type, $detail = ''): void
{
Activity::add($type, $detail);
}

View File

@ -9,7 +9,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
class DownloadResponseFactory
{
public function __construct(
protected Request $request,
protected Request $request
) {
}
@ -35,33 +35,6 @@ class DownloadResponseFactory
);
}
/**
* Create a response that downloads the given file via a stream.
* Has the option to delete the provided file once the stream is closed.
*/
public function streamedFileDirectly(string $filePath, string $fileName, bool $deleteAfter = false): StreamedResponse
{
$fileSize = filesize($filePath);
$stream = fopen($filePath, 'r');
if ($deleteAfter) {
// Delete the given file if it still exists after the app terminates
$callback = function () use ($filePath) {
if (file_exists($filePath)) {
unlink($filePath);
}
};
// We watch both app terminate and php shutdown to cover both normal app termination
// as well as other potential scenarios (connection termination).
app()->terminating($callback);
register_shutdown_function($callback);
}
return $this->streamedDirectly($stream, $fileName, $fileSize);
}
/**
* Create a file download response that provides the file with a content-type
* correct for the file, in a way so the browser can show the content in browser,
@ -70,7 +43,7 @@ class DownloadResponseFactory
public function streamedInline($stream, string $fileName, int $fileSize): StreamedResponse
{
$rangeStream = new RangeSupportedStream($stream, $fileSize, $this->request);
$mime = $rangeStream->sniffMime(pathinfo($fileName, PATHINFO_EXTENSION));
$mime = $rangeStream->sniffMime();
$headers = array_merge($this->getHeaders($fileName, $fileSize, $mime), $rangeStream->getResponseHeaders());
return response()->stream(
@ -80,22 +53,6 @@ class DownloadResponseFactory
);
}
/**
* Create a response that provides the given file via a stream with detected content-type.
* Has the option to delete the provided file once the stream is closed.
*/
public function streamedFileInline(string $filePath, ?string $fileName = null): StreamedResponse
{
$fileSize = filesize($filePath);
$stream = fopen($filePath, 'r');
if ($fileName === null) {
$fileName = basename($filePath);
}
return $this->streamedInline($stream, $fileName, $fileSize);
}
/**
* Get the common headers to provide for a download response.
*/

View File

@ -7,13 +7,6 @@ use Symfony\Component\HttpFoundation\Response;
class PreventResponseCaching
{
/**
* Paths to ignore when preventing response caching.
*/
protected array $ignoredPathPrefixes = [
'theme/',
];
/**
* Handle an incoming request.
*
@ -27,13 +20,6 @@ class PreventResponseCaching
/** @var Response $response */
$response = $next($request);
$path = $request->path();
foreach ($this->ignoredPathPrefixes as $ignoredPath) {
if (str_starts_with($path, $ignoredPath)) {
return $response;
}
}
$response->headers->set('Cache-Control', 'no-cache, no-store, private');
$response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');

View File

@ -32,12 +32,12 @@ class RangeSupportedStream
/**
* Sniff a mime type from the stream.
*/
public function sniffMime(string $extension = ''): string
public function sniffMime(): string
{
$offset = min(2000, $this->fileSize);
$this->sniffContent = fread($this->stream, $offset);
return (new WebSafeMimeSniffer())->sniff($this->sniffContent, $extension);
return (new WebSafeMimeSniffer())->sniff($this->sniffContent);
}
/**
@ -92,7 +92,7 @@ class RangeSupportedStream
if ($start < 0 || $start > $end) {
$this->responseStatus = 416;
$this->responseHeaders['Content-Range'] = sprintf('bytes */%s', $this->fileSize);
} else {
} elseif ($end - $start < $this->fileSize - 1) {
$this->responseLength = $end < $this->fileSize ? $end - $start + 1 : -1;
$this->responseOffset = $start;
$this->responseStatus = 206;

View File

@ -1,22 +0,0 @@
<?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);
}
}

View File

@ -1,58 +0,0 @@
<?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;
}
}

View File

@ -9,18 +9,21 @@ 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(
protected SearchRunner $searchRunner,
protected SearchResultsFormatter $resultsFormatter
) {
public function __construct(SearchRunner $searchRunner, SearchResultsFormatter $resultsFormatter)
{
$this->searchRunner = $searchRunner;
$this->resultsFormatter = $resultsFormatter;
}
/**
@ -47,16 +50,16 @@ class SearchApiController extends ApiController
$this->resultsFormatter->format($results['results']->all(), $options);
$data = (new ApiEntityListFormatter($results['results']->all()))
->withType()->withTags()->withParents()
->withType()->withTags()
->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'],
]);
}

View File

@ -16,13 +16,7 @@ class SearchIndex
/**
* A list of delimiter characters used to break-up parsed content into terms for indexing.
*/
public static string $delimiters = " \n\t.-,!?:;()[]{}<>`'\"«»";
/**
* A list of delimiter which could be commonly used within a single term and also indicate a break between terms.
* The indexer will index the full term with these delimiters, plus the terms split via these delimiters.
*/
public static string $softDelimiters = ".-";
public static string $delimiters = " \n\t.,!?:;()[]{}<>`'\"";
public function __construct(
protected EntityProvider $entityProvider
@ -36,7 +30,7 @@ class SearchIndex
{
$this->deleteEntityTerms($entity);
$terms = $this->entityToTermDataArray($entity);
$this->insertTerms($terms);
SearchTerm::query()->insert($terms);
}
/**
@ -52,7 +46,10 @@ class SearchIndex
array_push($terms, ...$entityTerms);
}
$this->insertTerms($terms);
$chunkedTerms = array_chunk($terms, 500);
foreach ($chunkedTerms as $termChunk) {
SearchTerm::query()->insert($termChunk);
}
}
/**
@ -102,19 +99,6 @@ 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.
@ -202,36 +186,15 @@ class SearchIndex
protected function textToTermCountMap(string $text): array
{
$tokenMap = []; // {TextToken => OccurrenceCount}
$softDelims = static::$softDelimiters;
$tokenizer = new SearchTextTokenizer($text, static::$delimiters);
$extendedToken = '';
$extendedLen = 0;
$token = $tokenizer->next();
$splitChars = static::$delimiters;
$token = strtok($text, $splitChars);
while ($token !== false) {
$delim = $tokenizer->previousDelimiter();
if ($delim && str_contains($softDelims, $delim) && $token !== '') {
$extendedToken .= $delim . $token;
$extendedLen++;
} else {
if ($extendedLen > 1) {
$tokenMap[$extendedToken] = ($tokenMap[$extendedToken] ?? 0) + 1;
}
$extendedToken = $token;
$extendedLen = 1;
if (!isset($tokenMap[$token])) {
$tokenMap[$token] = 0;
}
if ($token) {
$tokenMap[$token] = ($tokenMap[$token] ?? 0) + 1;
}
$token = $tokenizer->next();
}
if ($extendedLen > 1) {
$tokenMap[$extendedToken] = ($tokenMap[$extendedToken] ?? 0) + 1;
$tokenMap[$token]++;
$token = strtok($splitChars);
}
return $tokenMap;

View File

@ -181,7 +181,7 @@ class SearchOptions
protected static function parseStandardTermString(string $termString): array
{
$terms = explode(' ', $termString);
$indexDelimiters = implode('', array_diff(str_split(SearchIndex::$delimiters), str_split(SearchIndex::$softDelimiters)));
$indexDelimiters = SearchIndex::$delimiters;
$parsed = [
'terms' => [],
'exacts' => [],

View File

@ -1,70 +0,0 @@
<?php
namespace BookStack\Search;
/**
* A custom text tokenizer which records & provides insight needed for our search indexing.
* We used to use basic strtok() but this class does the following which that lacked:
* - Tracks and provides the current/previous delimiter that we've stopped at.
* - Returns empty tokens upon parsing a delimiter.
*/
class SearchTextTokenizer
{
protected int $currentIndex = 0;
protected int $length;
protected string $currentDelimiter = '';
protected string $previousDelimiter = '';
public function __construct(
protected string $text,
protected string $delimiters = ' '
) {
$this->length = strlen($this->text);
}
/**
* Get the current delimiter to be found.
*/
public function currentDelimiter(): string
{
return $this->currentDelimiter;
}
/**
* Get the previous delimiter found.
*/
public function previousDelimiter(): string
{
return $this->previousDelimiter;
}
/**
* Get the next token between delimiters.
* Returns false if there's no further tokens.
*/
public function next(): string|false
{
$token = '';
for ($i = $this->currentIndex; $i < $this->length; $i++) {
$char = $this->text[$i];
if (str_contains($this->delimiters, $char)) {
$this->previousDelimiter = $this->currentDelimiter;
$this->currentDelimiter = $char;
$this->currentIndex = $i + 1;
return $token;
}
$token .= $char;
}
if ($token) {
$this->currentIndex = $this->length;
$this->previousDelimiter = $this->currentDelimiter;
$this->currentDelimiter = '';
return $token;
}
return false;
}
}

View File

@ -9,6 +9,8 @@ use Illuminate\Http\Request;
class SettingController extends Controller
{
protected array $settingCategories = ['features', 'customization', 'registration'];
/**
* Handle requests to the settings index path.
*/
@ -29,7 +31,7 @@ class SettingController extends Controller
// Get application version
$version = trim(file_get_contents(base_path('version')));
return view('settings.categories.' . $category, [
return view('settings.' . $category, [
'category' => $category,
'version' => $version,
'guestUser' => User::getGuest(),
@ -57,7 +59,7 @@ class SettingController extends Controller
protected function ensureCategoryExists(string $category): void
{
if (!view()->exists('settings.categories.' . $category)) {
if (!in_array($category, $this->settingCategories)) {
abort(404);
}
}

View File

@ -1,284 +0,0 @@
<?php
namespace BookStack\Sorting;
use BookStack\App\Model;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries;
class BookSorter
{
public function __construct(
protected EntityQueries $queries,
) {
}
public function runBookAutoSortForAllWithSet(SortRule $set): void
{
$set->books()->chunk(50, function ($books) {
foreach ($books as $book) {
$this->runBookAutoSort($book);
}
});
}
/**
* Runs the auto-sort for a book if the book has a sort set applied to it.
* This does not consider permissions since the sort operations are centrally
* managed by admins so considered permitted if existing and assigned.
*/
public function runBookAutoSort(Book $book): void
{
$set = $book->sortRule;
if (!$set) {
return;
}
$sortFunctions = array_map(function (SortRuleOperation $op) {
return $op->getSortFunction();
}, $set->getOperations());
$chapters = $book->chapters()
->with('pages:id,name,priority,created_at,updated_at,chapter_id')
->get(['id', 'name', 'priority', 'created_at', 'updated_at']);
/** @var (Chapter|Book)[] $topItems */
$topItems = [
...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']),
...$chapters,
];
foreach ($sortFunctions as $sortFunction) {
usort($topItems, $sortFunction);
}
foreach ($topItems as $index => $topItem) {
$topItem->priority = $index + 1;
$topItem::withoutTimestamps(fn () => $topItem->save());
}
foreach ($chapters as $chapter) {
$pages = $chapter->pages->all();
foreach ($sortFunctions as $sortFunction) {
usort($pages, $sortFunction);
}
foreach ($pages as $index => $page) {
$page->priority = $index + 1;
$page::withoutTimestamps(fn () => $page->save());
}
}
}
/**
* Sort the books content using the given sort map.
* Returns a list of books that were involved in the operation.
*
* @returns Book[]
*/
public function sortUsingMap(BookSortMap $sortMap): array
{
// Load models into map
$modelMap = $this->loadModelsFromSortMap($sortMap);
// Sort our changes from our map to be chapters first
// Since they need to be process to ensure book alignment for child page changes.
$sortMapItems = $sortMap->all();
usort($sortMapItems, function (BookSortMapItem $itemA, BookSortMapItem $itemB) {
$aScore = $itemA->type === 'page' ? 2 : 1;
$bScore = $itemB->type === 'page' ? 2 : 1;
return $aScore - $bScore;
});
// Perform the sort
foreach ($sortMapItems as $item) {
$this->applySortUpdates($item, $modelMap);
}
/** @var Book[] $booksInvolved */
$booksInvolved = array_values(array_filter($modelMap, function (string $key) {
return str_starts_with($key, 'book:');
}, ARRAY_FILTER_USE_KEY));
// Update permissions of books involved
foreach ($booksInvolved as $book) {
$book->rebuildPermissions();
}
return $booksInvolved;
}
/**
* Using the given sort map item, detect changes for the related model
* and update it if required. Changes where permissions are lacking will
* be skipped and not throw an error.
*
* @param array<string, Entity> $modelMap
*/
protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMap): void
{
/** @var BookChild $model */
$model = $modelMap[$sortMapItem->type . ':' . $sortMapItem->id] ?? null;
if (!$model) {
return;
}
$priorityChanged = $model->priority !== $sortMapItem->sort;
$bookChanged = $model->book_id !== $sortMapItem->parentBookId;
$chapterChanged = ($model instanceof Page) && $model->chapter_id !== $sortMapItem->parentChapterId;
// Stop if there's no change
if (!$priorityChanged && !$bookChanged && !$chapterChanged) {
return;
}
$currentParentKey = 'book:' . $model->book_id;
if ($model instanceof Page && $model->chapter_id) {
$currentParentKey = 'chapter:' . $model->chapter_id;
}
$currentParent = $modelMap[$currentParentKey] ?? null;
/** @var Book $newBook */
$newBook = $modelMap['book:' . $sortMapItem->parentBookId] ?? null;
/** @var ?Chapter $newChapter */
$newChapter = $sortMapItem->parentChapterId ? ($modelMap['chapter:' . $sortMapItem->parentChapterId] ?? null) : null;
if (!$this->isSortChangePermissible($sortMapItem, $model, $currentParent, $newBook, $newChapter)) {
return;
}
// Action the required changes
if ($bookChanged) {
$model->changeBook($newBook->id);
}
if ($model instanceof Page && $chapterChanged) {
$model->chapter_id = $newChapter->id ?? 0;
}
if ($priorityChanged) {
$model->priority = $sortMapItem->sort;
}
if ($chapterChanged || $priorityChanged) {
$model::withoutTimestamps(fn () => $model->save());
}
}
/**
* Check if the current user has permissions to apply the given sorting change.
* Is quite complex since items can gain a different parent change. Acts as a:
* - Update of old parent element (Change of content/order).
* - Update of sorted/moved element.
* - Deletion of element (Relative to parent upon move).
* - Creation of element within parent (Upon move to new parent).
*/
protected function isSortChangePermissible(BookSortMapItem $sortMapItem, BookChild $model, ?Entity $currentParent, ?Entity $newBook, ?Entity $newChapter): bool
{
// Stop if we can't see the current parent or new book.
if (!$currentParent || !$newBook) {
return false;
}
$hasNewParent = $newBook->id !== $model->book_id || ($model instanceof Page && $model->chapter_id !== ($sortMapItem->parentChapterId ?? 0));
if ($model instanceof Chapter) {
$hasPermission = userCan('book-update', $currentParent)
&& userCan('book-update', $newBook)
&& userCan('chapter-update', $model)
&& (!$hasNewParent || userCan('chapter-create', $newBook))
&& (!$hasNewParent || userCan('chapter-delete', $model));
if (!$hasPermission) {
return false;
}
}
if ($model instanceof Page) {
$parentPermission = ($currentParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasCurrentParentPermission = userCan($parentPermission, $currentParent);
// This needs to check if there was an intended chapter location in the original sort map
// rather than inferring from the $newChapter since that variable may be null
// due to other reasons (Visibility).
$newParent = $sortMapItem->parentChapterId ? $newChapter : $newBook;
if (!$newParent) {
return false;
}
$hasPageEditPermission = userCan('page-update', $model);
$newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
$newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
$hasNewParentPermission = userCan($newParentPermission, $newParent);
$hasDeletePermissionIfMoving = (!$hasNewParent || userCan('page-delete', $model));
$hasCreatePermissionIfMoving = (!$hasNewParent || userCan('page-create', $newParent));
$hasPermission = $hasCurrentParentPermission
&& $newParentInRightLocation
&& $hasNewParentPermission
&& $hasPageEditPermission
&& $hasDeletePermissionIfMoving
&& $hasCreatePermissionIfMoving;
if (!$hasPermission) {
return false;
}
}
return true;
}
/**
* Load models from the database into the given sort map.
*
* @return array<string, Entity>
*/
protected function loadModelsFromSortMap(BookSortMap $sortMap): array
{
$modelMap = [];
$ids = [
'chapter' => [],
'page' => [],
'book' => [],
];
foreach ($sortMap->all() as $sortMapItem) {
$ids[$sortMapItem->type][] = $sortMapItem->id;
$ids['book'][] = $sortMapItem->parentBookId;
if ($sortMapItem->parentChapterId) {
$ids['chapter'][] = $sortMapItem->parentChapterId;
}
}
$pages = $this->queries->pages->visibleForList()->whereIn('id', array_unique($ids['page']))->get();
/** @var Page $page */
foreach ($pages as $page) {
$modelMap['page:' . $page->id] = $page;
$ids['book'][] = $page->book_id;
if ($page->chapter_id) {
$ids['chapter'][] = $page->chapter_id;
}
}
$chapters = $this->queries->chapters->visibleForList()->whereIn('id', array_unique($ids['chapter']))->get();
/** @var Chapter $chapter */
foreach ($chapters as $chapter) {
$modelMap['chapter:' . $chapter->id] = $chapter;
$ids['book'][] = $chapter->book_id;
}
$books = $this->queries->books->visibleForList()->whereIn('id', array_unique($ids['book']))->get();
/** @var Book $book */
foreach ($books as $book) {
$modelMap['book:' . $book->id] = $book;
}
return $modelMap;
}
}

View File

@ -1,63 +0,0 @@
<?php
namespace BookStack\Sorting;
use BookStack\Activity\Models\Loggable;
use BookStack\Entities\Models\Book;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property string $name
* @property string $sequence
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class SortRule extends Model implements Loggable
{
use HasFactory;
/**
* @return SortRuleOperation[]
*/
public function getOperations(): array
{
return SortRuleOperation::fromSequence($this->sequence);
}
/**
* @param SortRuleOperation[] $options
*/
public function setOperations(array $options): void
{
$values = array_map(fn (SortRuleOperation $opt) => $opt->value, $options);
$this->sequence = implode(',', $values);
}
public function logDescriptor(): string
{
return "({$this->id}) {$this->name}";
}
public function getUrl(): string
{
return url("/settings/sorting/rules/{$this->id}");
}
public function books(): HasMany
{
return $this->hasMany(Book::class);
}
public static function allByName(): Collection
{
return static::query()
->withCount('books')
->orderBy('name', 'asc')
->get();
}
}

View File

@ -1,114 +0,0 @@
<?php
namespace BookStack\Sorting;
use BookStack\Activity\ActivityType;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
class SortRuleController extends Controller
{
public function __construct()
{
$this->middleware('can:settings-manage');
}
public function create()
{
$this->setPageTitle(trans('settings.sort_rule_create'));
return view('settings.sort-rules.create');
}
public function store(Request $request)
{
$this->validate($request, [
'name' => ['required', 'string', 'min:1', 'max:200'],
'sequence' => ['required', 'string', 'min:1'],
]);
$operations = SortRuleOperation::fromSequence($request->input('sequence'));
if (count($operations) === 0) {
return redirect()->withInput()->withErrors(['sequence' => 'No operations set.']);
}
$rule = new SortRule();
$rule->name = $request->input('name');
$rule->setOperations($operations);
$rule->save();
$this->logActivity(ActivityType::SORT_RULE_CREATE, $rule);
return redirect('/settings/sorting');
}
public function edit(string $id)
{
$rule = SortRule::query()->findOrFail($id);
$this->setPageTitle(trans('settings.sort_rule_edit'));
return view('settings.sort-rules.edit', ['rule' => $rule]);
}
public function update(string $id, Request $request, BookSorter $bookSorter)
{
$this->validate($request, [
'name' => ['required', 'string', 'min:1', 'max:200'],
'sequence' => ['required', 'string', 'min:1'],
]);
$rule = SortRule::query()->findOrFail($id);
$operations = SortRuleOperation::fromSequence($request->input('sequence'));
if (count($operations) === 0) {
return redirect($rule->getUrl())->withInput()->withErrors(['sequence' => 'No operations set.']);
}
$rule->name = $request->input('name');
$rule->setOperations($operations);
$changedSequence = $rule->isDirty('sequence');
$rule->save();
$this->logActivity(ActivityType::SORT_RULE_UPDATE, $rule);
if ($changedSequence) {
$bookSorter->runBookAutoSortForAllWithSet($rule);
}
return redirect('/settings/sorting');
}
public function destroy(string $id, Request $request)
{
$rule = SortRule::query()->findOrFail($id);
$confirmed = $request->input('confirm') === 'true';
$booksAssigned = $rule->books()->count();
$warnings = [];
if ($booksAssigned > 0) {
if ($confirmed) {
$rule->books()->update(['sort_rule_id' => null]);
} else {
$warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]);
}
}
$defaultBookSortSetting = intval(setting('sorting-book-default', '0'));
if ($defaultBookSortSetting === intval($id)) {
if ($confirmed) {
setting()->remove('sorting-book-default');
} else {
$warnings[] = trans('settings.sort_rule_delete_warn_default');
}
}
if (count($warnings) > 0) {
return redirect($rule->getUrl() . '#delete')->withErrors(['delete' => $warnings]);
}
$rule->delete();
$this->logActivity(ActivityType::SORT_RULE_DELETE, $rule);
return redirect('/settings/sorting');
}
}

View File

@ -1,69 +0,0 @@
<?php
namespace BookStack\Sorting;
use Closure;
use Illuminate\Support\Str;
enum SortRuleOperation: string
{
case NameAsc = 'name_asc';
case NameDesc = 'name_desc';
case NameNumericAsc = 'name_numeric_asc';
case NameNumericDesc = 'name_numeric_desc';
case CreatedDateAsc = 'created_date_asc';
case CreatedDateDesc = 'created_date_desc';
case UpdateDateAsc = 'updated_date_asc';
case UpdateDateDesc = 'updated_date_desc';
case ChaptersFirst = 'chapters_first';
case ChaptersLast = 'chapters_last';
/**
* Provide a translated label string for this option.
*/
public function getLabel(): string
{
$key = $this->value;
$label = '';
if (str_ends_with($key, '_asc')) {
$key = substr($key, 0, -4);
$label = trans('settings.sort_rule_op_asc');
} elseif (str_ends_with($key, '_desc')) {
$key = substr($key, 0, -5);
$label = trans('settings.sort_rule_op_desc');
}
$label = trans('settings.sort_rule_op_' . $key) . ' ' . $label;
return trim($label);
}
public function getSortFunction(): callable
{
$camelValue = Str::camel($this->value);
return SortSetOperationComparisons::$camelValue(...);
}
/**
* @return SortRuleOperation[]
*/
public static function allExcluding(array $operations): array
{
$all = SortRuleOperation::cases();
$filtered = array_filter($all, function (SortRuleOperation $operation) use ($operations) {
return !in_array($operation, $operations);
});
return array_values($filtered);
}
/**
* Create a set of operations from a string sequence representation.
* (values seperated by commas).
* @return SortRuleOperation[]
*/
public static function fromSequence(string $sequence): array
{
$strOptions = explode(',', $sequence);
$options = array_map(fn ($val) => SortRuleOperation::tryFrom($val), $strOptions);
return array_filter($options);
}
}

View File

@ -1,72 +0,0 @@
<?php
namespace BookStack\Sorting;
use voku\helper\ASCII;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity;
/**
* Sort comparison function for each of the possible SortSetOperation values.
* Method names should be camelCase names for the SortSetOperation enum value.
*/
class SortSetOperationComparisons
{
public static function nameAsc(Entity $a, Entity $b): int
{
return strtolower(ASCII::to_transliterate($a->name, null)) <=> strtolower(ASCII::to_transliterate($b->name, null));
}
public static function nameDesc(Entity $a, Entity $b): int
{
return strtolower(ASCII::to_transliterate($b->name, null)) <=> strtolower(ASCII::to_transliterate($a->name, null));
}
public static function nameNumericAsc(Entity $a, Entity $b): int
{
$numRegex = '/^\d+(\.\d+)?/';
$aMatches = [];
$bMatches = [];
preg_match($numRegex, $a->name, $aMatches);
preg_match($numRegex, $b->name, $bMatches);
$aVal = floatval(($aMatches[0] ?? 0));
$bVal = floatval(($bMatches[0] ?? 0));
return $aVal <=> $bVal;
}
public static function nameNumericDesc(Entity $a, Entity $b): int
{
return -(static::nameNumericAsc($a, $b));
}
public static function createdDateAsc(Entity $a, Entity $b): int
{
return $a->created_at->unix() <=> $b->created_at->unix();
}
public static function createdDateDesc(Entity $a, Entity $b): int
{
return $b->created_at->unix() <=> $a->created_at->unix();
}
public static function updatedDateAsc(Entity $a, Entity $b): int
{
return $a->updated_at->unix() <=> $b->updated_at->unix();
}
public static function updatedDateDesc(Entity $a, Entity $b): int
{
return $b->updated_at->unix() <=> $a->updated_at->unix();
}
public static function chaptersFirst(Entity $a, Entity $b): int
{
return ($b instanceof Chapter ? 1 : 0) - (($a instanceof Chapter) ? 1 : 0);
}
public static function chaptersLast(Entity $a, Entity $b): int
{
return ($a instanceof Chapter ? 1 : 0) - (($b instanceof Chapter) ? 1 : 0);
}
}

View File

@ -1,49 +0,0 @@
<?php
namespace BookStack\Sorting;
/**
* Generate a URL with multiple parameters for sorting purposes.
* Works out the logic to set the correct sorting direction
* Discards empty parameters and allows overriding.
*/
class SortUrl
{
public function __construct(
protected string $path,
protected array $data,
protected array $overrideData = []
) {
}
public function withOverrideData(array $overrideData = []): self
{
return new self($this->path, $this->data, $overrideData);
}
public function build(): string
{
$queryStringSections = [];
$queryData = array_merge($this->data, $this->overrideData);
// Change sorting direction if already sorted on current attribute
if (isset($this->overrideData['sort']) && $this->overrideData['sort'] === $this->data['sort']) {
$queryData['order'] = ($this->data['order'] === 'asc') ? 'desc' : 'asc';
} elseif (isset($this->overrideData['sort'])) {
$queryData['order'] = 'asc';
}
foreach ($queryData as $name => $value) {
$trimmedVal = trim($value);
if ($trimmedVal !== '') {
$queryStringSections[] = urlencode($name) . '=' . urlencode($trimmedVal);
}
}
if (count($queryStringSections) === 0) {
return url($this->path);
}
return url($this->path . '?' . implode('&', $queryStringSections));
}
}

Some files were not shown because too many files have changed in this diff Show More