Compare commits

..

434 Commits

Author SHA1 Message Date
Dan Brown
fef61f054a Updated version and assets for release v25.11.1 2025-11-11 12:17:44 +00:00
Dan Brown
8082c95ec3 Merge branch 'development' into release 2025-11-11 12:15:16 +00:00
Dan Brown
fcabf478de Updated version and assets for release v25.11 2025-11-09 12:52:34 +00:00
Dan Brown
8de2c28497 Merge branch 'development' into release 2025-11-09 12:51:26 +00:00
Dan Brown
0838d5ea16 Updated version and assets for release v25.07.3 2025-10-05 15:38:50 +01:00
Dan Brown
449ac40114 Merge branch 'v25-07' into release 2025-10-05 15:37:20 +01:00
Dan Brown
3131050acd Updated version and assets for release v25.07.2 2025-08-28 17:41:48 +01:00
Dan Brown
c0d2874892 Merge branch 'development' into release 2025-08-28 17:39:31 +01:00
Dan Brown
5940a91809 Updated version and assets for release v25.07.1 2025-08-11 14:43:51 +01:00
Dan Brown
9a4651badb Merge branch 'development' into release 2025-08-11 14:43:13 +01:00
Dan Brown
92d15d9cf2 Updated version and assets for release v25.07 2025-07-30 09:46:37 +01:00
Dan Brown
b06147fef7 Merge branch 'development' into release 2025-07-30 09:45:40 +01:00
Dan Brown
841350a937 Updated version and assets for release v25.05.2 2025-07-07 15:01:24 +01:00
Dan Brown
12183bac07 Merge branch 'development' into release 2025-07-07 15:00:35 +01:00
Dan Brown
e65b4b63a2 Updated version and assets for release v25.05.1 2025-06-17 15:30:40 +01:00
Dan Brown
7cac3f4780 Merge branch 'development' into release 2025-06-17 15:29:46 +01:00
Dan Brown
92cd11d105 Updated version and assets for release v25.05 2025-05-31 14:27:44 +01:00
Dan Brown
13115ace84 Merge branch 'development' into release 2025-05-31 14:26:04 +01:00
Dan Brown
73f9834e6f Updated version and assets for release v25.02.5 2025-05-17 12:16:55 +01:00
Dan Brown
3afe855156 Merge branch 'development' into release 2025-05-17 12:14:51 +01:00
Dan Brown
bfde896f0b Updated version and assets for release v25.02.4 2025-05-08 16:01:45 +01:00
Dan Brown
1cdc0a7a3d Merge branch 'development' into release 2025-05-08 15:57:02 +01:00
Dan Brown
d19b86640b Updated version and assets for release v25.02.3 2025-05-05 18:32:39 +01:00
Dan Brown
2936ba609b Merge branch 'development' into release 2025-05-05 18:20:31 +01:00
Dan Brown
573a2dd22a Updated version and assets for release v25.02.2 2025-04-02 17:32:58 +01:00
Dan Brown
b55cc803d3 Merge branch 'development' into release 2025-04-02 17:31:14 +01:00
Dan Brown
304ade418e Updated version, assets, and checksums for release v25.02.1 2025-03-16 12:47:19 +00:00
Dan Brown
997931c42f Merge branch 'development' into release 2025-03-16 12:45:08 +00:00
Dan Brown
268e353431 Updated version and assets for release v25.02 2025-02-26 14:30:52 +00:00
Dan Brown
b491b5fbca Merge branch 'development' into release 2025-02-26 14:30:17 +00:00
Dan Brown
387c786768 Updated version and assets for release v24.12.1 2025-01-04 22:22:17 +00:00
Dan Brown
2641586a6f Merge branch 'development' into release 2025-01-04 22:22:04 +00:00
Dan Brown
6d2cd20e80 Updated version and assets for release v24.12 2024-12-23 11:55:23 +00:00
Dan Brown
b0c574356a Merge branch 'development' into release 2024-12-23 11:55:02 +00:00
Dan Brown
07e45a20e5 Updated version and assets for release v24.10.3 2024-11-29 13:50:41 +00:00
Dan Brown
14056c69e6 Updated version and assets for release v24.10.2 2024-11-29 13:47:24 +00:00
Dan Brown
fb9c840c46 Merge branch 'development' into release 2024-11-29 13:47:08 +00:00
Dan Brown
5fba4a5399 Updated version and assets for release v24.10.2 2024-11-13 12:03:15 +00:00
Dan Brown
c0b377050e Merge branch 'development' into release 2024-11-13 12:02:30 +00:00
Dan Brown
f3efb6441d Updated version and assets for release v24.10.1 2024-11-08 13:53:06 +00:00
Dan Brown
0cf313a21e Merge branch 'development' into release 2024-11-08 13:52:37 +00:00
Dan Brown
26aadffb20 Updated version and assets for release v24.10 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
550 changed files with 4204 additions and 10364 deletions

View File

@@ -26,13 +26,6 @@ DB_DATABASE=database_database
DB_USERNAME=database_username DB_USERNAME=database_username
DB_PASSWORD=database_user_password DB_PASSWORD=database_user_password
# Storage system to use
# By default files are stored on the local filesystem, with images being placed in
# public web space so they can be efficiently served directly by the web-server.
# For other options with different security levels & considerations, refer to:
# https://www.bookstackapp.com/docs/admin/upload-config/
STORAGE_TYPE=local
# Mail system to use # Mail system to use
# Can be 'smtp' or 'sendmail' # Can be 'smtp' or 'sendmail'
MAIL_DRIVER=smtp MAIL_DRIVER=smtp

View File

@@ -351,25 +351,10 @@ EXPORT_PDF_COMMAND_TIMEOUT=15
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections. # Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
WKHTMLTOPDF=false WKHTMLTOPDF=false
# Allow JavaScript, and other potentiall dangerous content in page content. # Allow <script> tags in page content
# This also removes CSP-level JavaScript control.
# Note, if set to 'true' the page editor may still escape scripts. # Note, if set to 'true' the page editor may still escape scripts.
# DEPRECATED: Use 'APP_CONTENT_FILTERING' instead as detailed below. Activiting this option
# effectively sets APP_CONTENT_FILTERING='' (No filtering)
ALLOW_CONTENT_SCRIPTS=false ALLOW_CONTENT_SCRIPTS=false
# Control the behaviour of content filtering, primarily used for page content.
# This setting is a string of characters which represent different available filters:
# - j - Filter out JavaScript and unknown binary data based content
# - h - Filter out unexpected, and potentially dangerous, HTML elements
# - f - Filter out unexpected form elements
# - a - Run content through a more complex allowlist filter
# This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
# Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
# Note: The default value will always be the most-strict, so it's advised to leave this unset in your own configuration
# to ensure you are always using the full range of filters.
APP_CONTENT_FILTERING="jfha"
# Indicate if robots/crawlers should crawl your instance. # Indicate if robots/crawlers should crawl your instance.
# Can be 'true', 'false' or 'null'. # Can be 'true', 'false' or 'null'.
# The behaviour of the default 'null' option will depend on the 'app-public' admin setting. # The behaviour of the default 'null' option will depend on the 'app-public' admin setting.

View File

@@ -511,22 +511,3 @@ MrCharlesIII :: Arabic
David Olsen (dawin) :: Danish David Olsen (dawin) :: Danish
ltnzr :: French ltnzr :: French
Frank Holler (holler.frank) :: German; German Informal Frank Holler (holler.frank) :: German; German Informal
Korab Arifi (korabidev) :: Albanian
Petr Husák (petrhusak) :: Czech
Bernardo Maia (bernardo.bmaia2) :: Portuguese, Brazilian
Amr (amr3k) :: Arabic
Tahsin Ahmed (tahsinahmed2012) :: Bengali
bojan_che :: Serbian (Cyrillic)
setiawan setiawan (culture.setiawan) :: Indonesian
Donald Mac Kenzie (kiuman) :: Norwegian Bokmal
Gabriel Silver (GabrielBSilver) :: Hebrew
Tomas Darius Davainis (Tomasdd) :: Lithuanian
CriedHero :: Chinese Simplified
Henrik (henrik2105) :: Norwegian Bokmal
FoW (fofwisdom) :: Korean
serinf-lauza :: French
Diyan Nikolaev (nikolaev.diyan) :: Bulgarian
Shadluk Avan (quldosh) :: Uzbek
Marci (MartonPoto) :: Hungarian
Michał Sadurski (wheeskeey) :: Polish
JanDziaslo :: Polish

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
strategy: strategy:
matrix: matrix:
php: ['8.2', '8.3', '8.4', '8.5'] php: ['8.2', '8.3', '8.4']
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
strategy: strategy:
matrix: matrix:
php: ['8.2', '8.3', '8.4', '8.5'] php: ['8.2', '8.3', '8.4']
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

6
.gitignore vendored
View File

@@ -8,10 +8,10 @@ Homestead.yaml
.idea .idea
npm-debug.log npm-debug.log
yarn-error.log yarn-error.log
/public/dist /public/dist/*.map
/public/plugins /public/plugins
/public/css /public/css/*.map
/public/js /public/js/*.map
/public/bower /public/bower
/public/build/ /public/build/
/public/favicon.ico /public/favicon.ico

View File

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

View File

@@ -9,9 +9,11 @@ use Illuminate\Http\Request;
class OidcController extends Controller class OidcController extends Controller
{ {
public function __construct( protected OidcService $oidcService;
protected OidcService $oidcService
) { public function __construct(OidcService $oidcService)
{
$this->oidcService = $oidcService;
$this->middleware('guard:oidc'); $this->middleware('guard:oidc');
} }
@@ -28,7 +30,7 @@ class OidcController extends Controller
return redirect('/login'); return redirect('/login');
} }
session()->put('oidc_state', time() . ':' . $loginDetails['state']); session()->flash('oidc_state', $loginDetails['state']);
return redirect($loginDetails['url']); return redirect($loginDetails['url']);
} }
@@ -39,16 +41,10 @@ class OidcController extends Controller
*/ */
public function callback(Request $request) public function callback(Request $request)
{ {
$storedState = session()->pull('oidc_state');
$responseState = $request->query('state'); $responseState = $request->query('state');
$splitState = explode(':', session()->pull('oidc_state', ':'), 2);
if (count($splitState) !== 2) {
$splitState = [null, null];
}
[$storedStateTime, $storedState] = $splitState; if ($storedState !== $responseState) {
$threeMinutesAgo = time() - 3 * 60;
if (!$storedState || $storedState !== $responseState || intval($storedStateTime) < $threeMinutesAgo) {
$this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')])); $this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
return redirect('/login'); return redirect('/login');
@@ -66,7 +62,7 @@ class OidcController extends Controller
} }
/** /**
* Log the user out, then start the OIDC RP-initiated logout process. * Log the user out then start the OIDC RP-initiated logout process.
*/ */
public function logout() public function logout()
{ {

View File

@@ -14,9 +14,10 @@ use PragmaRX\Google2FA\Support\Constants;
class TotpService class TotpService
{ {
public function __construct( protected $google2fa;
protected Google2FA $google2fa
) { public function __construct(Google2FA $google2fa)
{
$this->google2fa = $google2fa; $this->google2fa = $google2fa;
// Use SHA1 as a default, Personal testing of other options in 2021 found // Use SHA1 as a default, Personal testing of other options in 2021 found
// many apps lack support for other algorithms yet still will scan // many apps lack support for other algorithms yet still will scan
@@ -34,7 +35,7 @@ class TotpService
} }
/** /**
* Generate a TOTP URL from a secret key. * Generate a TOTP URL from secret key.
*/ */
public function generateUrl(string $secret, User $user): string public function generateUrl(string $secret, User $user): string
{ {

View File

@@ -8,7 +8,6 @@ use BookStack\Permissions\PermissionApplicator;
use BookStack\Users\Models\HasCreatorAndUpdater; use BookStack\Users\Models\HasCreatorAndUpdater;
use BookStack\Users\Models\OwnableInterface; use BookStack\Users\Models\OwnableInterface;
use BookStack\Util\HtmlContentFilter; use BookStack\Util\HtmlContentFilter;
use BookStack\Util\HtmlContentFilterConfig;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -42,19 +41,7 @@ class Comment extends Model implements Loggable, OwnableInterface
*/ */
public function entity(): MorphTo public function entity(): MorphTo
{ {
// We specifically define null here to avoid the different name (commentable) return $this->morphTo('commentable');
// being used by Laravel eager loading instead of the method name, which it was doing
// in some scenarios like when deserialized when going through the queue system.
// So we instead specify the type and id column names to use.
// Related to:
// https://github.com/laravel/framework/pull/24815
// https://github.com/laravel/framework/issues/27342
// https://github.com/laravel/framework/issues/47953
// (and probably more)
// Ultimately, we could just align the method name to 'commentable' but that would be a potential
// breaking change and not really worthwhile in a patch due to the risk of creating extra problems.
return $this->morphTo(null, 'commentable_type', 'commentable_id');
} }
/** /**
@@ -83,8 +70,7 @@ class Comment extends Model implements Loggable, OwnableInterface
public function safeHtml(): string public function safeHtml(): string
{ {
$filter = new HtmlContentFilter(new HtmlContentFilterConfig()); return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
return $filter->filterString($this->html ?? '');
} }
public function jointPermissions(): HasMany public function jointPermissions(): HasMany

View File

@@ -1,20 +0,0 @@
<?php
namespace BookStack\Activity\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property string $mentionable_type
* @property int $mentionable_id
* @property int $from_user_id
* @property int $to_user_id
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class MentionHistory extends Model
{
protected $table = 'mention_history';
}

View File

@@ -20,7 +20,6 @@ abstract class BaseNotificationHandler implements NotificationHandler
{ {
$users = User::query()->whereIn('id', array_unique($userIds))->get(); $users = User::query()->whereIn('id', array_unique($userIds))->get();
/** @var User $user */
foreach ($users as $user) { foreach ($users as $user) {
// Prevent sending to the user that initiated the activity // Prevent sending to the user that initiated the activity
if ($user->id === $initiator->id) { if ($user->id === $initiator->id) {

View File

@@ -1,85 +0,0 @@
<?php
namespace BookStack\Activity\Notifications\Handlers;
use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\MentionHistory;
use BookStack\Activity\Notifications\Messages\CommentMentionNotification;
use BookStack\Activity\Tools\MentionParser;
use BookStack\Entities\Models\Page;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
class CommentMentionNotificationHandler extends BaseNotificationHandler
{
public function handle(Activity $activity, Loggable|string $detail, User $user): void
{
if (!($detail instanceof Comment) || !($detail->entity instanceof Page)) {
throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page");
}
/** @var Page $page */
$page = $detail->entity;
$parser = new MentionParser();
$mentionedUserIds = $parser->parseUserIdsFromHtml($detail->html);
$realMentionedUsers = User::whereIn('id', $mentionedUserIds)->get();
$receivingNotifications = $realMentionedUsers->filter(function (User $user) {
$prefs = new UserNotificationPreferences($user);
return $prefs->notifyOnCommentMentions();
});
$receivingNotificationsUserIds = $receivingNotifications->pluck('id')->toArray();
$userMentionsToLog = $realMentionedUsers;
// When an edit, we check our history to see if we've already notified the user about this comment before
// so that we can filter them out to avoid double notifications.
if ($activity->type === ActivityType::COMMENT_UPDATE) {
$previouslyNotifiedUserIds = $this->getPreviouslyNotifiedUserIds($detail);
$receivingNotificationsUserIds = array_values(array_diff($receivingNotificationsUserIds, $previouslyNotifiedUserIds));
$userMentionsToLog = $userMentionsToLog->filter(function (User $user) use ($previouslyNotifiedUserIds) {
return !in_array($user->id, $previouslyNotifiedUserIds);
});
}
$this->logMentions($userMentionsToLog, $detail, $user);
$this->sendNotificationToUserIds(CommentMentionNotification::class, $receivingNotificationsUserIds, $user, $detail, $page);
}
/**
* @param Collection<User> $mentionedUsers
*/
protected function logMentions(Collection $mentionedUsers, Comment $comment, User $fromUser): void
{
$mentions = [];
$now = Carbon::now();
foreach ($mentionedUsers as $mentionedUser) {
$mentions[] = [
'mentionable_type' => $comment->getMorphClass(),
'mentionable_id' => $comment->id,
'from_user_id' => $fromUser->id,
'to_user_id' => $mentionedUser->id,
'created_at' => $now,
'updated_at' => $now,
];
}
MentionHistory::query()->insert($mentions);
}
protected function getPreviouslyNotifiedUserIds(Comment $comment): array
{
return MentionHistory::query()
->where('mentionable_id', $comment->id)
->where('mentionable_type', $comment->getMorphClass())
->pluck('to_user_id')
->toArray();
}
}

View File

@@ -1,37 +0,0 @@
<?php
namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
use BookStack\Entities\Models\Page;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class CommentMentionNotification extends BaseActivityNotification
{
public function toMail(User $notifiable): MailMessage
{
/** @var Comment $comment */
$comment = $this->detail;
/** @var Page $page */
$page = $comment->entity;
$locale = $notifiable->getLocale();
$listLines = array_filter([
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
$locale->trans('notifications.detail_commenter') => $this->user->name,
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
]);
return $this->newMailMessage($locale)
->subject($locale->trans('notifications.comment_mention_subject', ['pageName' => $page->getShortName()]))
->line($locale->trans('notifications.comment_mention_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine($listLines))
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
->line($this->buildReasonFooterLine($locale));
}
}

View File

@@ -6,7 +6,6 @@ use BookStack\Activity\ActivityType;
use BookStack\Activity\Models\Activity; use BookStack\Activity\Models\Activity;
use BookStack\Activity\Models\Loggable; use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler; use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\CommentMentionNotificationHandler;
use BookStack\Activity\Notifications\Handlers\NotificationHandler; use BookStack\Activity\Notifications\Handlers\NotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler; use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler; use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
@@ -49,7 +48,5 @@ class NotificationManager
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class); $this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class); $this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class); $this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class);
$this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class);
} }
} }

View File

@@ -1,28 +0,0 @@
<?php
namespace BookStack\Activity\Tools;
use BookStack\Util\HtmlDocument;
use DOMElement;
class MentionParser
{
public function parseUserIdsFromHtml(string $html): array
{
$doc = new HtmlDocument($html);
$ids = [];
$mentionLinks = $doc->queryXPath('//a[@data-mention-user-id]');
foreach ($mentionLinks as $link) {
if ($link instanceof DOMElement) {
$id = intval($link->getAttribute('data-mention-user-id'));
if ($id > 0) {
$ids[] = $id;
}
}
}
return array_values(array_unique($ids));
}
}

View File

@@ -83,7 +83,7 @@ class HomeController extends Controller
if ($homepageOption === 'bookshelves') { if ($homepageOption === 'bookshelves') {
$shelves = $this->queries->shelves->visibleForListWithCover() $shelves = $this->queries->shelves->visibleForListWithCover()
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder()) ->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000)); ->paginate(18);
$data = array_merge($commonData, ['shelves' => $shelves]); $data = array_merge($commonData, ['shelves' => $shelves]);
return view('home.shelves', $data); return view('home.shelves', $data);
@@ -92,7 +92,7 @@ class HomeController extends Controller
if ($homepageOption === 'books') { if ($homepageOption === 'books') {
$books = $this->queries->books->visibleForListWithCover() $books = $this->queries->books->visibleForListWithCover()
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder()) ->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); ->paginate(18);
$data = array_merge($commonData, ['books' => $books]); $data = array_merge($commonData, ['books' => $books]);
return view('home.books', $data); return view('home.books', $data);

View File

@@ -3,7 +3,6 @@
namespace BookStack\App\Providers; namespace BookStack\App\Providers;
use BookStack\Access\SocialDriverManager; use BookStack\Access\SocialDriverManager;
use BookStack\Activity\Models\Comment;
use BookStack\Activity\Tools\ActivityLogger; use BookStack\Activity\Tools\ActivityLogger;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Bookshelf;
@@ -74,7 +73,6 @@ class AppServiceProvider extends ServiceProvider
'book' => Book::class, 'book' => Book::class,
'chapter' => Chapter::class, 'chapter' => Chapter::class,
'page' => Page::class, 'page' => Page::class,
'comment' => Comment::class,
]); ]);
} }
} }

View File

@@ -4,8 +4,6 @@ namespace BookStack\App\Providers;
use BookStack\Theming\ThemeEvents; use BookStack\Theming\ThemeEvents;
use BookStack\Theming\ThemeService; use BookStack\Theming\ThemeService;
use BookStack\Theming\ThemeViews;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class ThemeServiceProvider extends ServiceProvider class ThemeServiceProvider extends ServiceProvider
@@ -26,26 +24,7 @@ class ThemeServiceProvider extends ServiceProvider
{ {
// Boot up the theme system // Boot up the theme system
$themeService = $this->app->make(ThemeService::class); $themeService = $this->app->make(ThemeService::class);
$viewFactory = $this->app->make('view');
$themeViews = new ThemeViews($viewFactory->getFinder());
// Use a custom include so that we can insert theme views before/after includes.
// This is done, even if no theme is active, so that view caching does not create problems
// when switching between themes or when switching a theme on/off.
$viewFactory->share('__themeViews', $themeViews);
Blade::directive('include', function ($expression) {
return "<?php echo \$__themeViews->handleViewInclude({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>";
});
if (!$themeService->getTheme()) {
return;
}
$themeService->loadModules();
$themeService->readThemeActions(); $themeService->readThemeActions();
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app); $themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
$themeViews->registerViewPathsForTheme($themeService->getModules());
$themeService->dispatch(ThemeEvents::THEME_REGISTER_VIEWS, $themeViews);
} }
} }

View File

@@ -5,9 +5,11 @@ namespace BookStack\App;
/** /**
* Assigned to models that can have slugs. * Assigned to models that can have slugs.
* Must have the below properties. * Must have the below properties.
*
* @property string $slug
*/ */
interface SluggableInterface interface SluggableInterface
{ {
/**
* Regenerate the slug for this model.
*/
public function refreshSlug(): string;
} }

View File

@@ -81,7 +81,8 @@ function setting(?string $key = null, mixed $default = null): mixed
/** /**
* Get a path to a theme resource. * Get a path to a theme resource.
* Returns null if a theme is not configured, and therefore a full path is not available for use. * Returns null if a theme is not configured and
* therefore a full path is not available for use.
*/ */
function theme_path(string $path = ''): ?string function theme_path(string $path = ''): ?string
{ {

View File

@@ -37,15 +37,10 @@ return [
// The limit for all uploaded files, including images and attachments in MB. // The limit for all uploaded files, including images and attachments in MB.
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50), 'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
// Control the behaviour of content filtering, primarily used for page content. // Allow <script> tags to entered within page content.
// This setting is a string of characters which represent different available filters: // <script> tags are escaped by default.
// - j - Filter out JavaScript and unknown binary data based content // Even when overridden the WYSIWYG editor may still escape script content.
// - h - Filter out unexpected, and potentially dangerous, HTML elements 'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false),
// - f - Filter out unexpected form elements
// - a - Run content through a more complex allowlist filter
// This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
// Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
'content_filtering' => env('APP_CONTENT_FILTERING', env('ALLOW_CONTENT_SCRIPTS', false) === true ? '' : 'jhfa'),
// Allow server-side fetches to be performed to potentially unknown // Allow server-side fetches to be performed to potentially unknown
// and user-provided locations. Primarily used in exports when loading // and user-provided locations. Primarily used in exports when loading
@@ -53,8 +48,8 @@ return [
'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false), 'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
// Override the default behaviour for allowing crawlers to crawl the instance. // Override the default behaviour for allowing crawlers to crawl the instance.
// May be ignored if the underlying view has been overridden or modified. // May be ignored if view has be overridden or modified.
// Defaults to null in which case the 'app-public' status is used instead. // Defaults to null since, if not set, 'app-public' status used instead.
'allow_robots' => env('ALLOW_ROBOTS', null), 'allow_robots' => env('ALLOW_ROBOTS', null),
// Application Base URL, Used by laravel in development commands // Application Base URL, Used by laravel in development commands

View File

@@ -81,8 +81,7 @@ return [
'strict' => false, 'strict' => false,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([ 'options' => extension_loaded('pdo_mysql') ? array_filter([
// @phpstan-ignore class.notFound PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [], ]) : [],
], ],

View File

@@ -41,7 +41,6 @@ return [
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'), 'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'), 'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'), 'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'),
'notifications#comment-mentions' => true,
], ],
]; ];

View File

@@ -8,6 +8,12 @@
* Do not edit this file unless you're happy to maintain any changes yourself. * Do not edit this file unless you're happy to maintain any changes yourself.
*/ */
// Join up possible view locations
$viewPaths = [realpath(base_path('resources/views'))];
if ($theme = env('APP_THEME', false)) {
array_unshift($viewPaths, base_path('themes/' . $theme));
}
return [ return [
// App theme // App theme
@@ -20,7 +26,7 @@ return [
// Most templating systems load templates from disk. Here you may specify // Most templating systems load templates from disk. Here you may specify
// an array of paths that should be checked for your views. Of course // an array of paths that should be checked for your views. Of course
// the usual Laravel view path has already been registered for you. // the usual Laravel view path has already been registered for you.
'paths' => [realpath(base_path('resources/views'))], 'paths' => $viewPaths,
// Compiled View Path // Compiled View Path
// This option determines where all the compiled Blade templates will be // This option determines where all the compiled Blade templates will be

View File

@@ -1,305 +0,0 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeModule;
use BookStack\Theming\ThemeModuleException;
use BookStack\Theming\ThemeModuleManager;
use BookStack\Theming\ThemeModuleZip;
use GuzzleHttp\Psr7\Request;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class InstallModuleCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:install-module
{location : The URL or path of the module file}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Install a module to the currently configured theme';
protected array $cleanupActions = [];
/**
* Execute the console command.
*/
public function handle(): int
{
$location = $this->argument('location');
// Get the ZIP file containing the module files
$zipPath = $this->getPathToZip($location);
if (!$zipPath) {
$this->cleanup();
return 1;
}
// Validate module zip file (metadata, size, etc...) and get module instance
$zip = new ThemeModuleZip($zipPath);
$themeModule = $this->validateAndGetModuleInfoFromZip($zip);
if (!$themeModule) {
$this->cleanup();
return 1;
}
// Get the theme folder in use, attempting to create one if no active theme in use
$themeFolder = $this->getThemeFolder();
if (!$themeFolder) {
$this->cleanup();
return 1;
}
// Get the modules folder of the theme, attempting to create it if not existing,
// and create a new module manager instance.
$moduleFolder = $this->getModuleFolder($themeFolder);
if (!$moduleFolder) {
$this->cleanup();
return 1;
}
$manager = new ThemeModuleManager($moduleFolder);
// Handle existing modules with the same name
$exitingModulesWithName = $manager->getByName($themeModule->name);
$shouldContinue = $this->handleExistingModulesWithSameName($exitingModulesWithName, $manager);
if (!$shouldContinue) {
$this->cleanup();
return 1;
}
// Extract module ZIP into the theme modules folder
try {
$newModule = $manager->addFromZip($themeModule->name, $zip);
} catch (ThemeModuleException $exception) {
$this->error("ERROR: Failed to install module with error: {$exception->getMessage()}");
$this->cleanup();
return 1;
}
$this->info("Module \"{$newModule->name}\" ({$newModule->getVersion()}) successfully installed!");
$this->info("Install location: {$moduleFolder}/{$newModule->folderName}");
$this->cleanup();
return 0;
}
/**
* @param ThemeModule[] $existingModules
*/
protected function handleExistingModulesWithSameName(array $existingModules, ThemeModuleManager $manager): bool
{
if (count($existingModules) === 0) {
return true;
}
$this->warn("The following modules already exist with the same name:");
foreach ($existingModules as $folder => $module) {
$this->line("{$module->name} ({$folder}:{$module->getVersion()}) - {$module->description}");
}
$this->line('');
$choices = ['Cancel module install', 'Add alongside existing module'];
if (count($existingModules) === 1) {
$choices[] = 'Replace existing module';
}
$choice = $this->choice("What would you like to do?", $choices, 0, null, false);
if ($choice === 'Cancel module install') {
return false;
}
if ($choice === 'Replace existing module') {
$existingModuleFolder = array_key_first($existingModules);
$this->info("Replacing existing module in {$existingModuleFolder} folder");
$manager->deleteModuleFolder($existingModuleFolder);
}
return true;
}
protected function getModuleFolder(string $themeFolder): string|null
{
$path = $themeFolder . DIRECTORY_SEPARATOR . 'modules';
if (file_exists($path) && !is_dir($path)) {
$this->error("ERROR: Cannot create a modules folder, file already exists at {$path}");
return null;
}
if (!file_exists($path)) {
$created = mkdir($path, 0755, true);
if (!$created) {
$this->error("ERROR: Failed to create a modules folder at {$path}");
return null;
}
}
return $path;
}
protected function getThemeFolder(): string|null
{
$path = theme_path('');
if (!$path || !is_dir($path)) {
$shouldCreate = $this->confirm('No active theme folder found, would you like to create one?');
if (!$shouldCreate) {
return null;
}
$folder = 'custom';
while (file_exists(base_path("themes" . DIRECTORY_SEPARATOR . $folder))) {
$folder = 'custom-' . Str::random(4);
}
$path = base_path("themes/{$folder}");
$created = mkdir($path, 0755, true);
if (!$created) {
$this->error('Failed to create a theme folder to use. This may be a permissions issue. Try manually configuring an active theme');
return null;
}
$this->info("Created theme folder at {$path}");
$this->warn("You will need to set APP_THEME={$folder} in your BookStack env configuration to enable this theme!");
}
return $path;
}
protected function validateAndGetModuleInfoFromZip(ThemeModuleZip $zip): ThemeModule|null
{
if (!$zip->exists()) {
$this->error("ERROR: Cannot open ZIP file at {$zip->getPath()}");
return null;
}
if ($zip->getContentsSize() > (50 * 1024 * 1024)) {
$this->error("ERROR: Module ZIP file contents are too large. Maximum size is 50MB");
return null;
}
try {
$themeModule = $zip->getModuleInstance();
} catch (ThemeModuleException $exception) {
$this->error("ERROR: Failed to read module metadata with error: {$exception->getMessage()}");
return null;
}
return $themeModule;
}
protected function downloadModuleFile(string $location): string|null
{
$httpRequests = app()->make(HttpRequestService::class);
$client = $httpRequests->buildClient(30, ['stream' => true]);
$originalUrl = parse_url($location);
$currentLocation = $location;
$maxRedirects = 3;
$redirectCount = 0;
// Follow redirects up to 3 times for the same hostname
do {
$resp = $client->sendRequest(new Request('GET', $currentLocation));
$statusCode = $resp->getStatusCode();
if ($statusCode >= 300 && $statusCode < 400 && $redirectCount < $maxRedirects) {
$redirectLocation = $resp->getHeaderLine('Location');
if ($redirectLocation) {
$redirectUrl = parse_url($redirectLocation);
if (
($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '')
&& ($originalUrl['scheme'] ?? '') === ($redirectUrl['scheme'] ?? '')
&& ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? '')
) {
$currentLocation = $redirectLocation;
$redirectCount++;
continue;
}
}
}
break;
} while (true);
if ($resp->getStatusCode() >= 300) {
$this->error("ERROR: Failed to download module from {$location}");
$this->error("Download failed with status code {$resp->getStatusCode()}");
return null;
}
$tempFile = tempnam(sys_get_temp_dir(), 'bookstack_module_');
$fileHandle = fopen($tempFile, 'w');
$respBody = $resp->getBody();
$size = 0;
$maxSize = 50 * 1024 * 1024;
while (!$respBody->eof()) {
fwrite($fileHandle, $respBody->read(1024));
$size += 1024;
if ($size > $maxSize) {
fclose($fileHandle);
unlink($tempFile);
$this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB");
return '';
}
}
fclose($fileHandle);
$this->cleanupActions[] = function () use ($tempFile) {
unlink($tempFile);
};
return $tempFile;
}
protected function getPathToZip(string $location): string|null
{
$lowerLocation = strtolower($location);
$isRemote = str_starts_with($lowerLocation, 'http://') || str_starts_with($lowerLocation, 'https://');
if ($isRemote) {
// Warning about fetching from source
$host = parse_url($location, PHP_URL_HOST);
$this->warn("This will download a module from {$host}. Modules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.");
$trustHost = $this->confirm('Are you sure you trust this source?');
if (!$trustHost) {
return null;
}
// Check if the connection is http. If so, warn the user.
if (str_starts_with($lowerLocation, 'http://')) {
$this->warn("You are downloading a module from an insecure HTTP source.\nWe recommend only using HTTPS sources to avoid various security risks.");
if (!$this->confirm('Are you sure you want to continue without HTTPS?')) {
return null;
}
}
// Download ZIP and get its location
return $this->downloadModuleFile($location);
}
// Validate file and get full location
$zipPath = realpath($location);
if (!$zipPath || !is_file($zipPath)) {
$this->error("ERROR: Module file not found at {$location}");
return null;
}
return $zipPath;
}
protected function cleanup(): void
{
foreach ($this->cleanupActions as $action) {
$action();
}
}
}

View File

@@ -7,14 +7,11 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\BookContents;
use BookStack\Http\ApiController; use BookStack\Http\ApiController;
use BookStack\Permissions\Permission; use BookStack\Permissions\Permission;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -24,7 +21,6 @@ class BookApiController extends ApiController
protected BookRepo $bookRepo, protected BookRepo $bookRepo,
protected BookQueries $queries, protected BookQueries $queries,
protected PageQueries $pageQueries, protected PageQueries $pageQueries,
protected BookshelfQueries $shelfQueries,
) { ) {
} }
@@ -64,20 +60,13 @@ class BookApiController extends ApiController
* View the details of a single book. * View the details of a single book.
* The response data will contain a 'content' property listing the chapter and pages directly within, in * The response data will contain a 'content' property listing the chapter and pages directly within, in
* the same structure as you'd see within the BookStack interface when viewing a book. Top-level * the same structure as you'd see within the BookStack interface when viewing a book. Top-level
* contents will have a 'type' property to distinguish between pages and chapters. * contents will have a 'type' property to distinguish between pages & chapters.
*/ */
public function read(string $id) public function read(string $id)
{ {
$book = $this->queries->findVisibleByIdOrFail(intval($id)); $book = $this->queries->findVisibleByIdOrFail(intval($id));
$book = $this->forJsonDisplay($book); $book = $this->forJsonDisplay($book);
$book->load([ $book->load(['createdBy', 'updatedBy', 'ownedBy']);
'createdBy',
'updatedBy',
'ownedBy',
'shelves' => function (BelongsToMany $query) {
$query->select(['id', 'name', 'slug'])->scopes('visible');
}
]);
$contents = (new BookContents($book))->getTree(true, false)->all(); $contents = (new BookContents($book))->getTree(true, false)->all();
$contentsApiData = (new ApiEntityListFormatter($contents)) $contentsApiData = (new ApiEntityListFormatter($contents))

View File

@@ -8,7 +8,6 @@ use BookStack\Activity\Models\View;
use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries; use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\Cloner; use BookStack\Entities\Tools\Cloner;
@@ -32,7 +31,6 @@ class BookController extends Controller
protected ShelfContext $shelfContext, protected ShelfContext $shelfContext,
protected BookRepo $bookRepo, protected BookRepo $bookRepo,
protected BookQueries $queries, protected BookQueries $queries,
protected EntityQueries $entityQueries,
protected BookshelfQueries $shelfQueries, protected BookshelfQueries $shelfQueries,
protected ReferenceFetcher $referenceFetcher, protected ReferenceFetcher $referenceFetcher,
) { ) {
@@ -52,7 +50,7 @@ class BookController extends Controller
$books = $this->queries->visibleForListWithCover() $books = $this->queries->visibleForListWithCover()
->orderBy($listOptions->getSort(), $listOptions->getOrder()) ->orderBy($listOptions->getSort(), $listOptions->getOrder())
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); ->paginate(18);
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false; $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
$popular = $this->queries->popularForList()->take(4)->get(); $popular = $this->queries->popularForList()->take(4)->get();
$new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get(); $new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get();
@@ -129,16 +127,7 @@ class BookController extends Controller
*/ */
public function show(Request $request, ActivityQueries $activities, string $slug) public function show(Request $request, ActivityQueries $activities, string $slug)
{ {
try { $book = $this->queries->findVisibleBySlugOrFail($slug);
$book = $this->queries->findVisibleBySlugOrFail($slug);
} catch (NotFoundException $exception) {
$book = $this->entityQueries->findVisibleByOldSlugs('book', $slug);
if (is_null($book)) {
throw $exception;
}
return redirect($book->getUrl());
}
$bookChildren = (new BookContents($book))->getTree(true); $bookChildren = (new BookContents($book))->getTree(true);
$bookParentShelves = $book->shelves()->scopes('visible')->get(); $bookParentShelves = $book->shelves()->scopes('visible')->get();
@@ -224,14 +213,9 @@ class BookController extends Controller
{ {
$book = $this->queries->findVisibleBySlugOrFail($bookSlug); $book = $this->queries->findVisibleBySlugOrFail($bookSlug);
$this->checkOwnablePermission(Permission::BookDelete, $book); $this->checkOwnablePermission(Permission::BookDelete, $book);
$contextShelf = $this->shelfContext->getContextualShelfForBook($book);
$this->bookRepo->destroy($book); $this->bookRepo->destroy($book);
if ($contextShelf) {
return redirect($contextShelf->getUrl());
}
return redirect('/books'); return redirect('/books');
} }

View File

@@ -6,7 +6,6 @@ use BookStack\Activity\ActivityQueries;
use BookStack\Activity\Models\View; use BookStack\Activity\Models\View;
use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Queries\BookQueries;
use BookStack\Entities\Queries\BookshelfQueries; use BookStack\Entities\Queries\BookshelfQueries;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Repos\BookshelfRepo; use BookStack\Entities\Repos\BookshelfRepo;
use BookStack\Entities\Tools\ShelfContext; use BookStack\Entities\Tools\ShelfContext;
use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\ImageUploadException;
@@ -24,7 +23,6 @@ class BookshelfController extends Controller
public function __construct( public function __construct(
protected BookshelfRepo $shelfRepo, protected BookshelfRepo $shelfRepo,
protected BookshelfQueries $queries, protected BookshelfQueries $queries,
protected EntityQueries $entityQueries,
protected BookQueries $bookQueries, protected BookQueries $bookQueries,
protected ShelfContext $shelfContext, protected ShelfContext $shelfContext,
protected ReferenceFetcher $referenceFetcher, protected ReferenceFetcher $referenceFetcher,
@@ -45,7 +43,7 @@ class BookshelfController extends Controller
$shelves = $this->queries->visibleForListWithCover() $shelves = $this->queries->visibleForListWithCover()
->orderBy($listOptions->getSort(), $listOptions->getOrder()) ->orderBy($listOptions->getSort(), $listOptions->getOrder())
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000)); ->paginate(18);
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false; $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
$popular = $this->queries->popularForList()->get(); $popular = $this->queries->popularForList()->get();
$new = $this->queries->visibleForList() $new = $this->queries->visibleForList()
@@ -107,16 +105,7 @@ class BookshelfController extends Controller
*/ */
public function show(Request $request, ActivityQueries $activities, string $slug) public function show(Request $request, ActivityQueries $activities, string $slug)
{ {
try { $shelf = $this->queries->findVisibleBySlugOrFail($slug);
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
} catch (NotFoundException $exception) {
$shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug);
if (is_null($shelf)) {
throw $exception;
}
return redirect($shelf->getUrl());
}
$this->checkOwnablePermission(Permission::BookshelfView, $shelf); $this->checkOwnablePermission(Permission::BookshelfView, $shelf);
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([ $listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([

View File

@@ -77,15 +77,7 @@ class ChapterController extends Controller
*/ */
public function show(string $bookSlug, string $chapterSlug) public function show(string $bookSlug, string $chapterSlug)
{ {
try { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
} catch (NotFoundException $exception) {
$chapter = $this->entityQueries->findVisibleByOldSlugs('chapter', $chapterSlug, $bookSlug);
if (is_null($chapter)) {
throw $exception;
}
return redirect($chapter->getUrl());
}
$sidebarTree = (new BookContents($chapter->book))->getTree(); $sidebarTree = (new BookContents($chapter->book))->getTree();
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get(); $pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();

View File

@@ -17,12 +17,11 @@ use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditActivity; use BookStack\Entities\Tools\PageEditActivity;
use BookStack\Entities\Tools\PageEditorData; use BookStack\Entities\Tools\PageEditorData;
use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PermissionsException; use BookStack\Exceptions\PermissionsException;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Permissions\Permission; use BookStack\Permissions\Permission;
use BookStack\References\ReferenceFetcher; use BookStack\References\ReferenceFetcher;
use BookStack\Util\HtmlContentFilter;
use BookStack\Util\HtmlContentFilterConfig;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -141,7 +140,9 @@ class PageController extends Controller
try { try {
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
} catch (NotFoundException $e) { } catch (NotFoundException $e) {
$page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug); $revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
$page = $revision->page ?? null;
if (is_null($page)) { if (is_null($page)) {
throw $e; throw $e;
} }
@@ -175,7 +176,7 @@ class PageController extends Controller
} }
/** /**
* Get a page from an ajax request. * Get page from an ajax request.
* *
* @throws NotFoundException * @throws NotFoundException
*/ */
@@ -185,10 +186,6 @@ class PageController extends Controller
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown'])); $page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
$page->makeHidden(['book']); $page->makeHidden(['book']);
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
$filter = new HtmlContentFilter($filterConfig);
$page->html = $filter->filterString($page->html);
return response()->json($page); return response()->json($page);
} }

View File

@@ -2,6 +2,7 @@
namespace BookStack\Entities\Models; namespace BookStack\Entities\Models;
use BookStack\References\ReferenceUpdater;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
@@ -16,10 +17,34 @@ abstract class BookChild extends Entity
{ {
/** /**
* Get the book this page sits in. * Get the book this page sits in.
* @return BelongsTo<Book, $this>
*/ */
public function book(): BelongsTo public function book(): BelongsTo
{ {
return $this->belongsTo(Book::class)->withTrashed(); return $this->belongsTo(Book::class)->withTrashed();
} }
/**
* Change the book that this entity belongs to.
*/
public function changeBook(int $newBookId): self
{
$oldUrl = $this->getUrl();
$this->book_id = $newBookId;
$this->unsetRelation('book');
$this->refreshSlug();
$this->save();
if ($oldUrl !== $this->getUrl()) {
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
}
// Update all child pages if a chapter
if ($this instanceof Chapter) {
foreach ($this->pages()->withTrashed()->get() as $page) {
$page->changeBook($newBookId);
}
}
return $this;
}
} }

View File

@@ -19,7 +19,7 @@ class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInter
public float $searchFactor = 1.2; public float $searchFactor = 1.2;
protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id']; protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
protected $fillable = ['name']; protected $fillable = ['name'];
/** /**

View File

@@ -13,6 +13,7 @@ use BookStack\Activity\Models\Viewable;
use BookStack\Activity\Models\Watch; use BookStack\Activity\Models\Watch;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\App\SluggableInterface; use BookStack\App\SluggableInterface;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Permissions\JointPermissionBuilder; use BookStack\Permissions\JointPermissionBuilder;
use BookStack\Permissions\Models\EntityPermission; use BookStack\Permissions\Models\EntityPermission;
use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\Models\JointPermission;
@@ -404,6 +405,16 @@ abstract class Entity extends Model implements
app()->make(SearchIndex::class)->indexEntity(clone $this); app()->make(SearchIndex::class)->indexEntity(clone $this);
} }
/**
* {@inheritdoc}
*/
public function refreshSlug(): string
{
$this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
return $this->slug;
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
@@ -430,14 +441,6 @@ abstract class Entity extends Model implements
return $this->morphMany(Watch::class, 'watchable'); return $this->morphMany(Watch::class, 'watchable');
} }
/**
* Get the related slug history for this entity.
*/
public function slugHistory(): MorphMany
{
return $this->morphMany(SlugHistory::class, 'sluggable');
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */

View File

@@ -124,14 +124,6 @@ class Page extends BookChild
return url('/' . implode('/', $parts)); return url('/' . implode('/', $parts));
} }
/**
* Get the ID-based permalink for this page.
*/
public function getPermalink(): string
{
return url("/link/{$this->id}");
}
/** /**
* Get this page for JSON display. * Get this page for JSON display.
*/ */

View File

@@ -1,28 +0,0 @@
<?php
namespace BookStack\Entities\Models;
use BookStack\App\Model;
use BookStack\Permissions\Models\JointPermission;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property int $sluggable_id
* @property string $sluggable_type
* @property string $slug
* @property ?string $parent_slug
*/
class SlugHistory extends Model
{
use HasFactory;
protected $table = 'slug_history';
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'sluggable_id')
->whereColumn('joint_permissions.entity_type', '=', 'slug_history.sluggable_type');
}
}

View File

@@ -4,7 +4,6 @@ namespace BookStack\Entities\Queries;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\EntityTable; use BookStack\Entities\Models\EntityTable;
use BookStack\Entities\Tools\SlugHistory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Query\JoinClause; use Illuminate\Database\Query\JoinClause;
@@ -19,7 +18,6 @@ class EntityQueries
public ChapterQueries $chapters, public ChapterQueries $chapters,
public PageQueries $pages, public PageQueries $pages,
public PageRevisionQueries $revisions, public PageRevisionQueries $revisions,
protected SlugHistory $slugHistory,
) { ) {
} }
@@ -33,30 +31,9 @@ class EntityQueries
$explodedId = explode(':', $identifier); $explodedId = explode(':', $identifier);
$entityType = $explodedId[0]; $entityType = $explodedId[0];
$entityId = intval($explodedId[1]); $entityId = intval($explodedId[1]);
$queries = $this->getQueriesForType($entityType);
return $this->findVisibleById($entityType, $entityId); return $queries->findVisibleById($entityId);
}
/**
* Find an entity by its ID.
*/
public function findVisibleById(string $type, int $id): ?Entity
{
$queries = $this->getQueriesForType($type);
return $queries->findVisibleById($id);
}
/**
* Find an entity by looking up old slugs in the slug history.
*/
public function findVisibleByOldSlugs(string $type, string $slug, string $parentSlug = ''): ?Entity
{
$id = $this->slugHistory->lookupEntityIdUsingSlugs($type, $slug, $parentSlug);
if ($id === null) {
return null;
}
return $this->findVisibleById($type, $id);
} }
/** /**

View File

@@ -8,8 +8,6 @@ use BookStack\Entities\Models\HasCoverInterface;
use BookStack\Entities\Models\HasDescriptionInterface; use BookStack\Entities\Models\HasDescriptionInterface;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Entities\Tools\SlugHistory;
use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore; use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater; use BookStack\References\ReferenceUpdater;
@@ -27,8 +25,6 @@ class BaseRepo
protected ReferenceStore $referenceStore, protected ReferenceStore $referenceStore,
protected PageQueries $pageQueries, protected PageQueries $pageQueries,
protected BookSorter $bookSorter, protected BookSorter $bookSorter,
protected SlugGenerator $slugGenerator,
protected SlugHistory $slugHistory,
) { ) {
} }
@@ -47,7 +43,7 @@ class BaseRepo
'updated_by' => user()->id, 'updated_by' => user()->id,
'owned_by' => user()->id, 'owned_by' => user()->id,
]); ]);
$this->refreshSlug($entity); $entity->refreshSlug();
if ($entity instanceof HasDescriptionInterface) { if ($entity instanceof HasDescriptionInterface) {
$this->updateDescription($entity, $input); $this->updateDescription($entity, $input);
@@ -82,7 +78,7 @@ class BaseRepo
$entity->updated_by = user()->id; $entity->updated_by = user()->id;
if ($entity->isDirty('name') || empty($entity->slug)) { if ($entity->isDirty('name') || empty($entity->slug)) {
$this->refreshSlug($entity); $entity->refreshSlug();
} }
if ($entity instanceof HasDescriptionInterface) { if ($entity instanceof HasDescriptionInterface) {
@@ -159,13 +155,4 @@ class BaseRepo
$entity->descriptionInfo()->set('', $input['description']); $entity->descriptionInfo()->set('', $input['description']);
} }
} }
/**
* Refresh the slug for the given entity.
*/
public function refreshSlug(Entity $entity): void
{
$this->slugHistory->recordForEntity($entity);
$this->slugGenerator->regenerateForEntity($entity);
}
} }

View File

@@ -7,7 +7,6 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\ParentChanger;
use BookStack\Entities\Tools\TrashCan; use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\PermissionsException; use BookStack\Exceptions\PermissionsException;
@@ -22,7 +21,6 @@ class ChapterRepo
protected BaseRepo $baseRepo, protected BaseRepo $baseRepo,
protected EntityQueries $entityQueries, protected EntityQueries $entityQueries,
protected TrashCan $trashCan, protected TrashCan $trashCan,
protected ParentChanger $parentChanger,
) { ) {
} }
@@ -99,7 +97,7 @@ class ChapterRepo
} }
return (new DatabaseTransaction(function () use ($chapter, $parent) { return (new DatabaseTransaction(function () use ($chapter, $parent) {
$this->parentChanger->changeBook($chapter, $parent->id); $chapter = $chapter->changeBook($parent->id);
$chapter->rebuildPermissions(); $chapter->rebuildPermissions();
Activity::add(ActivityType::CHAPTER_MOVE, $chapter); Activity::add(ActivityType::CHAPTER_MOVE, $chapter);

View File

@@ -12,7 +12,6 @@ use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditorType; use BookStack\Entities\Tools\PageEditorType;
use BookStack\Entities\Tools\ParentChanger;
use BookStack\Entities\Tools\TrashCan; use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\PermissionsException; use BookStack\Exceptions\PermissionsException;
@@ -32,7 +31,6 @@ class PageRepo
protected ReferenceStore $referenceStore, protected ReferenceStore $referenceStore,
protected ReferenceUpdater $referenceUpdater, protected ReferenceUpdater $referenceUpdater,
protected TrashCan $trashCan, protected TrashCan $trashCan,
protected ParentChanger $parentChanger,
) { ) {
} }
@@ -244,7 +242,7 @@ class PageRepo
} }
$page->updated_by = user()->id; $page->updated_by = user()->id;
$this->baseRepo->refreshSlug($page); $page->refreshSlug();
$page->save(); $page->save();
$page->indexForSearch(); $page->indexForSearch();
$this->referenceStore->updateForEntity($page); $this->referenceStore->updateForEntity($page);
@@ -286,7 +284,7 @@ class PageRepo
return (new DatabaseTransaction(function () use ($page, $parent) { return (new DatabaseTransaction(function () use ($page, $parent) {
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null; $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id; $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
$this->parentChanger->changeBook($page, $newBookId); $page = $page->changeBook($newBookId);
$page->rebuildPermissions(); $page->rebuildPermissions();
Activity::add(ActivityType::PAGE_MOVE, $page); Activity::add(ActivityType::PAGE_MOVE, $page);

View File

@@ -13,47 +13,30 @@ use BookStack\Entities\Repos\BookRepo;
use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Repos\PageRepo; use BookStack\Entities\Repos\PageRepo;
use BookStack\Permissions\Permission; use BookStack\Permissions\Permission;
use BookStack\References\ReferenceChangeContext;
use BookStack\References\ReferenceUpdater;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService; use BookStack\Uploads\ImageService;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
class Cloner class Cloner
{ {
protected ReferenceChangeContext $referenceChangeContext;
public function __construct( public function __construct(
protected PageRepo $pageRepo, protected PageRepo $pageRepo,
protected ChapterRepo $chapterRepo, protected ChapterRepo $chapterRepo,
protected BookRepo $bookRepo, protected BookRepo $bookRepo,
protected ImageService $imageService, protected ImageService $imageService,
protected ReferenceUpdater $referenceUpdater,
) { ) {
$this->referenceChangeContext = new ReferenceChangeContext();
} }
/** /**
* Clone the given page into the given parent using the provided name. * Clone the given page into the given parent using the provided name.
*/ */
public function clonePage(Page $original, Entity $parent, string $newName): Page public function clonePage(Page $original, Entity $parent, string $newName): Page
{
$context = $this->newReferenceChangeContext();
$page = $this->createPageClone($original, $parent, $newName);
$this->referenceUpdater->changeReferencesUsingContext($context);
return $page;
}
protected function createPageClone(Page $original, Entity $parent, string $newName): Page
{ {
$copyPage = $this->pageRepo->getNewDraftPage($parent); $copyPage = $this->pageRepo->getNewDraftPage($parent);
$pageData = $this->entityToInputData($original); $pageData = $this->entityToInputData($original);
$pageData['name'] = $newName; $pageData['name'] = $newName;
$newPage = $this->pageRepo->publishDraft($copyPage, $pageData); return $this->pageRepo->publishDraft($copyPage, $pageData);
$this->referenceChangeContext->add($original, $newPage);
return $newPage;
} }
/** /**
@@ -61,14 +44,6 @@ class Cloner
* Clones all child pages. * Clones all child pages.
*/ */
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
{
$context = $this->newReferenceChangeContext();
$chapter = $this->createChapterClone($original, $parent, $newName);
$this->referenceUpdater->changeReferencesUsingContext($context);
return $chapter;
}
protected function createChapterClone(Chapter $original, Book $parent, string $newName): Chapter
{ {
$chapterDetails = $this->entityToInputData($original); $chapterDetails = $this->entityToInputData($original);
$chapterDetails['name'] = $newName; $chapterDetails['name'] = $newName;
@@ -78,12 +53,10 @@ class Cloner
if (userCan(Permission::PageCreate, $copyChapter)) { if (userCan(Permission::PageCreate, $copyChapter)) {
/** @var Page $page */ /** @var Page $page */
foreach ($original->getVisiblePages() as $page) { foreach ($original->getVisiblePages() as $page) {
$this->createPageClone($page, $copyChapter, $page->name); $this->clonePage($page, $copyChapter, $page->name);
} }
} }
$this->referenceChangeContext->add($original, $copyChapter);
return $copyChapter; return $copyChapter;
} }
@@ -92,14 +65,6 @@ class Cloner
* Clones all child chapters and pages. * Clones all child chapters and pages.
*/ */
public function cloneBook(Book $original, string $newName): Book public function cloneBook(Book $original, string $newName): Book
{
$context = $this->newReferenceChangeContext();
$book = $this->createBookClone($original, $newName);
$this->referenceUpdater->changeReferencesUsingContext($context);
return $book;
}
protected function createBookClone(Book $original, string $newName): Book
{ {
$bookDetails = $this->entityToInputData($original); $bookDetails = $this->entityToInputData($original);
$bookDetails['name'] = $newName; $bookDetails['name'] = $newName;
@@ -111,11 +76,11 @@ class Cloner
$directChildren = $original->getDirectVisibleChildren(); $directChildren = $original->getDirectVisibleChildren();
foreach ($directChildren as $child) { foreach ($directChildren as $child) {
if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) { if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) {
$this->createChapterClone($child, $copyBook, $child->name); $this->cloneChapter($child, $copyBook, $child->name);
} }
if ($child instanceof Page && !$child->draft && userCan(Permission::PageCreate, $copyBook)) { if ($child instanceof Page && !$child->draft && userCan(Permission::PageCreate, $copyBook)) {
$this->createPageClone($child, $copyBook, $child->name); $this->clonePage($child, $copyBook, $child->name);
} }
} }
@@ -127,8 +92,6 @@ class Cloner
} }
} }
$this->referenceChangeContext->add($original, $copyBook);
return $copyBook; return $copyBook;
} }
@@ -192,10 +155,4 @@ class Cloner
return $tags; return $tags;
} }
protected function newReferenceChangeContext(): ReferenceChangeContext
{
$this->referenceChangeContext = new ReferenceChangeContext();
return $this->referenceChangeContext;
}
} }

View File

@@ -6,7 +6,6 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Util\HtmlContentFilter; use BookStack\Util\HtmlContentFilter;
use BookStack\Util\HtmlContentFilterConfig;
class EntityHtmlDescription class EntityHtmlDescription
{ {
@@ -51,13 +50,7 @@ class EntityHtmlDescription
return $html; return $html;
} }
$isEmpty = empty(trim(strip_tags($html))); return HtmlContentFilter::removeScriptsFromHtmlString($html);
if ($isEmpty) {
return '<p></p>';
}
$filter = new HtmlContentFilter(new HtmlContentFilterConfig());
return $filter->filterString($html);
} }
public function getPlain(): string public function getPlain(): string

View File

@@ -17,8 +17,7 @@ class HierarchyTransformer
protected BookRepo $bookRepo, protected BookRepo $bookRepo,
protected BookshelfRepo $shelfRepo, protected BookshelfRepo $shelfRepo,
protected Cloner $cloner, protected Cloner $cloner,
protected TrashCan $trashCan, protected TrashCan $trashCan
protected ParentChanger $parentChanger,
) { ) {
} }
@@ -36,7 +35,7 @@ class HierarchyTransformer
foreach ($chapter->pages as $page) { foreach ($chapter->pages as $page) {
$page->chapter_id = 0; $page->chapter_id = 0;
$page->save(); $page->save();
$this->parentChanger->changeBook($page, $book->id); $page->changeBook($book->id);
} }
$this->trashCan->destroyEntity($chapter); $this->trashCan->destroyEntity($chapter);

View File

@@ -2,7 +2,6 @@
namespace BookStack\Entities\Tools; namespace BookStack\Entities\Tools;
use BookStack\App\AppVersion;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Tools\Markdown\MarkdownToHtml; use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
@@ -14,7 +13,6 @@ use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageService; use BookStack\Uploads\ImageService;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use BookStack\Util\HtmlContentFilter; use BookStack\Util\HtmlContentFilter;
use BookStack\Util\HtmlContentFilterConfig;
use BookStack\Util\HtmlDocument; use BookStack\Util\HtmlDocument;
use BookStack\Util\WebSafeMimeSniffer; use BookStack\Util\WebSafeMimeSniffer;
use Closure; use Closure;
@@ -319,30 +317,11 @@ class PageContent
$this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap); $this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
} }
$cacheKey = $this->getContentCacheKey($doc->getBodyInnerHtml()); if (!config('app.allow_content_scripts')) {
$cached = cache()->get($cacheKey, null); HtmlContentFilter::removeScriptsFromDocument($doc);
if ($cached !== null) {
return $cached;
} }
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering')); return $doc->getBodyInnerHtml();
$filter = new HtmlContentFilter($filterConfig);
$filtered = $filter->filterDocument($doc);
$cacheTime = 86400 * 7; // 1 week
cache()->put($cacheKey, $filtered, $cacheTime);
return $filtered;
}
protected function getContentCacheKey(string $html): string
{
$contentHash = md5($html);
$contentId = $this->page->id;
$contentTime = $this->page->updated_at?->timestamp ?? time();
$appVersion = AppVersion::get();
$filterConfig = config('app.content_filtering') ?? '';
return "page-content-cache::{$filterConfig}::{$appVersion}::{$contentId}::{$contentTime}::{$contentHash}";
} }
/** /**

View File

@@ -8,8 +8,6 @@ use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown; use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
use BookStack\Entities\Tools\Markdown\MarkdownToHtml; use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
use BookStack\Permissions\Permission; use BookStack\Permissions\Permission;
use BookStack\Util\HtmlContentFilter;
use BookStack\Util\HtmlContentFilterConfig;
class PageEditorData class PageEditorData
{ {
@@ -49,7 +47,6 @@ class PageEditorData
$isDraftRevision = false; $isDraftRevision = false;
$this->warnings = []; $this->warnings = [];
$editActivity = new PageEditActivity($page); $editActivity = new PageEditActivity($page);
$lastEditorId = $page->updated_by ?? user()->id;
if ($editActivity->hasActiveEditing()) { if ($editActivity->hasActiveEditing()) {
$this->warnings[] = $editActivity->activeEditingMessage(); $this->warnings[] = $editActivity->activeEditingMessage();
@@ -61,20 +58,11 @@ class PageEditorData
$page->forceFill($userDraft->only(['name', 'html', 'markdown'])); $page->forceFill($userDraft->only(['name', 'html', 'markdown']));
$isDraftRevision = true; $isDraftRevision = true;
$this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft); $this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
$lastEditorId = $userDraft->created_by;
} }
// Get editor type and handle changes
$editorType = $this->getEditorType($page); $editorType = $this->getEditorType($page);
$this->updateContentForEditor($page, $editorType); $this->updateContentForEditor($page, $editorType);
// Filter HTML content if required
if ($editorType->isHtmlBased() && !old('html') && $lastEditorId !== user()->id) {
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
$filter = new HtmlContentFilter($filterConfig);
$page->html = $filter->filterString($page->html);
}
return [ return [
'page' => $page, 'page' => $page,
'book' => $page->book, 'book' => $page->book,

View File

@@ -1,40 +0,0 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\References\ReferenceUpdater;
class ParentChanger
{
public function __construct(
protected SlugGenerator $slugGenerator,
protected ReferenceUpdater $referenceUpdater
) {
}
/**
* Change the parent book of a chapter or page.
*/
public function changeBook(BookChild $child, int $newBookId): void
{
$oldUrl = $child->getUrl();
$child->book_id = $newBookId;
$child->unsetRelation('book');
$this->slugGenerator->regenerateForEntity($child);
$child->save();
if ($oldUrl !== $child->getUrl()) {
$this->referenceUpdater->updateEntityReferences($child, $oldUrl);
}
// Update all child pages if a chapter
if ($child instanceof Chapter) {
foreach ($child->pages()->withTrashed()->get() as $page) {
$this->changeBook($page, $newBookId);
}
}
}
}

View File

@@ -5,14 +5,12 @@ namespace BookStack\Entities\Tools;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\App\SluggableInterface; use BookStack\App\SluggableInterface;
use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Users\Models\User;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class SlugGenerator class SlugGenerator
{ {
/** /**
* Generate a fresh slug for the given item. * Generate a fresh slug for the given entity.
* The slug will be generated so that it doesn't conflict within the same parent item. * The slug will be generated so that it doesn't conflict within the same parent item.
*/ */
public function generate(SluggableInterface&Model $model, string $slugSource): string public function generate(SluggableInterface&Model $model, string $slugSource): string
@@ -25,26 +23,6 @@ class SlugGenerator
return $slug; return $slug;
} }
/**
* Regenerate the slug for the given entity.
*/
public function regenerateForEntity(Entity $entity): string
{
$entity->slug = $this->generate($entity, $entity->name);
return $entity->slug;
}
/**
* Regenerate the slug for a user.
*/
public function regenerateForUser(User $user): string
{
$user->slug = $this->generate($user, $user->name);
return $user->slug;
}
/** /**
* Format a name as a URL slug. * Format a name as a URL slug.
*/ */

View File

@@ -1,97 +0,0 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\EntityTable;
use BookStack\Entities\Models\SlugHistory as SlugHistoryModel;
use BookStack\Permissions\PermissionApplicator;
use Illuminate\Support\Facades\DB;
class SlugHistory
{
public function __construct(
protected PermissionApplicator $permissions,
) {
}
/**
* Record the current slugs for the given entity.
*/
public function recordForEntity(Entity $entity): void
{
if (!$entity->id || !$entity->slug) {
return;
}
$parentSlug = null;
if ($entity instanceof BookChild) {
$parentSlug = $entity->book()->first()?->slug;
}
$latest = $this->getLatestEntryForEntity($entity);
if ($latest && $latest->slug === $entity->slug && $latest->parent_slug === $parentSlug) {
return;
}
$info = [
'sluggable_type' => $entity->getMorphClass(),
'sluggable_id' => $entity->id,
'slug' => $entity->slug,
'parent_slug' => $parentSlug,
];
$entry = new SlugHistoryModel();
$entry->forceFill($info);
$entry->save();
if ($entity instanceof Book) {
$this->recordForBookChildren($entity);
}
}
protected function recordForBookChildren(Book $book): void
{
$query = EntityTable::query()
->select(['type', 'id', 'slug', DB::raw("'{$book->slug}' as parent_slug"), DB::raw('now() as created_at'), DB::raw('now() as updated_at')])
->where('book_id', '=', $book->id)
->whereNotNull('book_id');
SlugHistoryModel::query()->insertUsing(
['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'],
$query
);
}
/**
* Find the latest visible entry for an entity which uses the given slug(s) in the history.
*/
public function lookupEntityIdUsingSlugs(string $type, string $slug, string $parentSlug = ''): ?int
{
$query = SlugHistoryModel::query()
->where('sluggable_type', '=', $type)
->where('slug', '=', $slug);
if ($parentSlug) {
$query->where('parent_slug', '=', $parentSlug);
}
$query = $this->permissions->restrictEntityRelationQuery($query, 'slug_history', 'sluggable_id', 'sluggable_type');
/** @var SlugHistoryModel|null $result */
$result = $query->orderBy('created_at', 'desc')->first();
return $result?->sluggable_id;
}
protected function getLatestEntryForEntity(Entity $entity): SlugHistoryModel|null
{
return SlugHistoryModel::query()
->where('sluggable_type', '=', $entity->getMorphClass())
->where('sluggable_id', '=', $entity->id)
->orderBy('created_at', 'desc')
->first();
}
}

View File

@@ -388,7 +388,7 @@ class TrashCan
/** /**
* Update entity relations to remove or update outstanding connections. * Update entity relations to remove or update outstanding connections.
*/ */
protected function destroyCommonRelations(Entity $entity): void protected function destroyCommonRelations(Entity $entity)
{ {
Activity::removeEntity($entity); Activity::removeEntity($entity);
$entity->views()->delete(); $entity->views()->delete();
@@ -402,7 +402,6 @@ class TrashCan
$entity->watches()->delete(); $entity->watches()->delete();
$entity->referencesTo()->delete(); $entity->referencesTo()->delete();
$entity->referencesFrom()->delete(); $entity->referencesFrom()->delete();
$entity->slugHistory()->delete();
if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) { if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
$imageService = app()->make(ImageService::class); $imageService = app()->make(ImageService::class);

View File

@@ -58,16 +58,6 @@ class ZipExportReader
{ {
$this->open(); $this->open();
$info = $this->zip->statName('data.json');
if ($info === false) {
throw new ZipExportException(trans('errors.import_zip_cant_decode_data'));
}
$maxSize = max(intval(config()->get('app.upload_limit')), 1) * 1000000;
if ($info['size'] > $maxSize) {
throw new ZipExportException(trans('errors.import_zip_data_too_large'));
}
// Validate json data exists, including metadata // Validate json data exists, including metadata
$jsonData = $this->zip->getFromName('data.json') ?: ''; $jsonData = $this->zip->getFromName('data.json') ?: '';
$importData = json_decode($jsonData, true); $importData = json_decode($jsonData, true);
@@ -83,17 +73,6 @@ class ZipExportReader
return $this->zip->statName("files/{$fileName}") !== false; return $this->zip->statName("files/{$fileName}") !== false;
} }
public function fileWithinSizeLimit(string $fileName): bool
{
$fileInfo = $this->zip->statName("files/{$fileName}");
if ($fileInfo === false) {
return false;
}
$maxSize = max(intval(config()->get('app.upload_limit')), 1) * 1000000;
return $fileInfo['size'] <= $maxSize;
}
/** /**
* @return false|resource * @return false|resource
*/ */

View File

@@ -15,7 +15,6 @@ use BookStack\Exports\ZipExports\Models\ZipExportPage;
use BookStack\Permissions\Permission; use BookStack\Permissions\Permission;
use BookStack\Uploads\Attachment; use BookStack\Uploads\Attachment;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService;
class ZipExportReferences class ZipExportReferences
{ {
@@ -34,7 +33,6 @@ class ZipExportReferences
public function __construct( public function __construct(
protected ZipReferenceParser $parser, protected ZipReferenceParser $parser,
protected ImageService $imageService,
) { ) {
} }
@@ -135,17 +133,10 @@ class ZipExportReferences
return "[[bsexport:image:{$model->id}]]"; return "[[bsexport:image:{$model->id}]]";
} }
// Get the page which we'll reference this image upon // Find and include images if in visibility
$page = $model->getPage(); $page = $model->getPage();
$pageExportModel = null; $pageExportModel = $this->pages[$page->id] ?? ($exportModel instanceof ZipExportPage ? $exportModel : null);
if ($page && isset($this->pages[$page->id])) { if (isset($this->images[$model->id]) || ($page && $pageExportModel && userCan(Permission::PageView, $page))) {
$pageExportModel = $this->pages[$page->id];
} elseif ($exportModel instanceof ZipExportPage) {
$pageExportModel = $exportModel;
}
// Add the image to the export if it's accessible or just return the existing reference if already added
if (isset($this->images[$model->id]) || ($pageExportModel && $this->imageService->imageAccessible($model))) {
if (!isset($this->images[$model->id])) { if (!isset($this->images[$model->id])) {
$exportImage = ZipExportImage::fromModel($model, $files); $exportImage = ZipExportImage::fromModel($model, $files);
$this->images[$model->id] = $exportImage; $this->images[$model->id] = $exportImage;
@@ -153,7 +144,6 @@ class ZipExportReferences
} }
return "[[bsexport:image:{$model->id}]]"; return "[[bsexport:image:{$model->id}]]";
} }
return null; return null;
} }

View File

@@ -13,6 +13,7 @@ class ZipFileReferenceRule implements ValidationRule
) { ) {
} }
/** /**
* @inheritDoc * @inheritDoc
*/ */
@@ -22,13 +23,6 @@ class ZipFileReferenceRule implements ValidationRule
$fail('validation.zip_file')->translate(); $fail('validation.zip_file')->translate();
} }
if (!$this->context->zipReader->fileWithinSizeLimit($value)) {
$fail('validation.zip_file_size')->translate([
'attribute' => $value,
'size' => config('app.upload_limit'),
]);
}
if (!empty($this->acceptedMimes)) { if (!empty($this->acceptedMimes)) {
$fileMime = $this->context->zipReader->sniffFileMime($value); $fileMime = $this->context->zipReader->sniffFileMime($value);
if (!in_array($fileMime, $this->acceptedMimes)) { if (!in_array($fileMime, $this->acceptedMimes)) {

View File

@@ -265,12 +265,6 @@ class ZipImportRunner
protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile
{ {
if (!$reader->fileWithinSizeLimit($fileName)) {
throw new ZipImportException([
"File $fileName exceeds app upload limit."
]);
}
$tempPath = tempnam(sys_get_temp_dir(), 'bszipextract'); $tempPath = tempnam(sys_get_temp_dir(), 'bszipextract');
$fileStream = $reader->streamFile($fileName); $fileStream = $reader->streamFile($fileName);
$tempStream = fopen($tempPath, 'wb'); $tempStream = fopen($tempPath, 'wb');

View File

@@ -17,7 +17,7 @@ class ApiAuthenticate
public function handle(Request $request, Closure $next) public function handle(Request $request, Closure $next)
{ {
// Validate the token and it's users API access // Validate the token and it's users API access
$this->ensureAuthorizedBySessionOrToken($request); $this->ensureAuthorizedBySessionOrToken();
return $next($request); return $next($request);
} }
@@ -28,28 +28,22 @@ class ApiAuthenticate
* *
* @throws ApiAuthException * @throws ApiAuthException
*/ */
protected function ensureAuthorizedBySessionOrToken(Request $request): void protected function ensureAuthorizedBySessionOrToken(): void
{ {
// Use the active user session already exists. // Return if the user is already found to be signed in via session-based auth.
// This is to make it easy to explore API endpoints via the UI. // This is to make it easy to browser the API via browser after just logging into the system.
if (session()->isStarted()) { if (!user()->isGuest() || session()->isStarted()) {
// Ensure the user has API access permission
if (!$this->sessionUserHasApiAccess()) { if (!$this->sessionUserHasApiAccess()) {
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403); throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
} }
// Only allow GET requests for cookie-based API usage
if ($request->method() !== 'GET') {
throw new ApiAuthException(trans('errors.api_cookie_auth_only_get'), 403);
}
return; return;
} }
// Set our api guard to be the default for this request lifecycle. // Set our api guard to be the default for this request lifecycle.
auth()->shouldUse('api'); auth()->shouldUse('api');
// Validate the token and its users API access // Validate the token and it's users API access
auth()->authenticate(); auth()->authenticate();
} }

View File

@@ -14,10 +14,7 @@ use Illuminate\Session\Middleware\StartSession as Middleware;
class StartSessionExtended extends Middleware class StartSessionExtended extends Middleware
{ {
protected static array $pathPrefixesExcludedFromHistory = [ protected static array $pathPrefixesExcludedFromHistory = [
'uploads/images/', 'uploads/images/'
'dist/',
'manifest.json',
'opensearch.xml',
]; ];
/** /**

View File

@@ -1,45 +0,0 @@
<?php
namespace BookStack\References;
use BookStack\Entities\Models\Entity;
class ReferenceChangeContext
{
/**
* Entity pairs where the first is the old entity and the second is the new entity.
* @var array<array{0: Entity, 1: Entity}>
*/
protected array $changes = [];
public function add(Entity $oldEntity, Entity $newEntity): void
{
$this->changes[] = [$oldEntity, $newEntity];
}
/**
* Get all the new entities from the changes.
*/
public function getNewEntities(): array
{
return array_column($this->changes, 1);
}
/**
* Get all the old entities from the changes.
*/
public function getOldEntities(): array
{
return array_column($this->changes, 0);
}
public function getNewForOld(Entity $oldEntity): ?Entity
{
foreach ($this->changes as [$old, $new]) {
if ($old->id === $oldEntity->id && $old->type === $oldEntity->type) {
return $new;
}
}
return null;
}
}

View File

@@ -5,6 +5,7 @@ namespace BookStack\References;
use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\HasDescriptionInterface; use BookStack\Entities\Models\HasDescriptionInterface;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\EntityContainerData;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\RevisionRepo; use BookStack\Entities\Repos\RevisionRepo;
use BookStack\Util\HtmlDocument; use BookStack\Util\HtmlDocument;
@@ -29,47 +30,6 @@ class ReferenceUpdater
} }
} }
/**
* Change existing references for a range of entities using the given context.
*/
public function changeReferencesUsingContext(ReferenceChangeContext $context): void
{
$bindings = [];
foreach ($context->getOldEntities() as $old) {
$bindings[] = $old->getMorphClass();
$bindings[] = $old->id;
}
// No targets to update within the context, so no need to continue.
if (count($bindings) < 2) {
return;
}
$toReferenceQuery = '(to_type, to_id) IN (' . rtrim(str_repeat('(?,?),', count($bindings) / 2), ',') . ')';
// Cycle each new entity in the context
foreach ($context->getNewEntities() as $new) {
// For each, get all references from it which lead to other items within the context of the change
$newReferencesInContext = $new->referencesFrom()->whereRaw($toReferenceQuery, $bindings)->get();
// For each reference, update the URL and the reference entry
foreach ($newReferencesInContext as $reference) {
$oldToEntity = $reference->to;
$newToEntity = $context->getNewForOld($oldToEntity);
if ($newToEntity === null) {
continue;
}
$this->updateReferencesWithinEntity($new, $oldToEntity->getUrl(), $newToEntity->getUrl());
if ($newToEntity instanceof Page && $oldToEntity instanceof Page) {
$this->updateReferencesWithinEntity($new, $oldToEntity->getPermalink(), $newToEntity->getPermalink());
}
$reference->to_id = $newToEntity->id;
$reference->to_type = $newToEntity->getMorphClass();
$reference->save();
}
}
}
/** /**
* @return Reference[] * @return Reference[]
*/ */

View File

@@ -25,12 +25,11 @@ class SearchController extends Controller
$searchOpts = SearchOptions::fromRequest($request); $searchOpts = SearchOptions::fromRequest($request);
$fullSearchString = $searchOpts->toString(); $fullSearchString = $searchOpts->toString();
$page = intval($request->get('page', '0')) ?: 1; $page = intval($request->get('page', '0')) ?: 1;
$count = setting()->getInteger('lists-page-count-search', 18, 1, 1000);
$results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, $count); $results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
$formatter->format($results['results']->all(), $searchOpts); $formatter->format($results['results']->all(), $searchOpts);
$paginator = new LengthAwarePaginator($results['results'], $results['total'], $count, $page); $paginator = new LengthAwarePaginator($results['results'], $results['total'], 20, $page);
$paginator->setPath(url('/search')); $paginator->setPath('/search');
$paginator->appends($request->except('page')); $paginator->appends($request->except('page'));
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString])); $this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
@@ -78,9 +77,8 @@ class SearchController extends Controller
// Search for entities otherwise show most popular // Search for entities otherwise show most popular
if ($searchTerm !== false) { if ($searchTerm !== false) {
$options = SearchOptions::fromString($searchTerm); $searchTerm .= ' {type:' . implode('|', $entityTypes) . '}';
$options->setFilter('type', implode('|', $entityTypes)); $entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20)['results'];
$entities = $this->searchRunner->searchEntities($options, 'all', 1, 20)['results'];
} else { } else {
$entities = $queryPopular->run(20, 0, $entityTypes); $entities = $queryPopular->run(20, 0, $entityTypes);
} }

View File

@@ -126,7 +126,7 @@ class SearchIndex
$termMap = $this->textToTermCountMap($text); $termMap = $this->textToTermCountMap($text);
foreach ($termMap as $term => $count) { foreach ($termMap as $term => $count) {
$termMap[$term] = intval($count * $scoreAdjustment); $termMap[$term] = floor($count * $scoreAdjustment);
} }
return $termMap; return $termMap;

View File

@@ -82,12 +82,4 @@ class SearchOptionSet
$values = array_values(array_filter($this->options, fn (SearchOption $option) => !$option->negated)); $values = array_values(array_filter($this->options, fn (SearchOption $option) => !$option->negated));
return new self($values); return new self($values);
} }
/**
* @return self<T>
*/
public function limit(int $limit): self
{
return new self(array_slice(array_values($this->options), 0, $limit));
}
} }

View File

@@ -35,7 +35,6 @@ class SearchOptions
{ {
$instance = new self(); $instance = new self();
$instance->addOptionsFromString($search); $instance->addOptionsFromString($search);
$instance->limitOptions();
return $instance; return $instance;
} }
@@ -88,8 +87,6 @@ class SearchOptions
$instance->filters = $instance->filters->merge($extras->filters); $instance->filters = $instance->filters->merge($extras->filters);
} }
$instance->limitOptions();
return $instance; return $instance;
} }
@@ -150,25 +147,6 @@ class SearchOptions
$this->filters = $this->filters->merge(new SearchOptionSet($terms['filters'])); $this->filters = $this->filters->merge(new SearchOptionSet($terms['filters']));
} }
/**
* Limit the amount of search options to reasonable levels.
* Provides higher limits to logged-in users since that signals a slightly
* higher level of trust.
*/
protected function limitOptions(): void
{
$userLoggedIn = !user()->isGuest();
$searchLimit = $userLoggedIn ? 10 : 5;
$exactLimit = $userLoggedIn ? 4 : 2;
$tagLimit = $userLoggedIn ? 8 : 4;
$filterLimit = $userLoggedIn ? 10 : 5;
$this->searches = $this->searches->limit($searchLimit);
$this->exacts = $this->exacts->limit($exactLimit);
$this->tags = $this->tags->limit($tagLimit);
$this->filters = $this->filters->limit($filterLimit);
}
/** /**
* Decode backslash escaping within the input string. * Decode backslash escaping within the input string.
*/ */

View File

@@ -14,7 +14,7 @@ class AppSettingsStore
) { ) {
} }
public function storeFromUpdateRequest(Request $request, string $category): void public function storeFromUpdateRequest(Request $request, string $category)
{ {
$this->storeSimpleSettings($request); $this->storeSimpleSettings($request);
if ($category === 'customization') { if ($category === 'customization') {
@@ -76,7 +76,7 @@ class AppSettingsStore
protected function storeSimpleSettings(Request $request): void protected function storeSimpleSettings(Request $request): void
{ {
foreach ($request->all() as $name => $value) { foreach ($request->all() as $name => $value) {
if (!str_starts_with($name, 'setting-')) { if (strpos($name, 'setting-') !== 0) {
continue; continue;
} }
@@ -85,7 +85,7 @@ class AppSettingsStore
} }
} }
protected function destroyExistingSettingImage(string $settingKey): void protected function destroyExistingSettingImage(string $settingKey)
{ {
$existingVal = setting()->get($settingKey); $existingVal = setting()->get($settingKey);
if ($existingVal) { if ($existingVal) {

View File

@@ -28,21 +28,6 @@ class SettingService
return $this->formatValue($value, $default); return $this->formatValue($value, $default);
} }
/**
* Get a setting from the database as an integer.
* Returns the default value if not found or not an integer, and clamps the value to the given min/max range.
*/
public function getInteger(string $key, int $default, int $min = 0, int $max = PHP_INT_MAX): int
{
$value = $this->get($key, $default);
if (!is_numeric($value)) {
return $default;
}
$int = intval($value);
return max($min, min($max, $int));
}
/** /**
* Get a value from the session instead of the main store option. * Get a value from the session instead of the main store option.
*/ */

View File

@@ -26,14 +26,9 @@ class UserNotificationPreferences
return $this->getNotificationSetting('comment-replies'); return $this->getNotificationSetting('comment-replies');
} }
public function notifyOnCommentMentions(): bool
{
return $this->getNotificationSetting('comment-mentions');
}
public function updateFromSettingsArray(array $settings) public function updateFromSettingsArray(array $settings)
{ {
$allowList = ['own-page-changes', 'own-page-comments', 'comment-replies', 'comment-mentions']; $allowList = ['own-page-changes', 'own-page-comments', 'comment-replies'];
foreach ($settings as $setting => $status) { foreach ($settings as $setting => $status) {
if (!in_array($setting, $allowList)) { if (!in_array($setting, $allowList)) {
continue; continue;

View File

@@ -8,14 +8,12 @@ use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\ParentChanger;
use BookStack\Permissions\Permission; use BookStack\Permissions\Permission;
class BookSorter class BookSorter
{ {
public function __construct( public function __construct(
protected EntityQueries $queries, protected EntityQueries $queries,
protected ParentChanger $parentChanger,
) { ) {
} }
@@ -157,7 +155,7 @@ class BookSorter
// Action the required changes // Action the required changes
if ($bookChanged) { if ($bookChanged) {
$this->parentChanger->changeBook($model, $newBook->id); $model = $model->changeBook($newBook->id);
} }
if ($model instanceof Page && $chapterChanged) { if ($model instanceof Page && $chapterChanged) {

View File

@@ -4,16 +4,25 @@ namespace BookStack\Theming;
use BookStack\Util\CspService; use BookStack\Util\CspService;
use BookStack\Util\HtmlContentFilter; use BookStack\Util\HtmlContentFilter;
use BookStack\Util\HtmlContentFilterConfig;
use BookStack\Util\HtmlNonceApplicator; use BookStack\Util\HtmlNonceApplicator;
use Illuminate\Contracts\Cache\Repository as Cache; use Illuminate\Contracts\Cache\Repository as Cache;
class CustomHtmlHeadContentProvider class CustomHtmlHeadContentProvider
{ {
public function __construct( /**
protected CspService $cspService, * @var CspService
protected Cache $cache */
) { protected $cspService;
/**
* @var Cache
*/
protected $cache;
public function __construct(CspService $cspService, Cache $cache)
{
$this->cspService = $cspService;
$this->cache = $cache;
} }
/** /**
@@ -41,8 +50,7 @@ class CustomHtmlHeadContentProvider
$hash = md5($content); $hash = md5($content);
return $this->cache->remember('custom-head-export:' . $hash, 86400, function () use ($content) { return $this->cache->remember('custom-head-export:' . $hash, 86400, function () use ($content) {
$config = new HtmlContentFilterConfig(filterOutNonContentElements: false, useAllowListFilter: false); return HtmlContentFilter::removeScriptsFromHtmlString($content);
return (new HtmlContentFilter($config))->filterString($content);
}); });
} }

View File

@@ -5,22 +5,21 @@ namespace BookStack\Theming;
use BookStack\Facades\Theme; use BookStack\Facades\Theme;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Util\FilePathNormalizer; use BookStack\Util\FilePathNormalizer;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ThemeController extends Controller class ThemeController extends Controller
{ {
/** /**
* Serve a public file from the configured theme. * Serve a public file from the configured theme.
*/ */
public function publicFile(string $theme, string $path): StreamedResponse public function publicFile(string $theme, string $path)
{ {
$cleanPath = FilePathNormalizer::normalize($path); $cleanPath = FilePathNormalizer::normalize($path);
if ($theme !== Theme::getTheme() || !$cleanPath) { if ($theme !== Theme::getTheme() || !$cleanPath) {
abort(404); abort(404);
} }
$filePath = Theme::findFirstFile("public/{$cleanPath}"); $filePath = theme_path("public/{$cleanPath}");
if (!$filePath) { if (!file_exists($filePath)) {
abort(404); abort(404);
} }

View File

@@ -134,16 +134,6 @@ class ThemeEvents
*/ */
const ROUTES_REGISTER_WEB_AUTH = 'routes_register_web_auth'; const ROUTES_REGISTER_WEB_AUTH = 'routes_register_web_auth';
/**
* Theme register views event.
* Called by the theme system when a theme is active, so that custom view templates can be registered
* to be rendered in addition to existing app views.
*
* @param \BookStack\Theming\ThemeViews $themeViews
*/
const THEME_REGISTER_VIEWS = 'theme_register_views';
/** /**
* Web before middleware action. * Web before middleware action.
* Runs before the request is handled but after all other middleware apart from those * Runs before the request is handled but after all other middleware apart from those

View File

@@ -1,59 +0,0 @@
<?php
namespace BookStack\Theming;
readonly class ThemeModule
{
public function __construct(
public string $name,
public string $description,
public string $version,
public string $folderName,
) {
}
/**
* Create a ThemeModule instance from JSON data.
*
* @throws ThemeModuleException
*/
public static function fromJson(array $data, string $folderName): self
{
if (empty($data['name']) || !is_string($data['name'])) {
throw new ThemeModuleException("Module in folder \"{$folderName}\" is missing a valid 'name' property");
}
if (!isset($data['description']) || !is_string($data['description'])) {
throw new ThemeModuleException("Module in folder \"{$folderName}\" is missing a valid 'description' property");
}
if (!isset($data['version']) || !is_string($data['version'])) {
throw new ThemeModuleException("Module in folder \"{$folderName}\" is missing a valid 'version' property");
}
if (!preg_match('/^v?\d+\.\d+\.\d+(-.*)?$/', $data['version'])) {
throw new ThemeModuleException("Module in folder \"{$folderName}\" has an invalid 'version' format. Expected semantic version format like '1.0.0' or 'v1.0.0'");
}
return new self(
name: $data['name'],
description: $data['description'],
version: $data['version'],
folderName: $folderName,
);
}
/**
* Get a path for a file within this module.
*/
public function path($path = ''): string
{
$component = trim($path, '/');
return theme_path("modules/{$this->folderName}/{$component}");
}
public function getVersion(): string
{
return str_starts_with($this->version, 'v') ? $this->version : 'v' . $this->version;
}
}

View File

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

View File

@@ -1,133 +0,0 @@
<?php
namespace BookStack\Theming;
use Illuminate\Support\Str;
class ThemeModuleManager
{
/** @var array<string, ThemeModule>|null */
protected array|null $loadedModules = null;
public function __construct(
protected string $modulesFolderPath
) {
}
/**
* @return array<string, ThemeModule>
*/
public function getByName(string $name): array
{
return array_filter($this->load(), fn(ThemeModule $module) => $module->name === $name);
}
public function deleteModuleFolder(string $moduleFolderName): void
{
$modules = $this->load();
$module = $modules[$moduleFolderName] ?? null;
if (!$module) {
return;
}
$moduleFolderPath = $module->path('');
if (!file_exists($moduleFolderPath)) {
return;
}
$this->deleteDirectoryRecursively($moduleFolderPath);
unset($this->loadedModules[$moduleFolderName]);
}
/**
* @throws ThemeModuleException
*/
public function addFromZip(string $name, ThemeModuleZip $zip): ThemeModule
{
$baseFolderName = Str::limit(Str::slug($name), 20);
$folderName = $baseFolderName;
while (!$baseFolderName || file_exists($this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName)) {
$folderName = ($baseFolderName ?: 'mod') . '-' . Str::random(4);
}
$folderPath = $this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName;
$zip->extractTo($folderPath);
$module = $this->loadFromFolder($folderName);
if (!$module) {
throw new ThemeModuleException("Failed to load module from zip file after extraction");
}
return $module;
}
protected function deleteDirectoryRecursively(string $path): void
{
$items = array_diff(scandir($path), ['.', '..']);
foreach ($items as $item) {
$itemPath = $path . DIRECTORY_SEPARATOR . $item;
if (is_dir($itemPath)) {
$this->deleteDirectoryRecursively($itemPath);
} else {
$deleted = unlink($itemPath);
if (!$deleted) {
throw new ThemeModuleException("Failed to delete file at \"{$itemPath}\"");
}
}
}
rmdir($path);
}
public function load(): array
{
if ($this->loadedModules !== null) {
return $this->loadedModules;
}
if (!is_dir($this->modulesFolderPath)) {
return [];
}
$subFolders = array_filter(scandir($this->modulesFolderPath), function ($item) {
return $item !== '.' && $item !== '..' && is_dir($this->modulesFolderPath . DIRECTORY_SEPARATOR . $item);
});
$modules = [];
foreach ($subFolders as $folderName) {
$module = $this->loadFromFolder($folderName);
if ($module) {
$modules[$folderName] = $module;
}
}
$this->loadedModules = $modules;
return $modules;
}
protected function loadFromFolder(string $folderName): ThemeModule|null
{
$moduleJsonFile = $this->modulesFolderPath . DIRECTORY_SEPARATOR . $folderName . DIRECTORY_SEPARATOR . 'bookstack-module.json';
if (!file_exists($moduleJsonFile)) {
return null;
}
try {
$jsonContent = file_get_contents($moduleJsonFile);
$jsonData = json_decode($jsonContent, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new ThemeModuleException("Invalid JSON in module file at \"{$moduleJsonFile}\": " . json_last_error_msg());
}
$module = ThemeModule::fromJson($jsonData, $folderName);
} catch (ThemeModuleException $exception) {
throw $exception;
} catch (\Exception $exception) {
throw new ThemeModuleException("Failed loading module from \"{$moduleJsonFile}\" with error: {$exception->getMessage()}");
}
return $module;
}
}

View File

@@ -1,98 +0,0 @@
<?php
namespace BookStack\Theming;
use ZipArchive;
readonly class ThemeModuleZip
{
public function __construct(
protected string $path
) {
}
public function extractTo(string $destinationPath): void
{
$zip = new ZipArchive();
$zip->open($this->path);
$zip->extractTo($destinationPath);
$zip->close();
}
/**
* Read the module's JSON metadata to read it into a ThemeModule instance.
* @throws ThemeModuleException
*/
public function getModuleInstance(): ThemeModule
{
$zip = new ZipArchive();
$open = $zip->open($this->path);
if ($open !== true) {
throw new ThemeModuleException("Unable to open zip file at {$this->path}");
}
$moduleJsonText = $zip->getFromName('bookstack-module.json');
$zip->close();
if ($moduleJsonText === false) {
throw new ThemeModuleException("bookstack-module.json not found within module ZIP at {$this->path}");
}
$moduleJson = json_decode($moduleJsonText, true);
if ($moduleJson === null) {
throw new ThemeModuleException("Could not read JSON from bookstack-module.json within module ZIP at {$this->path}");
}
return ThemeModule::fromJson($moduleJson, '_temp');
}
/**
* Get the path to the zip file.
*/
public function getPath(): string
{
return $this->path;
}
/**
* Check if the zip file exists and that it appears to be a valid zip file.
*/
public function exists(): bool
{
if (!file_exists($this->path)) {
return false;
}
$zip = new ZipArchive();
$open = $zip->open($this->path, ZipArchive::RDONLY);
if ($open === true) {
$zip->close();
return true;
}
return false;
}
/**
* Get the total size of the zip file contents when uncompressed.
*/
public function getContentsSize(): int
{
$zip = new ZipArchive();
if ($zip->open($this->path) !== true) {
return 0;
}
$totalSize = 0;
for ($i = 0; $i < $zip->numFiles; $i++) {
$stat = $zip->statIndex($i);
if ($stat !== false) {
$totalSize += $stat['size'];
}
}
$zip->close();
return $totalSize;
}
}

View File

@@ -6,7 +6,6 @@ use BookStack\Access\SocialDriverManager;
use BookStack\Exceptions\ThemeException; use BookStack\Exceptions\ThemeException;
use Illuminate\Console\Application; use Illuminate\Console\Application;
use Illuminate\Console\Application as Artisan; use Illuminate\Console\Application as Artisan;
use Illuminate\View\FileViewFinder;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
class ThemeService class ThemeService
@@ -16,11 +15,6 @@ class ThemeService
*/ */
protected array $listeners = []; protected array $listeners = [];
/**
* @var array<string, ThemeModule>
*/
protected array $modules = [];
/** /**
* Get the currently configured theme. * Get the currently configured theme.
* Returns an empty string if not configured. * Returns an empty string if not configured.
@@ -82,71 +76,20 @@ class ThemeService
} }
/** /**
* Read any actions from the 'functions.php' file of the active theme or its modules. * Read any actions from the set theme path if the 'functions.php' file exists.
*/ */
public function readThemeActions(): void public function readThemeActions(): void
{ {
$moduleFunctionFiles = array_map(function (ThemeModule $module): string { $themeActionsFile = theme_path('functions.php');
return $module->path('functions.php'); if ($themeActionsFile && file_exists($themeActionsFile)) {
}, $this->modules);
$allFunctionFiles = array_merge(array_values($moduleFunctionFiles), [theme_path('functions.php')]);
$filteredFunctionFiles = array_filter($allFunctionFiles, function (string $file): bool {
return $file && file_exists($file);
});
foreach ($filteredFunctionFiles as $functionFile) {
try { try {
require $functionFile; require $themeActionsFile;
} catch (\Error $exception) { } catch (\Error $exception) {
throw new ThemeException("Failed loading theme functions file at \"{$functionFile}\" with error: {$exception->getMessage()}"); throw new ThemeException("Failed loading theme functions file at \"{$themeActionsFile}\" with error: {$exception->getMessage()}");
} }
} }
} }
/**
* Read the modules folder and load in any valid theme modules.
* @throws ThemeModuleException
*/
public function loadModules(): void
{
$modulesFolder = theme_path('modules');
if (!$modulesFolder) {
return;
}
$this->modules = (new ThemeModuleManager($modulesFolder))->load();
}
/**
* Get all loaded theme modules.
* @return array<string, ThemeModule>
*/
public function getModules(): array
{
return $this->modules;
}
/**
* Look for a specific file within the theme or its modules.
* Returns the first file found or null if not found.
*/
public function findFirstFile(string $path): ?string
{
$themePath = theme_path($path);
if (file_exists($themePath)) {
return $themePath;
}
foreach ($this->modules as $module) {
$customizedFile = $module->path($path);
if (file_exists($customizedFile)) {
return $customizedFile;
}
}
return null;
}
/** /**
* @see SocialDriverManager::addSocialDriver * @see SocialDriverManager::addSocialDriver
*/ */

View File

@@ -1,115 +0,0 @@
<?php
namespace BookStack\Theming;
use BookStack\Exceptions\ThemeException;
use Illuminate\View\FileViewFinder;
class ThemeViews
{
/**
* @var array<string, array<string, int>>
*/
protected array $beforeViews = [];
/**
* @var array<string, array<string, int>>
*/
protected array $afterViews = [];
public function __construct(
protected FileViewFinder $finder
) {
}
/**
* Register any extra paths for where we may expect views to be located
* with the FileViewFinder, to make custom views available for use.
* @param ThemeModule[] $modules
*/
public function registerViewPathsForTheme(array $modules): void
{
foreach ($modules as $module) {
$moduleViewsPath = $module->path('views');
if (file_exists($moduleViewsPath) && is_dir($moduleViewsPath)) {
$this->finder->prependLocation($moduleViewsPath);
}
}
$this->finder->prependLocation(theme_path());
}
/**
* Provide the response for a blade template view include.
*/
public function handleViewInclude(string $viewPath, array $data = [], array $mergeData = []): string
{
if (!$this->hasRegisteredViews()) {
return view()->make($viewPath, $data, $mergeData)->render();
}
if (str_contains('book-tree', $viewPath)) {
dd($viewPath, $data);
}
$viewsContent = [
...$this->renderViewSets($this->beforeViews[$viewPath] ?? [], $data, $mergeData),
view()->make($viewPath, $data, $mergeData)->render(),
...$this->renderViewSets($this->afterViews[$viewPath] ?? [], $data, $mergeData),
];
return implode("\n", $viewsContent);
}
/**
* Register a custom view to be rendered before the given target view is included in the template system.
*/
public function renderBefore(string $targetView, string $localView, int $priority = 50): void
{
$this->registerAdjacentView($this->beforeViews, $targetView, $localView, $priority);
}
/**
* Register a custom view to be rendered after the given target view is included in the template system.
*/
public function renderAfter(string $targetView, string $localView, int $priority = 50): void
{
$this->registerAdjacentView($this->afterViews, $targetView, $localView, $priority);
}
public function hasRegisteredViews(): bool
{
return !empty($this->beforeViews) || !empty($this->afterViews);
}
protected function registerAdjacentView(array &$location, string $targetView, string $localView, int $priority = 50): void
{
try {
$viewPath = $this->finder->find($localView);
} catch (\InvalidArgumentException $exception) {
throw new ThemeException("Expected registered view file with name \"{$localView}\" could not be found.");
}
if (!isset($location[$targetView])) {
$location[$targetView] = [];
}
$location[$targetView][$viewPath] = $priority;
}
/**
* @param array<string, int> $viewSet
* @return string[]
*/
protected function renderViewSets(array $viewSet, array $data, array $mergeData): array
{
$paths = array_keys($viewSet);
usort($paths, function (string $a, string $b) use ($viewSet) {
return $viewSet[$a] <=> $viewSet[$b];
});
return array_map(function (string $viewPath) use ($data, $mergeData) {
return view()->file($viewPath, $data, $mergeData)->render();
}, $paths);
}
}

View File

@@ -2,7 +2,6 @@
namespace BookStack\Translation; namespace BookStack\Translation;
use BookStack\Facades\Theme;
use Illuminate\Translation\FileLoader as BaseLoader; use Illuminate\Translation\FileLoader as BaseLoader;
class FileLoader extends BaseLoader class FileLoader extends BaseLoader
@@ -13,6 +12,11 @@ class FileLoader extends BaseLoader
* Extends Laravel's translation FileLoader to look in multiple directories * Extends Laravel's translation FileLoader to look in multiple directories
* so that we can load in translation overrides from the theme file if wanted. * so that we can load in translation overrides from the theme file if wanted.
* *
* Note: As of using Laravel 10, this may now be redundant since Laravel's
* file loader supports multiple paths. This needs further testing though
* to confirm if Laravel works how we expect, since we specifically need
* the theme folder to be able to partially override core lang files.
*
* @param string $locale * @param string $locale
* @param string $group * @param string $group
* @param string|null $namespace * @param string|null $namespace
@@ -28,18 +32,9 @@ class FileLoader extends BaseLoader
if (is_null($namespace) || $namespace === '*') { if (is_null($namespace) || $namespace === '*') {
$themePath = theme_path('lang'); $themePath = theme_path('lang');
$themeTranslations = $themePath ? $this->loadPaths([$themePath], $locale, $group) : []; $themeTranslations = $themePath ? $this->loadPaths([$themePath], $locale, $group) : [];
$modules = Theme::getModules();
$moduleTranslations = [];
foreach ($modules as $module) {
$modulePath = $module->path('lang');
if (file_exists($modulePath)) {
$moduleTranslations = array_merge($moduleTranslations, $this->loadPaths([$modulePath], $locale, $group));
}
}
$originalTranslations = $this->loadPaths($this->paths, $locale, $group); $originalTranslations = $this->loadPaths($this->paths, $locale, $group);
return array_merge($originalTranslations, $moduleTranslations, $themeTranslations);
return array_merge($originalTranslations, $themeTranslations);
} }
return $this->loadNamespaced($locale, $group, $namespace); return $this->loadNamespaced($locale, $group, $namespace);

View File

@@ -13,14 +13,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
/** /**
* @property int $id * @property int $id
* @property string $name * @property string $name
* @property string $url * @property string $url
* @property string $path * @property string $path
* @property string $type * @property string $type
* @property int|null $uploaded_to * @property int $uploaded_to
* @property int $created_by * @property int $created_by
* @property int $updated_by * @property int $updated_by
*/ */
class Image extends Model implements OwnableInterface class Image extends Model implements OwnableInterface
{ {

View File

@@ -55,7 +55,7 @@ class ImageResizer
/** /**
* Get the thumbnail for an image. * Get the thumbnail for an image.
* If $keepRatio is true, only the width will be used. * If $keepRatio is true only the width will be used.
* Checks the cache then storage to avoid creating / accessing the filesystem on every check. * Checks the cache then storage to avoid creating / accessing the filesystem on every check.
* *
* @throws Exception * @throws Exception
@@ -84,7 +84,7 @@ class ImageResizer
return $this->storage->getPublicUrl($cachedThumbPath); return $this->storage->getPublicUrl($cachedThumbPath);
} }
// If a thumbnail has already been generated, serve that and cache path // If thumbnail has already been generated, serve that and cache path
$disk = $this->storage->getDisk($image->type); $disk = $this->storage->getDisk($image->type);
if (!$shouldCreate && $disk->exists($thumbFilePath)) { if (!$shouldCreate && $disk->exists($thumbFilePath)) {
Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME); Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
@@ -110,7 +110,7 @@ class ImageResizer
} }
/** /**
* Resize the image of given data to the specified size and return the new image data. * Resize the image of given data to the specified size, and return the new image data.
* Format will remain the same as the input format, unless specified. * Format will remain the same as the input format, unless specified.
* *
* @throws ImageUploadException * @throws ImageUploadException
@@ -125,7 +125,6 @@ class ImageResizer
try { try {
$thumb = $this->interventionFromImageData($imageData, $format); $thumb = $this->interventionFromImageData($imageData, $format);
} catch (Exception $e) { } catch (Exception $e) {
Log::error('Failed to resize image with error:' . $e->getMessage());
throw new ImageUploadException(trans('errors.cannot_create_thumbs')); throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
} }
@@ -155,21 +154,17 @@ class ImageResizer
/** /**
* Create an intervention image instance from the given image data. * Create an intervention image instance from the given image data.
* Performs some manual library usage to ensure the image is specifically loaded * Performs some manual library usage to ensure image is specifically loaded
* from given binary data instead of data being misinterpreted. * from given binary data instead of data being misinterpreted.
*/ */
protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage
{ {
if (!extension_loaded('gd')) {
throw new ImageUploadException('The PHP "gd" extension is required to resize images, but is missing.');
}
$manager = new ImageManager( $manager = new ImageManager(
new Driver(), new Driver(),
autoOrientation: false, autoOrientation: false,
); );
// Ensure GIF images are decoded natively instead of deferring to intervention GIF // Ensure gif images are decoded natively instead of deferring to intervention GIF
// handling since we don't need the added animation support. // handling since we don't need the added animation support.
$isGif = $fileType === 'gif'; $isGif = $fileType === 'gif';
$decoder = $isGif ? NativeObjectDecoder::class : BinaryImageDecoder::class; $decoder = $isGif ? NativeObjectDecoder::class : BinaryImageDecoder::class;
@@ -228,7 +223,7 @@ class ImageResizer
} }
/** /**
* Checks if the image is a GIF. Returns true if it is, else false. * Checks if the image is a gif. Returns true if it is, else false.
*/ */
protected function isGif(Image $image): bool protected function isGif(Image $image): bool
{ {
@@ -255,7 +250,7 @@ class ImageResizer
/** /**
* Check if the given avif image data represents an animated image. * Check if the given avif image data represents an animated image.
* This is based upon the answer here: https://stackoverflow.com/a/79457313 * This is based up the answer here: https://stackoverflow.com/a/79457313
*/ */
protected function isAnimatedAvifData(string &$imageData): bool protected function isAnimatedAvifData(string &$imageData): bool
{ {

View File

@@ -148,7 +148,7 @@ class ImageService
} }
/** /**
* Destroy an image along with its revisions, thumbnails, and remaining folders. * Destroy an image along with its revisions, thumbnails and remaining folders.
* *
* @throws Exception * @throws Exception
*/ */
@@ -252,7 +252,16 @@ class ImageService
{ {
$disk = $this->storage->getDisk('gallery'); $disk = $this->storage->getDisk('gallery');
return $disk->usingSecureImages() && $this->pathAccessible($imagePath); if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
return false;
}
// Check local_secure is active
return $disk->usingSecureImages()
// Check the image file exists
&& $disk->exists($imagePath)
// Check the file is likely an image file
&& str_starts_with($disk->mimeType($imagePath), 'image/');
} }
/** /**
@@ -260,51 +269,16 @@ class ImageService
*/ */
public function pathAccessible(string $imagePath): bool public function pathAccessible(string $imagePath): bool
{ {
$disk = $this->storage->getDisk('gallery');
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) { if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
return false; return false;
} }
if ($this->blockedBySecureImages()) { // Check local_secure is active
return false; return $disk->exists($imagePath)
} // Check the file is likely an image file
&& str_starts_with($disk->mimeType($imagePath), 'image/');
return $this->imageFileExists($imagePath, 'gallery');
}
/**
* Check if the given image should be accessible to the current user.
*/
public function imageAccessible(Image $image): bool
{
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImage($image)) {
return false;
}
if ($this->blockedBySecureImages()) {
return false;
}
return $this->imageFileExists($image->path, $image->type);
}
/**
* Check if the current user should be blocked from accessing images based on if secure images are enabled
* and if public access is enabled for the application.
*/
protected function blockedBySecureImages(): bool
{
$enforced = $this->storage->usingSecureImages() && !setting('app-public');
return $enforced && user()->isGuest();
}
/**
* Check if the given image path exists for the given image type and that it is likely an image file.
*/
protected function imageFileExists(string $imagePath, string $imageType): bool
{
$disk = $this->storage->getDisk($imageType);
return $disk->exists($imagePath) && str_starts_with($disk->mimeType($imagePath), 'image/');
} }
/** /**
@@ -333,11 +307,6 @@ class ImageService
return false; return false;
} }
return $this->checkUserHasAccessToRelationOfImage($image);
}
protected function checkUserHasAccessToRelationOfImage(Image $image): bool
{
$imageType = $image->type; $imageType = $image->type;
// Allow user or system (logo) images // Allow user or system (logo) images

View File

@@ -34,15 +34,6 @@ class ImageStorage
return config('filesystems.images') === 'local_secure_restricted'; return config('filesystems.images') === 'local_secure_restricted';
} }
/**
* Check if "local secure" (Fetched behind auth, either with or without permissions enforced)
* is currently active in the instance.
*/
public function usingSecureImages(): bool
{
return config('filesystems.images') === 'local_secure' || $this->usingSecureRestrictedImages();
}
/** /**
* Clean up an image file name to be both URL and storage safe. * Clean up an image file name to be both URL and storage safe.
*/ */
@@ -74,7 +65,7 @@ class ImageStorage
return 'local'; return 'local';
} }
// Rename local_secure options to get our image-specific storage driver, which // Rename local_secure options to get our image specific storage driver which
// is scoped to the relevant image directories. // is scoped to the relevant image directories.
if ($localSecureInUse) { if ($localSecureInUse) {
return 'local_secure_images'; return 'local_secure_images';

View File

@@ -5,7 +5,6 @@ namespace BookStack\Users\Controllers;
use BookStack\Http\Controller; use BookStack\Http\Controller;
use BookStack\Permissions\Permission; use BookStack\Permissions\Permission;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class UserSearchController extends Controller class UserSearchController extends Controller
@@ -35,43 +34,8 @@ class UserSearchController extends Controller
$query->where('name', 'like', '%' . $search . '%'); $query->where('name', 'like', '%' . $search . '%');
} }
/** @var Collection<User> $users */
$users = $query->get();
return view('form.user-select-list', [ return view('form.user-select-list', [
'users' => $users, 'users' => $query->get(),
]);
}
/**
* Search users in the system, with the response formatted
* for use in a list of mentions.
*/
public function forMentions(Request $request)
{
$hasPermission = !user()->isGuest() && (
userCan(Permission::CommentCreateAll)
|| userCan(Permission::CommentUpdate)
);
if (!$hasPermission) {
$this->showPermissionError();
}
$search = $request->get('search', '');
$query = User::query()
->orderBy('name', 'asc')
->take(20);
if (!empty($search)) {
$query->where('name', 'like', '%' . $search . '%');
}
/** @var Collection<User> $users */
$users = $query->get();
return view('form.user-mention-list', [
'users' => $users,
]); ]);
} }
} }

View File

@@ -11,6 +11,7 @@ use BookStack\Activity\Models\Watch;
use BookStack\Api\ApiToken; use BookStack\Api\ApiToken;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\App\SluggableInterface; use BookStack\App\SluggableInterface;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Permissions\Permission; use BookStack\Permissions\Permission;
use BookStack\Translation\LocaleDefinition; use BookStack\Translation\LocaleDefinition;
use BookStack\Translation\LocaleManager; use BookStack\Translation\LocaleManager;
@@ -357,4 +358,14 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
{ {
return "({$this->id}) {$this->name}"; return "({$this->id}) {$this->name}";
} }
/**
* {@inheritdoc}
*/
public function refreshSlug(): string
{
$this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
return $this->slug;
}
} }

View File

@@ -5,7 +5,6 @@ namespace BookStack\Users;
use BookStack\Access\UserInviteException; use BookStack\Access\UserInviteException;
use BookStack\Access\UserInviteService; use BookStack\Access\UserInviteService;
use BookStack\Activity\ActivityType; use BookStack\Activity\ActivityType;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\UserUpdateException; use BookStack\Exceptions\UserUpdateException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
@@ -22,8 +21,7 @@ class UserRepo
{ {
public function __construct( public function __construct(
protected UserAvatars $userAvatar, protected UserAvatars $userAvatar,
protected UserInviteService $inviteService, protected UserInviteService $inviteService
protected SlugGenerator $slugGenerator,
) { ) {
} }
@@ -65,7 +63,7 @@ class UserRepo
$user->email_confirmed = $emailConfirmed; $user->email_confirmed = $emailConfirmed;
$user->external_auth_id = $data['external_auth_id'] ?? ''; $user->external_auth_id = $data['external_auth_id'] ?? '';
$this->slugGenerator->regenerateForUser($user); $user->refreshSlug();
$user->save(); $user->save();
if (!empty($data['language'])) { if (!empty($data['language'])) {
@@ -111,7 +109,7 @@ class UserRepo
{ {
if (!empty($data['name'])) { if (!empty($data['name'])) {
$user->name = $data['name']; $user->name = $data['name'];
$this->slugGenerator->regenerateForUser($user); $user->refreshSlug();
} }
if (!empty($data['email']) && $manageUsersAllowed) { if (!empty($data['email']) && $manageUsersAllowed) {

View File

@@ -1,150 +0,0 @@
<?php
namespace BookStack\Util;
use BookStack\App\AppVersion;
use HTMLPurifier;
use HTMLPurifier_Config;
use HTMLPurifier_DefinitionCache_Serializer;
use HTMLPurifier_HTML5Config;
use HTMLPurifier_HTMLDefinition;
/**
* Provides a configured HTML Purifier instance.
* https://github.com/ezyang/htmlpurifier
* Also uses this to extend support to HTML5 elements:
* https://github.com/xemlock/htmlpurifier-html5
*/
class ConfiguredHtmlPurifier
{
protected HTMLPurifier $purifier;
protected static bool $cachedChecked = false;
public function __construct()
{
// This is done by the web-server at run-time, with the existing
// storage/framework/cache folder to ensure we're using a server-writable folder.
$cachePath = storage_path('framework/cache/purifier');
$this->createCacheFolderIfNeeded($cachePath);
$config = HTMLPurifier_HTML5Config::createDefault();
$this->setConfig($config, $cachePath);
$this->resetCacheIfNeeded($config);
$htmlDef = $config->getDefinition('HTML', true, true);
if ($htmlDef instanceof HTMLPurifier_HTMLDefinition) {
$this->configureDefinition($htmlDef);
}
$this->purifier = new HTMLPurifier($config);
}
protected function createCacheFolderIfNeeded(string $cachePath): void
{
if (!file_exists($cachePath)) {
mkdir($cachePath, 0777, true);
}
}
protected function resetCacheIfNeeded(HTMLPurifier_Config $config): void
{
if (self::$cachedChecked) {
return;
}
$cachedForVersion = cache('htmlpurifier::cache-version');
$appVersion = AppVersion::get();
if ($cachedForVersion !== $appVersion) {
foreach (['HTML', 'CSS', 'URI'] as $name) {
$cache = new HTMLPurifier_DefinitionCache_Serializer($name);
$cache->flush($config);
}
cache()->set('htmlpurifier::cache-version', $appVersion);
}
self::$cachedChecked = true;
}
protected function setConfig(HTMLPurifier_Config $config, string $cachePath): void
{
$config->set('Cache.SerializerPath', $cachePath);
$config->set('Core.AllowHostnameUnderscore', true);
$config->set('CSS.AllowTricky', true);
$config->set('HTML.SafeIframe', true);
$config->set('Attr.EnableID', true);
$config->set('Attr.ID.HTML5', true);
$config->set('Output.FixInnerHTML', false);
$config->set('URI.SafeIframeRegexp', '%^(http://|https://|//)%');
$config->set('URI.AllowedSchemes', [
'http' => true,
'https' => true,
'mailto' => true,
'ftp' => true,
'nntp' => true,
'news' => true,
'tel' => true,
'file' => true,
]);
// $config->set('Cache.DefinitionImpl', null); // Disable cache during testing
}
public function configureDefinition(HTMLPurifier_HTMLDefinition $definition): void
{
// Allow the object element
$definition->addElement(
'object',
'Inline',
'Flow',
'Common',
[
'data' => 'URI',
'type' => 'Text',
'width' => 'Length',
'height' => 'Length',
]
);
// Allow the embed element
$definition->addElement(
'embed',
'Inline',
'Empty',
'Common',
[
'src' => 'URI',
'type' => 'Text',
'width' => 'Length',
'height' => 'Length',
]
);
// Allow checkbox inputs
$definition->addElement(
'input',
'Formctrl',
'Empty',
'Common',
[
'checked' => 'Bool#checked',
'disabled' => 'Bool#disabled',
'name' => 'Text',
'readonly' => 'Bool#readonly',
'type' => 'Enum#checkbox',
'value' => 'Text',
]
);
// Allow the drawio-diagram attribute on div elements
$definition->addAttribute(
'div',
'drawio-diagram',
'Number',
);
}
public function purify(string $html): string
{
return $this->purifier->purify($html);
}
}

View File

@@ -65,7 +65,7 @@ class CspService
*/ */
protected function getScriptSrc(): string protected function getScriptSrc(): string
{ {
if ($this->scriptFilteringDisabled()) { if (config('app.allow_content_scripts')) {
return ''; return '';
} }
@@ -108,7 +108,7 @@ class CspService
*/ */
protected function getObjectSrc(): string protected function getObjectSrc(): string
{ {
if ($this->scriptFilteringDisabled()) { if (config('app.allow_content_scripts')) {
return ''; return '';
} }
@@ -124,11 +124,6 @@ class CspService
return "base-uri 'self'"; return "base-uri 'self'";
} }
protected function scriptFilteringDisabled(): bool
{
return !HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'))->filterOutJavaScript;
}
protected function getAllowedIframeHosts(): array protected function getAllowedIframeHosts(): array
{ {
$hosts = config('app.iframe_hosts') ?? ''; $hosts = config('app.iframe_hosts') ?? '';

View File

@@ -8,46 +8,10 @@ use DOMNodeList;
class HtmlContentFilter class HtmlContentFilter
{ {
public function __construct( /**
protected HtmlContentFilterConfig $config * Remove all the script elements from the given HTML document.
) { */
} public static function removeScriptsFromDocument(HtmlDocument $doc)
public function filterDocument(HtmlDocument $doc): string
{
if ($this->config->filterOutJavaScript) {
$this->filterOutScriptsFromDocument($doc);
}
if ($this->config->filterOutFormElements) {
$this->filterOutFormElementsFromDocument($doc);
}
if ($this->config->filterOutBadHtmlElements) {
$this->filterOutBadHtmlElementsFromDocument($doc);
}
if ($this->config->filterOutNonContentElements) {
$this->filterOutNonContentElementsFromDocument($doc);
}
$filtered = $doc->getBodyInnerHtml();
if ($this->config->useAllowListFilter) {
$filtered = $this->applyAllowListFiltering($filtered);
}
return $filtered;
}
public function filterString(string $html): string
{
return $this->filterDocument(new HtmlDocument($html));
}
protected function applyAllowListFiltering(string $html): string
{
$purifier = new ConfiguredHtmlPurifier();
return $purifier->purify($html);
}
protected function filterOutScriptsFromDocument(HtmlDocument $doc): void
{ {
// Remove standard script tags // Remove standard script tags
$scriptElems = $doc->queryXPath('//script'); $scriptElems = $doc->queryXPath('//script');
@@ -57,21 +21,21 @@ class HtmlContentFilter
$badLinks = $doc->queryXPath('//*[' . static::xpathContains('@href', 'javascript:') . ']'); $badLinks = $doc->queryXPath('//*[' . static::xpathContains('@href', 'javascript:') . ']');
static::removeNodes($badLinks); static::removeNodes($badLinks);
// Remove elements with form-like attributes with calls to JavaScript URI // Remove forms with calls to JavaScript URI
$badForms = $doc->queryXPath('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']'); $badForms = $doc->queryXPath('//*[' . static::xpathContains('@action', 'javascript:') . '] | //*[' . static::xpathContains('@formaction', 'javascript:') . ']');
static::removeNodes($badForms); static::removeNodes($badForms);
// Remove data or JavaScript iFrames & embeds // Remove meta tag to prevent external redirects
$metaTags = $doc->queryXPath('//meta[' . static::xpathContains('@content', 'url') . ']');
static::removeNodes($metaTags);
// Remove data or JavaScript iFrames
$badIframes = $doc->queryXPath('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]'); $badIframes = $doc->queryXPath('//*[' . static::xpathContains('@src', 'data:') . '] | //*[' . static::xpathContains('@src', 'javascript:') . '] | //*[@srcdoc]');
static::removeNodes($badIframes); static::removeNodes($badIframes);
// Remove data or JavaScript objects
$badObjects = $doc->queryXPath('//*[' . static::xpathContains('@data', 'data:') . '] | //*[' . static::xpathContains('@data', 'javascript:') . ']');
static::removeNodes($badObjects);
// Remove attributes, within svg children, hiding JavaScript or data uris. // Remove attributes, within svg children, hiding JavaScript or data uris.
// A bunch of svg element and attribute combinations expose xss possibilities. // A bunch of svg element and attribute combinations expose xss possibilities.
// For example, SVG animate tag can exploit JavaScript in values. // For example, SVG animate tag can exploit javascript in values.
$badValuesAttrs = $doc->queryXPath('//svg//@*[' . static::xpathContains('.', 'data:') . '] | //svg//@*[' . static::xpathContains('.', 'javascript:') . ']'); $badValuesAttrs = $doc->queryXPath('//svg//@*[' . static::xpathContains('.', 'data:') . '] | //svg//@*[' . static::xpathContains('.', 'javascript:') . ']');
static::removeAttributes($badValuesAttrs); static::removeAttributes($badValuesAttrs);
@@ -85,52 +49,23 @@ class HtmlContentFilter
static::removeAttributes($onAttributes); static::removeAttributes($onAttributes);
} }
protected function filterOutFormElementsFromDocument(HtmlDocument $doc): void /**
* Remove scripts from the given HTML string.
*/
public static function removeScriptsFromHtmlString(string $html): string
{ {
// Remove form elements if (empty($html)) {
$formElements = ['form', 'fieldset', 'button', 'textarea', 'select']; return $html;
foreach ($formElements as $formElement) {
$matchingFormElements = $doc->queryXPath('//' . $formElement);
static::removeNodes($matchingFormElements);
} }
// Remove non-checkbox inputs $doc = new HtmlDocument($html);
$inputsToRemove = $doc->queryXPath('//input'); static::removeScriptsFromDocument($doc);
/** @var DOMElement $input */
foreach ($inputsToRemove as $input) {
$type = strtolower($input->getAttribute('type'));
if ($type !== 'checkbox') {
$input->parentNode->removeChild($input);
}
}
// Remove form attributes return $doc->getBodyInnerHtml();
$formAttrs = ['form', 'formaction', 'formmethod', 'formtarget'];
foreach ($formAttrs as $formAttr) {
$matchingFormAttrs = $doc->queryXPath('//@' . $formAttr);
static::removeAttributes($matchingFormAttrs);
}
}
protected function filterOutBadHtmlElementsFromDocument(HtmlDocument $doc): void
{
// Remove meta tag to prevent external redirects
$metaTags = $doc->queryXPath('//meta[' . static::xpathContains('@content', 'url') . ']');
static::removeNodes($metaTags);
}
protected function filterOutNonContentElementsFromDocument(HtmlDocument $doc): void
{
// Remove non-content elements
$formElements = ['link', 'style', 'meta', 'title', 'template'];
foreach ($formElements as $formElement) {
$matchingFormElements = $doc->queryXPath('//' . $formElement);
static::removeNodes($matchingFormElements);
}
} }
/** /**
* Create an x-path 'contains' statement with a translation automatically built within * Create a xpath contains statement with a translation automatically built within
* to affectively search in a cases-insensitive manner. * to affectively search in a cases-insensitive manner.
*/ */
protected static function xpathContains(string $property, string $value): string protected static function xpathContains(string $property, string $value): string
@@ -164,34 +99,4 @@ class HtmlContentFilter
$parentNode->removeAttribute($attrName); $parentNode->removeAttribute($attrName);
} }
} }
/**
* Alias using the old method name to avoid potential compatibility breaks during patch release.
* To remove in future feature release.
* @deprecated Use filterDocument instead.
*/
public static function removeScriptsFromDocument(HtmlDocument $doc): void
{
$config = new HtmlContentFilterConfig(
filterOutNonContentElements: false,
useAllowListFilter: false,
);
$filter = new self($config);
$filter->filterDocument($doc);
}
/**
* Alias using the old method name to avoid potential compatibility breaks during patch release.
* To remove in future feature release.
* @deprecated Use filterString instead.
*/
public static function removeScriptsFromHtmlString(string $html): string
{
$config = new HtmlContentFilterConfig(
filterOutNonContentElements: false,
useAllowListFilter: false,
);
$filter = new self($config);
return $filter->filterString($html);
}
} }

View File

@@ -1,31 +0,0 @@
<?php
namespace BookStack\Util;
readonly class HtmlContentFilterConfig
{
public function __construct(
public bool $filterOutJavaScript = true,
public bool $filterOutBadHtmlElements = true,
public bool $filterOutFormElements = true,
public bool $filterOutNonContentElements = true,
public bool $useAllowListFilter = true,
) {
}
/**
* Create an instance from a config string, where the string
* is a combination of characters to enable filters.
*/
public static function fromConfigString(string $config): self
{
$config = strtolower($config);
return new self(
filterOutJavaScript: str_contains($config, 'j'),
filterOutBadHtmlElements: str_contains($config, 'h'),
filterOutFormElements: str_contains($config, 'f'),
filterOutNonContentElements: str_contains($config, 'h'),
useAllowListFilter: str_contains($config, 'a'),
);
}
}

View File

@@ -19,7 +19,7 @@ class HtmlDescriptionFilter
*/ */
protected static array $allowedAttrsByElements = [ protected static array $allowedAttrsByElements = [
'p' => [], 'p' => [],
'a' => ['href', 'title', 'target', 'data-mention-user-id'], 'a' => ['href', 'title', 'target'],
'ol' => [], 'ol' => [],
'ul' => [], 'ul' => [],
'li' => [], 'li' => [],

View File

@@ -103,13 +103,7 @@ class HtmlDocument
*/ */
public function getBody(): DOMNode public function getBody(): DOMNode
{ {
$bodies = $this->document->getElementsByTagName('body'); return $this->document->getElementsByTagName('body')[0];
if ($bodies->length === 0) {
return new DOMElement('body', '');
}
return $bodies[0];
} }
/** /**

View File

@@ -2,8 +2,6 @@
namespace BookStack\Util; namespace BookStack\Util;
use BookStack\Facades\Theme;
class SvgIcon class SvgIcon
{ {
public function __construct( public function __construct(
@@ -25,9 +23,12 @@ class SvgIcon
$attrString .= $attrName . '="' . $attr . '" '; $attrString .= $attrName . '="' . $attr . '" ';
} }
$defaultIconPath = resource_path('icons/' . $this->name . '.svg'); $iconPath = resource_path('icons/' . $this->name . '.svg');
$iconPath = Theme::findFirstFile("icons/{$this->name}.svg") ?? $defaultIconPath; $themeIconPath = theme_path('icons/' . $this->name . '.svg');
if (!file_exists($iconPath)) {
if ($themeIconPath && file_exists($themeIconPath)) {
$iconPath = $themeIconPath;
} elseif (!file_exists($iconPath)) {
return ''; return '';
} }

Binary file not shown.

View File

@@ -19,7 +19,6 @@
"ext-zip": "*", "ext-zip": "*",
"bacon/bacon-qr-code": "^3.0", "bacon/bacon-qr-code": "^3.0",
"dompdf/dompdf": "^3.1", "dompdf/dompdf": "^3.1",
"ezyang/htmlpurifier": "^4.19",
"guzzlehttp/guzzle": "^7.4", "guzzlehttp/guzzle": "^7.4",
"intervention/image": "^3.5", "intervention/image": "^3.5",
"knplabs/knp-snappy": "^1.5", "knplabs/knp-snappy": "^1.5",
@@ -30,17 +29,16 @@
"league/flysystem-aws-s3-v3": "^3.0", "league/flysystem-aws-s3-v3": "^3.0",
"league/html-to-markdown": "^5.0.0", "league/html-to-markdown": "^5.0.0",
"league/oauth2-client": "^2.6", "league/oauth2-client": "^2.6",
"onelogin/php-saml": "^4.3.1", "onelogin/php-saml": "^4.0",
"phpseclib/phpseclib": "^3.0", "phpseclib/phpseclib": "^3.0",
"pragmarx/google2fa": "^9.0", "pragmarx/google2fa": "^8.0",
"predis/predis": "^3.2", "predis/predis": "^3.2",
"socialiteproviders/discord": "^4.1", "socialiteproviders/discord": "^4.1",
"socialiteproviders/gitlab": "^4.1", "socialiteproviders/gitlab": "^4.1",
"socialiteproviders/microsoft-azure": "^5.1", "socialiteproviders/microsoft-azure": "^5.1",
"socialiteproviders/okta": "^4.2", "socialiteproviders/okta": "^4.2",
"socialiteproviders/twitch": "^5.3", "socialiteproviders/twitch": "^5.3",
"ssddanbrown/htmldiff": "^2.0.0", "ssddanbrown/htmldiff": "^2.0.0"
"xemlock/htmlpurifier-html5": "^0.1.12"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.21", "fakerphp/faker": "^1.21",
@@ -49,7 +47,7 @@
"nunomaduro/collision": "^8.6", "nunomaduro/collision": "^8.6",
"larastan/larastan": "^v3.0", "larastan/larastan": "^v3.0",
"phpunit/phpunit": "^11.5", "phpunit/phpunit": "^11.5",
"squizlabs/php_codesniffer": "^4.0.1", "squizlabs/php_codesniffer": "^3.7",
"ssddanbrown/asserthtml": "^3.1" "ssddanbrown/asserthtml": "^3.1"
}, },
"autoload": { "autoload": {
@@ -95,7 +93,6 @@
"@php artisan view:clear" "@php artisan view:clear"
], ],
"refresh-test-database": [ "refresh-test-database": [
"@putenv APP_TIMEZONE=UTC",
"@php artisan migrate:refresh --database=mysql_testing", "@php artisan migrate:refresh --database=mysql_testing",
"@php artisan db:seed --class=DummyContentSeeder --database=mysql_testing" "@php artisan db:seed --class=DummyContentSeeder --database=mysql_testing"
] ]

1555
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
project_id: "377219"
project_identifier: bookstack project_identifier: bookstack
base_path: . base_path: .
preserve_hierarchy: false preserve_hierarchy: false

View File

@@ -1,29 +0,0 @@
<?php
namespace Database\Factories\Entities\Models;
use BookStack\Entities\Models\Book;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\BookStack\Entities\Models\SlugHistory>
*/
class SlugHistoryFactory extends Factory
{
protected $model = \BookStack\Entities\Models\SlugHistory::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'sluggable_id' => Book::factory(),
'sluggable_type' => 'book',
'slug' => $this->faker->slug(),
'parent_slug' => null,
];
}
}

View File

@@ -1,51 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Create the table for storing slug history
Schema::create('slug_history', function (Blueprint $table) {
$table->increments('id');
$table->string('sluggable_type', 10)->index();
$table->unsignedBigInteger('sluggable_id')->index();
$table->string('slug')->index();
$table->string('parent_slug')->nullable()->index();
$table->timestamps();
});
// Migrate in slugs from page revisions
$revisionSlugQuery = DB::table('page_revisions')
->select([
DB::raw('\'page\' as sluggable_type'),
'page_id as sluggable_id',
'slug',
'book_slug as parent_slug',
DB::raw('min(created_at) as created_at'),
DB::raw('min(updated_at) as updated_at'),
])
->where('type', '=', 'version')
->groupBy(['sluggable_id', 'slug', 'parent_slug']);
DB::table('slug_history')->insertUsing(
['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'],
$revisionSlugQuery,
);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('slug_history');
}
};

View File

@@ -1,31 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('mention_history', function (Blueprint $table) {
$table->increments('id');
$table->string('mentionable_type', 50)->index();
$table->unsignedBigInteger('mentionable_id')->index();
$table->unsignedInteger('from_user_id');
$table->unsignedInteger('to_user_id');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('mention_history');
}
};

View File

@@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('views', function (Blueprint $table) {
$table->index('viewable_type', 'views_viewable_type_index');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('views', function (Blueprint $table) {
$table->dropIndex('views_viewable_type_index');
});
}
};

View File

@@ -13,6 +13,7 @@ use BookStack\Search\SearchIndex;
use BookStack\Users\Models\Role; use BookStack\Users\Models\Role;
use BookStack\Users\Models\User; use BookStack\Users\Models\User;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -22,8 +23,10 @@ class DummyContentSeeder extends Seeder
{ {
/** /**
* Run the database seeds. * Run the database seeds.
*
* @return void
*/ */
public function run(): void public function run()
{ {
// Create an editor user // Create an editor user
$editorUser = User::factory()->create(); $editorUser = User::factory()->create();

View File

@@ -1,15 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
import * as esbuild from 'esbuild'; const esbuild = require('esbuild');
import * as path from 'node:path'; const path = require('path');
import * as fs from 'node:fs'; const fs = require('fs');
import * as process from "node:process";
// Check if we're building for production // Check if we're building for production
// (Set via passing `production` as first argument) // (Set via passing `production` as first argument)
const mode = process.argv[2]; const isProd = process.argv[2] === 'production';
const isProd = mode === 'production';
const __dirname = import.meta.dirname;
// Gather our input files // Gather our input files
const entryPoints = { const entryPoints = {
@@ -20,16 +17,11 @@ const entryPoints = {
wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'), wysiwyg: path.join(__dirname, '../../resources/js/wysiwyg/index.ts'),
}; };
// Watch styles so we can reload on change
if (mode === 'watch') {
entryPoints['styles-dummy'] = path.join(__dirname, '../../public/dist/styles.css');
}
// Locate our output directory // Locate our output directory
const outdir = path.join(__dirname, '../../public/dist'); const outdir = path.join(__dirname, '../../public/dist');
// Define the options for esbuild // Build via esbuild
const options = { esbuild.build({
bundle: true, bundle: true,
metafile: true, metafile: true,
entryPoints, entryPoints,
@@ -41,7 +33,6 @@ const options = {
minify: isProd, minify: isProd,
logLevel: 'info', logLevel: 'info',
loader: { loader: {
'.html': 'copy',
'.svg': 'text', '.svg': 'text',
}, },
absWorkingDir: path.join(__dirname, '../..'), absWorkingDir: path.join(__dirname, '../..'),
@@ -54,34 +45,6 @@ const options = {
js: '// See the "/licenses" URI for full package license details', js: '// See the "/licenses" URI for full package license details',
css: '/* See the "/licenses" URI for full package license details */', css: '/* See the "/licenses" URI for full package license details */',
}, },
}; }).then(result => {
if (mode === 'watch') {
options.inject = [
path.join(__dirname, './livereload.js'),
];
}
const ctx = await esbuild.context(options);
if (mode === 'watch') {
// Watch for changes and rebuild on change
ctx.watch({});
let {hosts, port} = await ctx.serve({
servedir: path.join(__dirname, '../../public'),
cors: {
origin: '*',
}
});
} else {
// Build with meta output for analysis
const result = await ctx.rebuild();
const outputs = result.metafile.outputs;
const files = Object.keys(outputs);
for (const file of files) {
const output = outputs[file];
console.log(`Written: ${file} @ ${Math.round(output.bytes / 1000)}kB`);
}
fs.writeFileSync('esbuild-meta.json', JSON.stringify(result.metafile)); fs.writeFileSync('esbuild-meta.json', JSON.stringify(result.metafile));
process.exit(0); }).catch(() => process.exit(1));
}

View File

@@ -1,35 +0,0 @@
if (!window.__dev_reload_listening) {
listen();
window.__dev_reload_listening = true;
}
function listen() {
console.log('Listening for livereload events...');
new EventSource("http://127.0.0.1:8000/esbuild").addEventListener('change', e => {
const { added, removed, updated } = JSON.parse(e.data);
if (!added.length && !removed.length && updated.length > 0) {
const updatedPath = updated.filter(path => path.endsWith('.css'))[0]
if (!updatedPath) return;
const links = [...document.querySelectorAll("link[rel='stylesheet']")];
for (const link of links) {
const url = new URL(link.href);
const name = updatedPath.replace('-dummy', '');
if (url.pathname.endsWith(name)) {
const next = link.cloneNode();
next.href = name + '?version=' + Math.random().toString(36).slice(2);
next.onload = function() {
link.remove();
};
link.after(next);
return
}
}
}
location.reload()
});
}

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