Compare commits

..

277 Commits

Author SHA1 Message Date
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
639 changed files with 10982 additions and 22893 deletions

View File

@@ -42,7 +42,7 @@ APP_TIMEZONE=UTC
# overrides can be made. Defaults to disabled.
APP_THEME=false
# Trusted proxies
# Trusted Proxies
# Used to indicate trust of systems that proxy to the application so
# certain header values (Such as "X-Forwarded-For") can be used from the
# incoming proxy request to provide origin detail.
@@ -58,13 +58,6 @@ DB_DATABASE=database_database
DB_USERNAME=database_username
DB_PASSWORD=database_user_password
# MySQL specific connection options
# Path to Certificate Authority (CA) certificate file for your MySQL instance.
# When this option is used host name identity verification will be performed
# which checks the hostname, used by the client, against names within the
# certificate itself (Common Name or Subject Alternative Name).
MYSQL_ATTR_SSL_CA="/path/to/ca.pem"
# Mail system to use
# Can be 'smtp' or 'sendmail'
MAIL_DRIVER=smtp
@@ -223,7 +216,6 @@ LDAP_DUMP_USER_DETAILS=false
LDAP_USER_TO_GROUPS=false
LDAP_GROUP_ATTRIBUTE="memberOf"
LDAP_REMOVE_FROM_GROUPS=false
LDAP_DUMP_USER_GROUPS=false
# SAML authentication configuration
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
@@ -274,7 +266,7 @@ AVATAR_URL=
# Enable diagrams.net integration
# Can simply be true/false to enable/disable the integration.
# Alternatively, It can be URL to the diagrams.net instance you want to use.
# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1&configure=1
# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1
DRAWIO=true
# Default item listing view
@@ -305,11 +297,6 @@ RECYCLE_BIN_LIFETIME=30
# Maximum file size, in megabytes, that can be uploaded to the system.
FILE_UPLOAD_SIZE_LIMIT=50
# Export Page Size
# Primarily used to determine page size of PDF exports.
# Can be 'a4' or 'letter'.
EXPORT_PAGE_SIZE=a4
# Allow <script> tags in page content
# Note, if set to 'true' the page editor may still escape scripts.
ALLOW_CONTENT_SCRIPTS=false
@@ -332,13 +319,6 @@ ALLOW_UNTRUSTED_SERVER_FETCHING=false
# Setting this option will also auto-adjust cookies to be SameSite=None.
ALLOWED_IFRAME_HOSTS=null
# A list of sources/hostnames that can be loaded within iframes within BookStack.
# Space separated if multiple. BookStack host domain is auto-inferred.
# Can be set to a lone "*" to allow all sources for iframe content (Not advised).
# Defaults to a set of common services.
# Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com"
# The default and maximum item-counts for listing API requests.
API_DEFAULT_ITEM_COUNT=100
API_MAX_ITEM_COUNT=500

View File

@@ -1,5 +1,6 @@
name: New API Endpoint or API Ability
description: Request a new endpoint or API feature be added
title: "[API Request]: "
labels: [":nut_and_bolt: API Request"]
body:
- type: textarea

View File

@@ -1,5 +1,6 @@
name: Bug Report
description: Create a report to help us improve or fix things
title: "[Bug Report]: "
labels: [":bug: Bug"]
body:
- type: textarea
@@ -35,15 +36,6 @@ body:
description: Provide any additional context and screenshots here to help us solve this issue
validations:
required: false
- type: input
id: browserdetails
attributes:
label: Browser Details
description: |
If this is an issue that occurs when using the BookStack interface, please provide details of the browser used which presents the reported issue.
placeholder: (eg. Firefox 97 (64-bit) on Windows 11)
validations:
required: false
- type: input
id: bsversion
attributes:

View File

@@ -1,13 +1,9 @@
blank_issues_enabled: false
contact_links:
- name: Discord Chat Support
- name: Discord chat support
url: https://discord.gg/ztkBqR2
about: Realtime support & chat with the BookStack community and the team.
about: Realtime support / chat with the community and the team.
- name: Debugging & Common Issues
url: https://www.bookstackapp.com/docs/admin/debugging/
about: Find details on how to debug issues and view common issues with their resolutions.
- name: Official Support Plans
url: https://www.bookstackapp.com/support/
about: View our official support plans that offer assured support for business.
about: Find details on how to debug issues and view common issues with thier resolutions.

View File

@@ -1,5 +1,6 @@
name: Feature Request
description: Request a new feature or idea to be added to BookStack
description: Request a new language to be added to CrowdIn for you to translate
title: "[Feature Request]: "
labels: [":hammer: Feature Request"]
body:
- type: textarea
@@ -12,41 +13,8 @@ body:
- type: textarea
id: benefits
attributes:
label: Describe the benefits this would bring to existing BookStack users
description: |
Explain the measurable benefits this feature would achieve for existing BookStack users.
These benefits should details outcomes in terms of what this request solves/achieves, and should not be specific to implementation.
This helps us understand the core desired goal so that a variety of potential implementations could be explored.
This field is important. Lack if input here may lead to early issue closure.
validations:
required: true
- type: textarea
id: already_achieved
attributes:
label: Can the goal of this request already be achieved via other means?
description: |
Yes/No. If yes, please describe how the requested approach fits in with the existing method.
validations:
required: true
- type: checkboxes
id: confirm-search
attributes:
label: Have you searched for an existing open/closed issue?
description: |
To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue) for any existing issues that cover the fundemental benefit/goal of your request.
options:
- label: I have searched for existing issues and none cover my fundemental request
required: true
- type: dropdown
id: existing_usage
attributes:
label: How long have you been using BookStack?
options:
- Not using yet, just scoping
- 0 to 6 months
- 6 months to 1 year
- 1 to 5 years
- Over 5 years
label: Describe the benefits this feature would bring to BookStack users
description: Explain the measurable benefits this feature would achieve for existing BookStack users
validations:
required: true
- type: textarea

View File

@@ -1,5 +1,6 @@
name: Language Request
description: Request a new language to be added to CrowdIn for you to translate
title: "[Language Request]: "
labels: [":earth_africa: Translations"]
assignees:
- ssddanbrown

View File

@@ -1,5 +1,6 @@
name: Support Request
description: Request support for a specific problem you have not been able to solve yourself
title: "[Support Request]: "
labels: [":dog2: Support"]
body:
- type: checkboxes

View File

@@ -158,14 +158,14 @@ HenrijsS :: Latvian
Pascal R-B (pborgner) :: German
Boris (Ginfred) :: Russian
Jonas Anker Rasmussen (jonasanker) :: Danish
Gerwin de Keijzer (gdekeijzer) :: Dutch; German Informal; German
Gerwin de Keijzer (gdekeijzer) :: Dutch; German; German Informal
kometchtech :: Japanese
Auri (Atalonica) :: Catalan
Francesco Franchina (ffranchina) :: Italian
Aimrane Kds (aimrane.kds) :: Arabic
whenwesober :: Indonesian
Rem (remkovdhoef) :: Dutch
syn7ax69 :: Bulgarian; Turkish; German
syn7ax69 :: Bulgarian; Turkish
Blaade :: French
Behzad HosseinPoor (behzad.hp) :: Persian
Ole Aldric (Swoy) :: Norwegian Bokmal
@@ -215,31 +215,3 @@ Saeed (saeed205) :: Persian
Julesdevops :: French
peter cerny (posli.to.semka) :: Slovak
Pavel Karlin (pavelkarlin) :: Russian
SmokingCrop :: Dutch
Maciej Lebiest (Szwendacz) :: Polish
DiscordDigital :: German; German Informal
Gábor Marton (dodver) :: Hungarian
Jasell :: Swedish
Ghost_chu (ghostchu) :: Chinese Simplified
Ravid Shachar (ravidshachar) :: Hebrew
Helga Guchshenskaya (guchshenskaya) :: Russian
daniel chou (chou0214) :: Chinese Traditional
Manolis PATRIARCHE (m.patriarche) :: French
Mohammed Haboubi (haboubi92) :: Arabic
roncallyt :: Portuguese, Brazilian
goegol :: Dutch
msevgen :: Turkish
Khroners :: French
MASOUD HOSSEINY (masoudme) :: Persian
Thomerson Roncally (roncallyt) :: Portuguese, Brazilian
metaarch :: Bulgarian
Xabi (xabikip) :: Basque
pedromcsousa :: Portuguese
Nir Louk (looknear) :: Hebrew
Alex (qianmengnet) :: Chinese Simplified
stothew :: German
sgenc :: Turkish
Shukrullo (vodiylik) :: Uzbek
William W. (Nevnt) :: Chinese Traditional
eamaro :: Portuguese
Ypsilon-dev :: Arabic

View File

@@ -1,14 +1,19 @@
name: phpstan
on: [push, pull_request]
on:
push:
branches-ignore:
- l10n_master
pull_request:
branches-ignore:
- l10n_master
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.4']
php: ['7.3']
steps:
- uses: actions/checkout@v1

View File

@@ -1,14 +1,19 @@
name: phpunit
on: [push, pull_request]
on:
push:
branches-ignore:
- l10n_master
pull_request:
branches-ignore:
- l10n_master
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.4', '8.0', '8.1']
php: ['7.3', '7.4', '8.0', '8.1']
steps:
- uses: actions/checkout@v1

View File

@@ -1,14 +1,19 @@
name: test-migrations
on: [push, pull_request]
on:
push:
branches-ignore:
- l10n_master
pull_request:
branches-ignore:
- l10n_master
jobs:
build:
if: ${{ github.ref != 'refs/heads/l10n_development' }}
runs-on: ubuntu-20.04
strategy:
matrix:
php: ['7.4', '8.0', '8.1']
php: ['7.3', '7.4', '8.0', '8.1']
steps:
- uses: actions/checkout@v1

6
.gitignore vendored
View File

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

View File

@@ -3,14 +3,17 @@
namespace BookStack\Actions;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Facades\Theme;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use BookStack\Theming\ThemeEvents;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -21,16 +24,31 @@ class DispatchWebhookJob implements ShouldQueue
use Queueable;
use SerializesModels;
protected Webhook $webhook;
protected string $event;
protected User $initiator;
protected int $initiatedTime;
/**
* @var Webhook
*/
protected $webhook;
/**
* @var string
*/
protected $event;
/**
* @var string|Loggable
*/
protected $detail;
/**
* @var User
*/
protected $initiator;
/**
* @var int
*/
protected $initiatedTime;
/**
* Create a new job instance.
*
@@ -52,8 +70,8 @@ class DispatchWebhookJob implements ShouldQueue
*/
public function handle()
{
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime);
$webhookData = $themeResponse ?? WebhookFormatter::getDefault($this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime)->format();
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail);
$webhookData = $themeResponse ?? $this->buildWebhookData();
$lastError = null;
try {
@@ -79,4 +97,36 @@ class DispatchWebhookJob implements ShouldQueue
$this->webhook->save();
}
protected function buildWebhookData(): array
{
$textParts = [
$this->initiator->name,
trans('activities.' . $this->event),
];
if ($this->detail instanceof Entity) {
$textParts[] = '"' . $this->detail->name . '"';
}
$data = [
'event' => $this->event,
'text' => implode(' ', $textParts),
'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
'triggered_by' => $this->initiator->attributesToArray(),
'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
'webhook_id' => $this->webhook->id,
'webhook_name' => $this->webhook->name,
];
if (method_exists($this->detail, 'getUrl')) {
$data['url'] = $this->detail->getUrl();
}
if ($this->detail instanceof Model) {
$data['related_item'] = $this->detail->attributesToArray();
}
return $data;
}
}

View File

@@ -28,10 +28,10 @@ class TagRepo
'name',
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
DB::raw('COUNT(id) as usages'),
DB::raw('SUM(IF(entity_type = \'page\', 1, 0)) as page_count'),
DB::raw('SUM(IF(entity_type = \'chapter\', 1, 0)) as chapter_count'),
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Page\', 1, 0)) as page_count'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Chapter\', 1, 0)) as chapter_count'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Book\', 1, 0)) as book_count'),
DB::raw('SUM(IF(entity_type = \'BookStack\\\\BookShelf\', 1, 0)) as shelf_count'),
])
->orderBy($nameFilter ? 'value' : 'name');

View File

@@ -1,124 +0,0 @@
<?php
namespace BookStack\Actions;
use BookStack\Auth\User;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use Illuminate\Support\Carbon;
class WebhookFormatter
{
protected Webhook $webhook;
protected string $event;
protected User $initiator;
protected int $initiatedTime;
/**
* @var string|Loggable
*/
protected $detail;
/**
* @var array{condition: callable(string, Model):bool, format: callable(Model):void}[]
*/
protected $modelFormatters = [];
public function __construct(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime)
{
$this->webhook = $webhook;
$this->event = $event;
$this->initiator = $initiator;
$this->initiatedTime = $initiatedTime;
$this->detail = is_object($detail) ? clone $detail : $detail;
}
public function format(): array
{
$data = [
'event' => $this->event,
'text' => $this->formatText(),
'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
'triggered_by' => $this->initiator->attributesToArray(),
'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
'webhook_id' => $this->webhook->id,
'webhook_name' => $this->webhook->name,
];
if (method_exists($this->detail, 'getUrl')) {
$data['url'] = $this->detail->getUrl();
}
if ($this->detail instanceof Model) {
$data['related_item'] = $this->formatModel();
}
return $data;
}
/**
* @param callable(string, Model):bool $condition
* @param callable(Model):void $format
*/
public function addModelFormatter(callable $condition, callable $format): void
{
$this->modelFormatters[] = [
'condition' => $condition,
'format' => $format,
];
}
public function addDefaultModelFormatters(): void
{
// Load entity owner, creator, updater details
$this->addModelFormatter(
fn ($event, $model) => ($model instanceof Entity),
fn ($model) => $model->load(['ownedBy', 'createdBy', 'updatedBy'])
);
// Load revision detail for page update and create events
$this->addModelFormatter(
fn ($event, $model) => ($model instanceof Page && ($event === ActivityType::PAGE_CREATE || $event === ActivityType::PAGE_UPDATE)),
fn ($model) => $model->load('currentRevision')
);
}
protected function formatModel(): array
{
/** @var Model $model */
$model = $this->detail;
$model->unsetRelations();
foreach ($this->modelFormatters as $formatter) {
if ($formatter['condition']($this->event, $model)) {
$formatter['format']($model);
}
}
return $model->toArray();
}
protected function formatText(): string
{
$textParts = [
$this->initiator->name,
trans('activities.' . $this->event),
];
if ($this->detail instanceof Entity) {
$textParts[] = '"' . $this->detail->name . '"';
}
return implode(' ', $textParts);
}
public static function getDefault(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime): self
{
$instance = new self($event, $webhook, $detail, $initiator, $initiatedTime);
$instance->addDefaultModelFormatters();
return $instance;
}
}

View File

@@ -3,13 +3,11 @@
namespace BookStack\Api;
use BookStack\Http\Controllers\Api\ApiController;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
@@ -102,37 +100,11 @@ class ApiDocsGenerator
$this->controllerClasses[$className] = $class;
}
$rules = collect($class->getValidationRules()[$methodName] ?? [])->map(function ($validations) {
return array_map(function ($validation) {
return $this->getValidationAsString($validation);
}, $validations);
})->toArray();
$rules = $class->getValdationRules()[$methodName] ?? [];
return empty($rules) ? null : $rules;
}
/**
* Convert the given validation message to a readable string.
*/
protected function getValidationAsString($validation): string
{
if (is_string($validation)) {
return $validation;
}
if (is_object($validation) && method_exists($validation, '__toString')) {
return strval($validation);
}
if ($validation instanceof Password) {
return 'min:8';
}
$class = get_class($validation);
throw new Exception("Cannot provide string representation of rule for class: {$class}");
}
/**
* Parse out the description text from a class method comment.
*/

View File

@@ -2,10 +2,8 @@
namespace BookStack\Api;
use BookStack\Model;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ListingResponseBuilder
@@ -14,11 +12,6 @@ class ListingResponseBuilder
protected $request;
protected $fields;
/**
* @var array<callable>
*/
protected $resultModifiers = [];
protected $filterOperators = [
'eq' => '=',
'ne' => '!=',
@@ -31,7 +24,6 @@ class ListingResponseBuilder
/**
* ListingResponseBuilder constructor.
* The given fields will be forced visible within the model results.
*/
public function __construct(Builder $query, Request $request, array $fields)
{
@@ -43,16 +35,12 @@ class ListingResponseBuilder
/**
* Get the response from this builder.
*/
public function toResponse(): JsonResponse
public function toResponse()
{
$filteredQuery = $this->filterQuery($this->query);
$total = $filteredQuery->count();
$data = $this->fetchData($filteredQuery)->each(function ($model) {
foreach ($this->resultModifiers as $modifier) {
$modifier($model);
}
});
$data = $this->fetchData($filteredQuery);
return response()->json([
'data' => $data,
@@ -61,17 +49,7 @@ class ListingResponseBuilder
}
/**
* Add a callback to modify each element of the results.
*
* @param (callable(Model)) $modifier
*/
public function modifyResults($modifier): void
{
$this->resultModifiers[] = $modifier;
}
/**
* Fetch the data to return within the response.
* Fetch the data to return in the response.
*/
protected function fetchData(Builder $query): Collection
{

View File

@@ -5,7 +5,6 @@ namespace BookStack\Auth\Access\Guards;
use BookStack\Auth\Access\LdapService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\LdapException;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
@@ -16,7 +15,7 @@ use Illuminate\Support\Str;
class LdapSessionGuard extends ExternalBaseSessionGuard
{
protected LdapService $ldapService;
protected $ldapService;
/**
* LdapSessionGuard constructor.
@@ -60,9 +59,8 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
* @param array $credentials
* @param bool $remember
*
* @throws LdapException*@throws \BookStack\Exceptions\JsonDebugException
* @throws LoginAttemptException
* @throws JsonDebugException
* @throws LdapException
*
* @return bool
*/
@@ -86,7 +84,7 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
try {
$user = $this->createNewFromLdapAndCreds($userDetails, $credentials);
} catch (UserRegistrationException $exception) {
throw new LoginAttemptException($exception->getMessage());
throw new LoginAttemptException($exception->message);
}
}

View File

@@ -15,17 +15,12 @@ use Illuminate\Support\Facades\Log;
*/
class LdapService
{
protected Ldap $ldap;
protected GroupSyncService $groupSyncService;
protected UserAvatars $userAvatars;
/**
* @var resource
*/
protected $ldap;
protected $groupSyncService;
protected $ldapConnection;
protected array $config;
protected bool $enabled;
protected $userAvatars;
protected $config;
protected $enabled;
/**
* LdapService constructor.
@@ -279,7 +274,6 @@ class LdapService
* Get the groups a user is a part of on ldap.
*
* @throws LdapException
* @throws JsonDebugException
*/
public function getUserGroups(string $userName): array
{
@@ -291,17 +285,8 @@ class LdapService
}
$userGroups = $this->groupFilter($user);
$allGroups = $this->getGroupsRecursive($userGroups, []);
if ($this->config['dump_user_groups']) {
throw new JsonDebugException([
'details_from_ldap' => $user,
'parsed_direct_user_groups' => $userGroups,
'parsed_recursive_user_groups' => $allGroups,
]);
}
return $allGroups;
return $this->getGroupsRecursive($userGroups, []);
}
/**
@@ -384,7 +369,6 @@ class LdapService
* Sync the LDAP groups to the user roles for the current user.
*
* @throws LdapException
* @throws JsonDebugException
*/
public function syncGroups(User $user, string $username)
{

View File

@@ -1,9 +0,0 @@
<?php
namespace BookStack\Auth\Access\Oidc;
use Exception;
class OidcException extends Exception
{
}

View File

@@ -2,8 +2,6 @@
namespace BookStack\Auth\Access\Oidc;
use Exception;
class OidcIssuerDiscoveryException extends Exception
class OidcIssuerDiscoveryException extends \Exception
{
}

View File

@@ -60,11 +60,8 @@ class OidcJwtSigningKey
*/
protected function loadFromJwkArray(array $jwk)
{
// 'alg' is optional for a JWK, but we will still attempt to validate if
// it exists otherwise presume it will be compatible.
$alg = $jwk['alg'] ?? null;
if ($jwk['kty'] !== 'RSA' || !(is_null($alg) || $alg === 'RS256')) {
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
if ($jwk['alg'] !== 'RS256') {
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$jwk['alg']}");
}
if (empty($jwk['use'])) {

View File

@@ -164,9 +164,7 @@ class OidcProviderSettings
protected function filterKeys(array $keys): array
{
return array_filter($keys, function (array $key) {
$alg = $key['alg'] ?? null;
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && (is_null($alg) || $alg === 'RS256');
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && $key['alg'] === 'RS256';
});
}

View File

@@ -7,12 +7,14 @@ use BookStack\Auth\Access\LoginService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\OpenIdConnectException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException;
use function config;
use Exception;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface as HttpClient;
use function trans;
use function url;
@@ -23,9 +25,9 @@ use function url;
*/
class OidcService
{
protected RegistrationService $registrationService;
protected LoginService $loginService;
protected HttpClient $httpClient;
protected $registrationService;
protected $loginService;
protected $httpClient;
/**
* OpenIdService constructor.
@@ -40,8 +42,6 @@ class OidcService
/**
* Initiate an authorization flow.
*
* @throws OidcException
*
* @return array{url: string, state: string}
*/
public function login(): array
@@ -57,15 +57,14 @@ class OidcService
/**
* Process the Authorization response from the authorization server and
* return the matching, or new if registration active, user matched to the
* authorization server. Throws if the user cannot be auth if not authenticated.
* return the matching, or new if registration active, user matched to
* the authorization server.
* Returns null if not authenticated.
*
* @throws JsonDebugException
* @throws OidcException
* @throws StoppedAuthenticationException
* @throws IdentityProviderException
* @throws Exception
* @throws ClientExceptionInterface
*/
public function processAuthorizeResponse(?string $authorizationCode): User
public function processAuthorizeResponse(?string $authorizationCode): ?User
{
$settings = $this->getProviderSettings();
$provider = $this->getProvider($settings);
@@ -79,7 +78,8 @@ class OidcService
}
/**
* @throws OidcException
* @throws OidcIssuerDiscoveryException
* @throws ClientExceptionInterface
*/
protected function getProviderSettings(): OidcProviderSettings
{
@@ -100,11 +100,7 @@ class OidcService
// Run discovery
if ($config['discover'] ?? false) {
try {
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
} catch (OidcIssuerDiscoveryException $exception) {
throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
}
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
}
$settings->validate();
@@ -165,8 +161,9 @@ class OidcService
* Processes a received access token for a user. Login the user when
* they exist, optionally registering them automatically.
*
* @throws OidcException
* @throws OpenIdConnectException
* @throws JsonDebugException
* @throws UserRegistrationException
* @throws StoppedAuthenticationException
*/
protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User
@@ -185,28 +182,28 @@ class OidcService
try {
$idToken->validate($settings->clientId);
} catch (OidcInvalidTokenException $exception) {
throw new OidcException("ID token validate failed with error: {$exception->getMessage()}");
throw new OpenIdConnectException("ID token validate failed with error: {$exception->getMessage()}");
}
$userDetails = $this->getUserDetails($idToken);
$isLoggedIn = auth()->check();
if (empty($userDetails['email'])) {
throw new OidcException(trans('errors.oidc_no_email_address'));
throw new OpenIdConnectException(trans('errors.oidc_no_email_address'));
}
if ($isLoggedIn) {
throw new OidcException(trans('errors.oidc_already_logged_in'));
throw new OpenIdConnectException(trans('errors.oidc_already_logged_in'), '/login');
}
try {
$user = $this->registrationService->findOrRegister(
$userDetails['name'],
$userDetails['email'],
$userDetails['external_id']
);
} catch (UserRegistrationException $exception) {
throw new OidcException($exception->getMessage());
$user = $this->registrationService->findOrRegister(
$userDetails['name'],
$userDetails['email'],
$userDetails['external_id']
);
if ($user === null) {
throw new OpenIdConnectException(trans('errors.oidc_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
}
$this->loginService->login($user, 'oidc');

View File

@@ -96,8 +96,7 @@ class RegistrationService
}
// Create the user
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
$newUser->attachDefaultRole();
$newUser = $this->userRepo->registerNew($userData, $emailConfirmed);
// Assign social account if given
if ($socialAccount) {

View File

@@ -1,39 +0,0 @@
<?php
namespace BookStack\Auth\Queries;
use BookStack\Auth\User;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* Get all the users with their permissions in a paginated format.
* Note: Due to the use of email search this should only be used when
* user is assumed to be trusted. (Admin users).
* Email search can be abused to extract email addresses.
*/
class AllUsersPaginatedAndSorted
{
/**
* @param array{sort: string, order: string, search: string} $sortData
*/
public function run(int $count, array $sortData): LengthAwarePaginator
{
$sort = $sortData['sort'];
$query = User::query()->select(['*'])
->scopes(['withLastActivityAt'])
->with(['roles', 'avatar'])
->withCount('mfaValues')
->orderBy($sort, $sortData['order']);
if ($sortData['search']) {
$term = '%' . $sortData['search'] . '%';
$query->where(function ($query) use ($term) {
$query->where('name', 'like', $term)
->orWhere('email', 'like', $term);
});
}
return $query->paginate($count);
}
}

View File

@@ -1,30 +0,0 @@
<?php
namespace BookStack\Auth\Queries;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
/**
* Get asset created counts for the given user.
*/
class UserContentCounts
{
/**
* @return array{pages: int, chapters: int, books: int, shelves: int}
*/
public function run(User $user): array
{
$createdBy = ['created_by' => $user->id];
return [
'pages' => Page::visible()->where($createdBy)->count(),
'chapters' => Chapter::visible()->where($createdBy)->count(),
'books' => Book::visible()->where($createdBy)->count(),
'shelves' => Bookshelf::visible()->where($createdBy)->count(),
];
}
}

View File

@@ -1,37 +0,0 @@
<?php
namespace BookStack\Auth\Queries;
use BookStack\Auth\User;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
/**
* Get the recently created content for the provided user.
*/
class UserRecentlyCreatedContent
{
/**
* @return array{pages: Collection, chapters: Collection, books: Collection, shelves: Collection}
*/
public function run(User $user, int $count): array
{
$query = function (Builder $query) use ($user, $count) {
return $query->orderBy('created_at', 'desc')
->where('created_by', '=', $user->id)
->take($count)
->get();
};
return [
'pages' => $query(Page::visible()->where('draft', '=', false)),
'chapters' => $query(Chapter::visible()),
'books' => $query(Book::visible()),
'shelves' => $query(Bookshelf::visible()),
];
}
}

View File

@@ -28,8 +28,6 @@ class Role extends Model implements Loggable
protected $fillable = ['display_name', 'description', 'external_auth_id'];
protected $hidden = ['pivot'];
/**
* The roles that belong to the role.
*/

View File

@@ -72,20 +72,22 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
protected $hidden = [
'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
'created_at', 'updated_at', 'image_id', 'roles', 'avatar', 'user_id',
'created_at', 'updated_at', 'image_id',
];
/**
* This holds the user's permissions when loaded.
*
* @var ?Collection
*/
protected ?Collection $permissions;
protected $permissions;
/**
* This holds the default user when loaded.
*
* @var null|User
*/
protected static ?User $defaultUser = null;
protected static $defaultUser = null;
/**
* Returns the default public user.
@@ -144,7 +146,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function attachDefaultRole(): void
{
$roleId = intval(setting('registration-role'));
$roleId = setting('registration-role');
if ($roleId && $this->roles()->where('id', '=', $roleId)->count() === 0) {
$this->roles()->attach($roleId);
}

View File

@@ -2,29 +2,30 @@
namespace BookStack\Auth;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\UserInviteService;
use BookStack\Entities\EntityProvider;
use BookStack\Exceptions\NotifyException;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Facades\Activity;
use BookStack\Uploads\UserAvatars;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class UserRepo
{
protected UserAvatars $userAvatar;
protected UserInviteService $inviteService;
protected $userAvatar;
/**
* UserRepo constructor.
*/
public function __construct(UserAvatars $userAvatar, UserInviteService $inviteService)
public function __construct(UserAvatars $userAvatar)
{
$this->userAvatar = $userAvatar;
$this->inviteService = $inviteService;
}
/**
@@ -52,164 +53,70 @@ class UserRepo
}
/**
* Create a new basic instance of user with the given pre-validated data.
*
* @param array{name: string, email: string, password: ?string, external_auth_id: ?string, language: ?string, roles: ?array} $data
* Get all the users with their permissions.
*/
public function createWithoutActivity(array $data, bool $emailConfirmed = false): User
public function getAllUsers(): Collection
{
$user = new User();
$user->name = $data['name'];
$user->email = $data['email'];
$user->password = bcrypt(empty($data['password']) ? Str::random(32) : $data['password']);
$user->email_confirmed = $emailConfirmed;
$user->external_auth_id = $data['external_auth_id'] ?? '';
return User::query()->with('roles', 'avatar')->orderBy('name', 'asc')->get();
}
$user->refreshSlug();
$user->save();
/**
* Get all the users with their permissions in a paginated format.
* Note: Due to the use of email search this should only be used when
* user is assumed to be trusted. (Admin users).
* Email search can be abused to extract email addresses.
*/
public function getAllUsersPaginatedAndSorted(int $count, array $sortData): LengthAwarePaginator
{
$sort = $sortData['sort'];
if (!empty($data['language'])) {
setting()->putUser($user, 'language', $data['language']);
$query = User::query()->select(['*'])
->scopes(['withLastActivityAt'])
->with(['roles', 'avatar'])
->withCount('mfaValues')
->orderBy($sort, $sortData['order']);
if ($sortData['search']) {
$term = '%' . $sortData['search'] . '%';
$query->where(function ($query) use ($term) {
$query->where('name', 'like', $term)
->orWhere('email', 'like', $term);
});
}
if (isset($data['roles'])) {
$this->setUserRoles($user, $data['roles']);
}
return $query->paginate($count);
}
/**
* Creates a new user and attaches a role to them.
*/
public function registerNew(array $data, bool $emailConfirmed = false): User
{
$user = $this->create($data, $emailConfirmed);
$user->attachDefaultRole();
$this->downloadAndAssignUserAvatar($user);
return $user;
}
/**
* As per "createWithoutActivity" but records a "create" activity.
* Assign a user to a system-level role.
*
* @param array{name: string, email: string, password: ?string, external_auth_id: ?string, language: ?string, roles: ?array} $data
* @throws NotFoundException
*/
public function create(array $data, bool $sendInvite = false): User
public function attachSystemRole(User $user, string $systemRoleName)
{
$user = $this->createWithoutActivity($data, true);
if ($sendInvite) {
$this->inviteService->sendInvitation($user);
}
Activity::add(ActivityType::USER_CREATE, $user);
return $user;
}
/**
* Update the given user with the given data.
*
* @param array{name: ?string, email: ?string, external_auth_id: ?string, password: ?string, roles: ?array<int>, language: ?string} $data
*
* @throws UserUpdateException
*/
public function update(User $user, array $data, bool $manageUsersAllowed): User
{
if (!empty($data['name'])) {
$user->name = $data['name'];
$user->refreshSlug();
}
if (!empty($data['email']) && $manageUsersAllowed) {
$user->email = $data['email'];
}
if (!empty($data['external_auth_id']) && $manageUsersAllowed) {
$user->external_auth_id = $data['external_auth_id'];
}
if (isset($data['roles']) && $manageUsersAllowed) {
$this->setUserRoles($user, $data['roles']);
}
if (!empty($data['password'])) {
$user->password = bcrypt($data['password']);
}
if (!empty($data['language'])) {
setting()->putUser($user, 'language', $data['language']);
}
$user->save();
Activity::add(ActivityType::USER_UPDATE, $user);
return $user;
}
/**
* Remove the given user from storage, Delete all related content.
*
* @throws Exception
*/
public function destroy(User $user, ?int $newOwnerId = null)
{
$this->ensureDeletable($user);
$user->socialAccounts()->delete();
$user->apiTokens()->delete();
$user->favourites()->delete();
$user->mfaValues()->delete();
$user->delete();
// Delete user profile images
$this->userAvatar->destroyAllForUser($user);
if (!empty($newOwnerId)) {
$newOwner = User::query()->find($newOwnerId);
if (!is_null($newOwner)) {
$this->migrateOwnership($user, $newOwner);
}
}
Activity::add(ActivityType::USER_DELETE, $user);
}
/**
* @throws NotifyException
*/
protected function ensureDeletable(User $user): void
{
if ($this->isOnlyAdmin($user)) {
throw new NotifyException(trans('errors.users_cannot_delete_only_admin'), $user->getEditUrl());
}
if ($user->system_name === 'public') {
throw new NotifyException(trans('errors.users_cannot_delete_guest'), $user->getEditUrl());
}
}
/**
* Migrate ownership of items in the system from one user to another.
*/
protected function migrateOwnership(User $fromUser, User $toUser)
{
$entities = (new EntityProvider())->all();
foreach ($entities as $instance) {
$instance->newQuery()->where('owned_by', '=', $fromUser->id)
->update(['owned_by' => $toUser->id]);
}
}
/**
* Get an avatar image for a user and set it as their avatar.
* Returns early if avatars disabled or not set in config.
*/
protected function downloadAndAssignUserAvatar(User $user): void
{
try {
$this->userAvatar->fetchAndAssignToUser($user);
} catch (Exception $e) {
Log::error('Failed to save user avatar image');
$role = Role::getSystemRole($systemRoleName);
if (is_null($role)) {
throw new NotFoundException("Role '{$systemRoleName}' not found");
}
$user->attachRole($role);
}
/**
* Checks if the give user is the only admin.
*/
protected function isOnlyAdmin(User $user): bool
public function isOnlyAdmin(User $user): bool
{
if (!$user->hasSystemRole('admin')) {
return false;
@@ -228,7 +135,7 @@ class UserRepo
*
* @throws UserUpdateException
*/
protected function setUserRoles(User $user, array $roles)
public function setUserRoles(User $user, array $roles)
{
if ($this->demotingLastAdmin($user, $roles)) {
throw new UserUpdateException(trans('errors.role_cannot_remove_only_admin'), $user->getEditUrl());
@@ -252,4 +159,117 @@ class UserRepo
return false;
}
/**
* Create a new basic instance of user.
*/
public function create(array $data, bool $emailConfirmed = false): User
{
$details = [
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
'email_confirmed' => $emailConfirmed,
'external_auth_id' => $data['external_auth_id'] ?? '',
];
$user = new User();
$user->forceFill($details);
$user->refreshSlug();
$user->save();
return $user;
}
/**
* Remove the given user from storage, Delete all related content.
*
* @throws Exception
*/
public function destroy(User $user, ?int $newOwnerId = null)
{
$user->socialAccounts()->delete();
$user->apiTokens()->delete();
$user->favourites()->delete();
$user->mfaValues()->delete();
$user->delete();
// Delete user profile images
$this->userAvatar->destroyAllForUser($user);
if (!empty($newOwnerId)) {
$newOwner = User::query()->find($newOwnerId);
if (!is_null($newOwner)) {
$this->migrateOwnership($user, $newOwner);
}
}
}
/**
* Migrate ownership of items in the system from one user to another.
*/
protected function migrateOwnership(User $fromUser, User $toUser)
{
$entities = (new EntityProvider())->all();
foreach ($entities as $instance) {
$instance->newQuery()->where('owned_by', '=', $fromUser->id)
->update(['owned_by' => $toUser->id]);
}
}
/**
* Get the recently created content for this given user.
*/
public function getRecentlyCreated(User $user, int $count = 20): array
{
$query = function (Builder $query) use ($user, $count) {
return $query->orderBy('created_at', 'desc')
->where('created_by', '=', $user->id)
->take($count)
->get();
};
return [
'pages' => $query(Page::visible()->where('draft', '=', false)),
'chapters' => $query(Chapter::visible()),
'books' => $query(Book::visible()),
'shelves' => $query(Bookshelf::visible()),
];
}
/**
* Get asset created counts for the give user.
*/
public function getAssetCounts(User $user): array
{
$createdBy = ['created_by' => $user->id];
return [
'pages' => Page::visible()->where($createdBy)->count(),
'chapters' => Chapter::visible()->where($createdBy)->count(),
'books' => Book::visible()->where($createdBy)->count(),
'shelves' => Bookshelf::visible()->where($createdBy)->count(),
];
}
/**
* Get the roles in the system that are assignable to a user.
*/
public function getAllRoles(): Collection
{
return Role::query()->orderBy('display_name', 'asc')->get();
}
/**
* Get an avatar image for a user and set it as their avatar.
* Returns early if avatars disabled or not set in config.
*/
public function downloadAndAssignUserAvatar(User $user): void
{
try {
$this->userAvatar->fetchAndAssignToUser($user);
} catch (Exception $e) {
Log::error('Failed to save user avatar image');
}
}
}

View File

@@ -57,13 +57,6 @@ return [
// Space separated if multiple. BookStack host domain is auto-inferred.
'iframe_hosts' => env('ALLOWED_IFRAME_HOSTS', null),
// A list of sources/hostnames that can be loaded within iframes within BookStack.
// Space separated if multiple. BookStack host domain is auto-inferred.
// Can be set to a lone "*" to allow all sources for iframe content (Not advised).
// Defaults to a set of common services.
// Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'),
// Application timezone for back-end date functions.
'timezone' => env('APP_TIMEZONE', 'UTC'),
@@ -71,7 +64,7 @@ return [
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'],
// Application Fallback Locale
'fallback_locale' => 'en',

View File

@@ -7,10 +7,6 @@
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
$dompdfPaperSizeMap = [
'a4' => 'a4',
'letter' => 'letter',
];
return [
@@ -154,7 +150,7 @@ return [
*
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
*/
'default_paper_size' => $dompdfPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'a4',
'default_paper_size' => 'a4',
/**
* The default font family.

View File

@@ -119,7 +119,6 @@ return [
'ldap' => [
'server' => env('LDAP_SERVER', false),
'dump_user_details' => env('LDAP_DUMP_USER_DETAILS', false),
'dump_user_groups' => env('LDAP_DUMP_USER_GROUPS', false),
'dn' => env('LDAP_DN', false),
'pass' => env('LDAP_PASS', false),
'base_dn' => env('LDAP_BASE_DN', false),

View File

@@ -72,7 +72,7 @@ return [
// to the server if the browser has a HTTPS connection. This will keep
// the cookie from being sent to you if it can not be done securely.
'secure' => env('SESSION_SECURE_COOKIE', null)
?? Str::startsWith(env('APP_URL', ''), 'https:'),
?? Str::startsWith(env('APP_URL'), 'https:'),
// HTTP Access Only
// Setting this value to true will prevent JavaScript from accessing the

View File

@@ -7,10 +7,6 @@
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
$snappyPaperSizeMap = [
'a4' => 'A4',
'letter' => 'Letter',
];
return [
'pdf' => [
@@ -18,8 +14,7 @@ return [
'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
'timeout' => false,
'options' => [
'outline' => true,
'page-size' => $snappyPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'A4',
'outline' => true,
],
'env' => [],
],

View File

@@ -2,12 +2,9 @@
namespace BookStack\Console\Commands;
use BookStack\Auth\Role;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\NotFoundException;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\Rules\Unique;
use Symfony\Component\Console\Command\Command as SymfonyCommand;
@@ -22,8 +19,7 @@ class CreateAdmin extends Command
protected $signature = 'bookstack:create-admin
{--email= : The email address for the new admin user}
{--name= : The name of the new admin user}
{--password= : The password to assign to the new admin user}
{--external-auth-id= : The external authentication system id for the new admin user (SAML2/LDAP/OIDC)}';
{--password= : The password to assign to the new admin user}';
/**
* The console command description.
@@ -46,35 +42,28 @@ class CreateAdmin extends Command
/**
* Execute the console command.
*
* @throws NotFoundException
* @throws \BookStack\Exceptions\NotFoundException
*
* @return mixed
*/
public function handle()
{
$details = $this->snakeCaseOptions();
$details = $this->options();
if (empty($details['email'])) {
$details['email'] = $this->ask('Please specify an email address for the new admin user');
}
if (empty($details['name'])) {
$details['name'] = $this->ask('Please specify a name for the new admin user');
}
if (empty($details['password'])) {
if (empty($details['external_auth_id'])) {
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
} else {
$details['password'] = Str::random(32);
}
$details['password'] = $this->ask('Please specify a password for the new admin user (8 characters min)');
}
$validator = Validator::make($details, [
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
'name' => ['required', 'min:2'],
'password' => ['required_without:external_auth_id', Password::default()],
'external_auth_id' => ['required_without:password'],
'email' => ['required', 'email', 'min:5', new Unique('users', 'email')],
'name' => ['required', 'min:2'],
'password' => ['required', Password::default()],
]);
if ($validator->fails()) {
@@ -85,8 +74,9 @@ class CreateAdmin extends Command
return SymfonyCommand::FAILURE;
}
$user = $this->userRepo->createWithoutActivity($validator->validated());
$user->attachRole(Role::getSystemRole('admin'));
$user = $this->userRepo->create($validator->validated());
$this->userRepo->attachSystemRole($user, 'admin');
$this->userRepo->downloadAndAssignUserAvatar($user);
$user->email_confirmed = true;
$user->save();
@@ -94,14 +84,4 @@ class CreateAdmin extends Command
return SymfonyCommand::SUCCESS;
}
protected function snakeCaseOptions(): array
{
$returnOpts = [];
foreach ($this->options() as $key => $value) {
$returnOpts[str_replace('-', '_', $key)] = $value;
}
return $returnOpts;
}
}

View File

@@ -15,6 +15,8 @@ class DeleteUsers extends Command
*/
protected $signature = 'bookstack:delete-users';
protected $user;
protected $userRepo;
/**
@@ -24,8 +26,9 @@ class DeleteUsers extends Command
*/
protected $description = 'Delete users that are not "admin" or system users';
public function __construct(UserRepo $userRepo)
public function __construct(User $user, UserRepo $userRepo)
{
$this->user = $user;
$this->userRepo = $userRepo;
parent::__construct();
}
@@ -35,8 +38,8 @@ class DeleteUsers extends Command
$confirm = $this->ask('This will delete all users from the system that are not "admin" or system users. Are you sure you want to continue? (Type "yes" to continue)');
$numDeleted = 0;
if (strtolower(trim($confirm)) === 'yes') {
$totalUsers = User::query()->count();
$users = User::query()->whereNull('system_name')->with('roles')->get();
$totalUsers = $this->user->count();
$users = $this->user->where('system_name', '=', null)->with('roles')->get();
foreach ($users as $user) {
if ($user->hasSystemRole('admin')) {
// don't delete users with "admin" role

View File

@@ -10,16 +10,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
/**
* @property int $id
* @property int $deleted_by
* @property string $deletable_type
* @property int $deletable_id
* @property Deletable $deletable
*/
class Deletion extends Model implements Loggable
{
protected $hidden = [];
/**
* Get the related deletable record.
*/

View File

@@ -10,23 +10,19 @@ use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* Class Page.
*
* @property int $chapter_id
* @property string $html
* @property string $markdown
* @property string $text
* @property bool $template
* @property bool $draft
* @property int $revision_count
* @property string $editor
* @property Chapter $chapter
* @property Collection $attachments
* @property Collection $revisions
* @property PageRevision $currentRevision
* @property int $chapter_id
* @property string $html
* @property string $markdown
* @property string $text
* @property bool $template
* @property bool $draft
* @property int $revision_count
* @property Chapter $chapter
* @property Collection $attachments
*/
class Page extends BookChild
{
@@ -86,19 +82,6 @@ class Page extends BookChild
->orderBy('id', 'desc');
}
/**
* Get the current revision for the page if existing.
*
* @return PageRevision|null
*/
public function currentRevision(): HasOne
{
return $this->hasOne(PageRevision::class)
->where('type', '=', 'version')
->orderBy('created_at', 'desc')
->orderBy('id', 'desc');
}
/**
* Get all revision instances assigned to this page.
* Includes all types of revisions.
@@ -134,6 +117,16 @@ class Page extends BookChild
return url('/' . implode('/', $parts));
}
/**
* Get the current revision for the page if existing.
*
* @return PageRevision|null
*/
public function getCurrentRevision()
{
return $this->revisions()->first();
}
/**
* Get this page for JSON display.
*/

View File

@@ -10,9 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Class PageRevision.
*
* @property mixed $id
* @property int $page_id
* @property string $name
* @property string $slug
* @property string $book_slug
* @property int $created_by
@@ -22,15 +20,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string $summary
* @property string $markdown
* @property string $html
* @property string $text
* @property int $revision_number
* @property Page $page
* @property-read ?User $createdBy
*/
class PageRevision extends Model
{
protected $fillable = ['name', 'text', 'summary'];
protected $hidden = ['html', 'markdown', 'restricted', 'text'];
protected $fillable = ['name', 'html', 'text', 'markdown', 'summary'];
/**
* Get the user that created the page revision.

View File

@@ -11,8 +11,8 @@ use Illuminate\Http\UploadedFile;
class BaseRepo
{
protected TagRepo $tagRepo;
protected ImageRepo $imageRepo;
protected $tagRepo;
protected $imageRepo;
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
{
@@ -58,7 +58,6 @@ class BaseRepo
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($entity, $input['tags']);
$entity->touch();
}
$entity->rebuildPermissions();

View File

@@ -1,36 +0,0 @@
<?php
namespace BookStack\Entities\Repos;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Deletion;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Facades\Activity;
class DeletionRepo
{
private TrashCan $trashCan;
public function __construct(TrashCan $trashCan)
{
$this->trashCan = $trashCan;
}
public function restore(int $id): int
{
/** @var Deletion $deletion */
$deletion = Deletion::query()->findOrFail($id);
Activity::add(ActivityType::RECYCLE_BIN_RESTORE, $deletion);
return $this->trashCan->restoreFromDeletion($deletion);
}
public function destroy(int $id): int
{
/** @var Deletion $deletion */
$deletion = Deletion::query()->findOrFail($id);
Activity::add(ActivityType::RECYCLE_BIN_DESTROY, $deletion);
return $this->trashCan->destroyFromDeletion($deletion);
}
}

View File

@@ -10,7 +10,6 @@ use BookStack\Entities\Models\Page;
use BookStack\Entities\Models\PageRevision;
use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditorData;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
@@ -218,25 +217,11 @@ class PageRepo
}
$pageContent = new PageContent($page);
$currentEditor = $page->editor ?: PageEditorData::getSystemDefaultEditor();
$newEditor = $currentEditor;
$haveInput = isset($input['markdown']) || isset($input['html']);
$inputEmpty = empty($input['markdown']) && empty($input['html']);
if ($haveInput && $inputEmpty) {
$pageContent->setNewHTML('');
} elseif (!empty($input['markdown']) && is_string($input['markdown'])) {
$newEditor = 'markdown';
if (!empty($input['markdown'] ?? '')) {
$pageContent->setNewMarkdown($input['markdown']);
} elseif (isset($input['html'])) {
$newEditor = 'wysiwyg';
$pageContent->setNewHTML($input['html']);
}
if ($newEditor !== $currentEditor && userCan('editor-change')) {
$page->editor = $newEditor;
}
}
/**
@@ -244,12 +229,8 @@ class PageRepo
*/
protected function savePageRevision(Page $page, string $summary = null): PageRevision
{
$revision = new PageRevision();
$revision = new PageRevision($page->getAttributes());
$revision->name = $page->name;
$revision->html = $page->html;
$revision->markdown = $page->markdown;
$revision->text = $page->text;
$revision->page_id = $page->id;
$revision->slug = $page->slug;
$revision->book_slug = $page->book->slug;
@@ -279,15 +260,10 @@ class PageRepo
return $page;
}
// Otherwise, save the data to a revision
// Otherwise save the data to a revision
$draft = $this->getPageRevisionToUpdate($page);
$draft->fill($input);
if (!empty($input['markdown'])) {
$draft->markdown = $input['markdown'];
$draft->html = '';
} else {
$draft->html = $input['html'];
if (setting('app-editor') !== 'markdown') {
$draft->markdown = '';
}

View File

@@ -7,7 +7,6 @@ use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
use BookStack\Uploads\ImageService;
use BookStack\Util\CspService;
use DOMDocument;
use DOMElement;
use DOMXPath;
@@ -16,18 +15,16 @@ use Throwable;
class ExportFormatter
{
protected ImageService $imageService;
protected PdfGenerator $pdfGenerator;
protected CspService $cspService;
protected $imageService;
protected $pdfGenerator;
/**
* ExportService constructor.
*/
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator, CspService $cspService)
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator)
{
$this->imageService = $imageService;
$this->pdfGenerator = $pdfGenerator;
$this->cspService = $cspService;
}
/**
@@ -40,9 +37,8 @@ class ExportFormatter
{
$page->html = (new PageContent($page))->render();
$pageHtml = view('pages.export', [
'page' => $page,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
'page' => $page,
'format' => 'html',
])->render();
return $this->containHtml($pageHtml);
@@ -60,10 +56,9 @@ class ExportFormatter
$page->html = (new PageContent($page))->render();
});
$html = view('chapters.export', [
'chapter' => $chapter,
'pages' => $pages,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
'chapter' => $chapter,
'pages' => $pages,
'format' => 'html',
])->render();
return $this->containHtml($html);
@@ -81,7 +76,6 @@ class ExportFormatter
'book' => $book,
'bookChildren' => $bookTree,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
])->render();
return $this->containHtml($html);
@@ -153,31 +147,10 @@ class ExportFormatter
{
$html = $this->containHtml($html);
$html = $this->replaceIframesWithLinks($html);
$html = $this->openDetailElements($html);
return $this->pdfGenerator->fromHtml($html);
}
/**
* Within the given HTML content, Open any detail blocks.
*/
protected function openDetailElements(string $html): string
{
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$details = $xPath->query('//details');
/** @var DOMElement $detail */
foreach ($details as $detail) {
$detail->setAttribute('open', 'open');
}
return $doc->saveHTML();
}
/**
* Within the given HTML content, replace any iframe elements
* with anchor links within paragraph blocks.
@@ -215,16 +188,14 @@ class ExportFormatter
*/
protected function containHtml(string $htmlContent): string
{
// Replace embed tags with images
$htmlContent = preg_replace("/<embed (.*?)>/i", '<img $1>', $htmlContent);
// Replace image & embed src attributes with base64 encoded data strings
$imageTagsOutput = [];
preg_match_all("/<img .*?src=['\"](.*?)['\"].*?>/i", $htmlContent, $imageTagsOutput);
preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
// Replace image src with base64 encoded image strings
if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
foreach ($imageTagsOutput[0] as $index => $imgMatch) {
$oldImgTagString = $imgMatch;
$srcString = $imageTagsOutput[1][$index];
$srcString = $imageTagsOutput[2][$index];
$imageEncoded = $this->imageService->imageUriToBase64($srcString);
if ($imageEncoded === null) {
$imageEncoded = $srcString;
@@ -234,13 +205,14 @@ class ExportFormatter
}
}
// Replace any relative links with full system URL
$linksOutput = [];
preg_match_all("/<a .*href=['\"](.*?)['\"].*?>/i", $htmlContent, $linksOutput);
preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $linksOutput);
// Replace image src with base64 encoded image strings
if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
foreach ($linksOutput[0] as $index => $linkMatch) {
$oldLinkString = $linkMatch;
$srcString = $linksOutput[1][$index];
$srcString = $linksOutput[2][$index];
if (strpos(trim($srcString), 'http') !== 0) {
$newSrcString = url($srcString);
$newLinkString = str_replace($srcString, $newSrcString, $oldLinkString);
@@ -249,6 +221,7 @@ class ExportFormatter
}
}
// Replace any relative links with system domain
return $htmlContent;
}
@@ -326,7 +299,7 @@ class ExportFormatter
$text .= $this->pageToMarkdown($page) . "\n\n";
}
return trim($text);
return $text;
}
/**
@@ -338,12 +311,12 @@ class ExportFormatter
$text = '# ' . $book->name . "\n\n";
foreach ($bookTree as $bookChild) {
if ($bookChild instanceof Chapter) {
$text .= $this->chapterToMarkdown($bookChild) . "\n\n";
$text .= $this->chapterToMarkdown($bookChild);
} else {
$text .= $this->pageToMarkdown($bookChild) . "\n\n";
$text .= $this->pageToMarkdown($bookChild);
}
}
return trim($text);
return $text;
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace BookStack\Entities\Tools\Markdown;
use League\HTMLToMarkdown\Converter\ConverterInterface;
use League\HTMLToMarkdown\ElementInterface;
class CheckboxConverter implements ConverterInterface
{
public function convert(ElementInterface $element): string
{
if (strtolower($element->getAttribute('type')) === 'checkbox') {
$isChecked = $element->getAttribute('checked') === 'checked';
return $isChecked ? ' [x] ' : ' [ ] ';
}
return $element->getValue();
}
/**
* @return string[]
*/
public function getSupportedTags(): array
{
return ['input'];
}
}

View File

@@ -1,20 +0,0 @@
<?php
namespace BookStack\Entities\Tools\Markdown;
use League\HTMLToMarkdown\Converter\DivConverter;
use League\HTMLToMarkdown\ElementInterface;
class CustomDivConverter extends DivConverter
{
public function convert(ElementInterface $element): string
{
// Clean up draw.io diagrams
$drawIoDiagram = $element->getAttribute('drawio-diagram');
if ($drawIoDiagram) {
return "<div drawio-diagram=\"{$drawIoDiagram}\">{$element->getValue()}</div>\n\n";
}
return parent::convert($element);
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace BookStack\Entities\Tools\Markdown;
use League\HTMLToMarkdown\Converter\ImageConverter;
use League\HTMLToMarkdown\ElementInterface;
class CustomImageConverter extends ImageConverter
{
public function convert(ElementInterface $element): string
{
$parent = $element->getParent();
// Remain as HTML if within diagram block.
$withinDrawing = $parent && !empty($parent->getAttribute('drawio-diagram'));
if ($withinDrawing) {
$src = e($element->getAttribute('src'));
$alt = e($element->getAttribute('alt'));
return "<img src=\"{$src}\" alt=\"{$alt}\"/>";
}
return parent::convert($element);
}
}

View File

@@ -9,7 +9,7 @@ class CustomParagraphConverter extends ParagraphConverter
{
public function convert(ElementInterface $element): string
{
$class = e($element->getAttribute('class'));
$class = $element->getAttribute('class');
if (strpos($class, 'callout') !== false) {
return "<{$element->getTagName()} class=\"{$class}\">{$element->getValue()}</{$element->getTagName()}>\n\n";
}

View File

@@ -5,10 +5,12 @@ namespace BookStack\Entities\Tools\Markdown;
use League\HTMLToMarkdown\Converter\BlockquoteConverter;
use League\HTMLToMarkdown\Converter\CodeConverter;
use League\HTMLToMarkdown\Converter\CommentConverter;
use League\HTMLToMarkdown\Converter\DivConverter;
use League\HTMLToMarkdown\Converter\EmphasisConverter;
use League\HTMLToMarkdown\Converter\HardBreakConverter;
use League\HTMLToMarkdown\Converter\HeaderConverter;
use League\HTMLToMarkdown\Converter\HorizontalRuleConverter;
use League\HTMLToMarkdown\Converter\ImageConverter;
use League\HTMLToMarkdown\Converter\LinkConverter;
use League\HTMLToMarkdown\Converter\ListBlockConverter;
use League\HTMLToMarkdown\Converter\ListItemConverter;
@@ -19,7 +21,7 @@ use League\HTMLToMarkdown\HtmlConverter;
class HtmlToMarkdown
{
protected string $html;
protected $html;
public function __construct(string $html)
{
@@ -73,20 +75,18 @@ class HtmlToMarkdown
$environment->addConverter(new BlockquoteConverter());
$environment->addConverter(new CodeConverter());
$environment->addConverter(new CommentConverter());
$environment->addConverter(new CustomDivConverter());
$environment->addConverter(new DivConverter());
$environment->addConverter(new EmphasisConverter());
$environment->addConverter(new HardBreakConverter());
$environment->addConverter(new HeaderConverter());
$environment->addConverter(new HorizontalRuleConverter());
$environment->addConverter(new CustomImageConverter());
$environment->addConverter(new ImageConverter());
$environment->addConverter(new LinkConverter());
$environment->addConverter(new ListBlockConverter());
$environment->addConverter(new ListItemConverter());
$environment->addConverter(new CustomParagraphConverter());
$environment->addConverter(new PreformattedConverter());
$environment->addConverter(new TextConverter());
$environment->addConverter(new CheckboxConverter());
$environment->addConverter(new SpacedTagFallbackConverter());
return $environment;
}

View File

@@ -1,35 +0,0 @@
<?php
namespace BookStack\Entities\Tools\Markdown;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension;
class MarkdownToHtml
{
protected string $markdown;
public function __construct(string $markdown)
{
$this->markdown = $markdown;
}
public function convert(): string
{
$environment = Environment::createCommonMarkEnvironment();
$environment->addExtension(new TableExtension());
$environment->addExtension(new TaskListExtension());
$environment->addExtension(new CustomStrikeThroughExtension());
$environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment;
$converter = new CommonMarkConverter([], $environment);
$environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);
return $converter->convertToHtml($this->markdown);
}
}

View File

@@ -1,23 +0,0 @@
<?php
namespace BookStack\Entities\Tools\Markdown;
use League\HTMLToMarkdown\Converter\ConverterInterface;
use League\HTMLToMarkdown\ElementInterface;
/**
* For certain defined tags, add additional spacing upon the retained HTML content
* to separate it out from anything that may be markdown soon afterwards or within.
*/
class SpacedTagFallbackConverter implements ConverterInterface
{
public function convert(ElementInterface $element): string
{
return \html_entity_decode($element->getChildrenAsString()) . "\n\n";
}
public function getSupportedTags(): array
{
return ['summary', 'iframe'];
}
}

View File

@@ -3,8 +3,11 @@
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
use BookStack\Entities\Tools\Markdown\CustomListItemRenderer;
use BookStack\Entities\Tools\Markdown\CustomStrikeThroughExtension;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;
use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageService;
use BookStack\Util\HtmlContentFilter;
@@ -14,10 +17,15 @@ use DOMNode;
use DOMNodeList;
use DOMXPath;
use Illuminate\Support\Str;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment;
use League\CommonMark\Extension\Table\TableExtension;
use League\CommonMark\Extension\TaskList\TaskListExtension;
class PageContent
{
protected Page $page;
protected $page;
/**
* PageContent constructor.
@@ -45,11 +53,28 @@ class PageContent
{
$markdown = $this->extractBase64ImagesFromMarkdown($markdown);
$this->page->markdown = $markdown;
$html = (new MarkdownToHtml($markdown))->convert();
$html = $this->markdownToHtml($markdown);
$this->page->html = $this->formatHtml($html);
$this->page->text = $this->toPlainText();
}
/**
* Convert the given Markdown content to a HTML string.
*/
protected function markdownToHtml(string $markdown): string
{
$environment = Environment::createCommonMarkEnvironment();
$environment->addExtension(new TableExtension());
$environment->addExtension(new TaskListExtension());
$environment->addExtension(new CustomStrikeThroughExtension());
$environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment;
$converter = new CommonMarkConverter([], $environment);
$environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);
return $converter->convertToHtml($markdown);
}
/**
* Convert all base64 image data to saved images.
*/
@@ -84,35 +109,15 @@ class PageContent
/**
* Convert all inline base64 content to uploaded image files.
* Regex is used to locate the start of data-uri definitions then
* manual looping over content is done to parse the whole data uri.
* Attempting to capture the whole data uri using regex can cause PHP
* PCRE limits to be hit with larger, multi-MB, files.
*/
protected function extractBase64ImagesFromMarkdown(string $markdown)
{
$matches = [];
$contentLength = strlen($markdown);
$replacements = [];
preg_match_all('/!\[.*?]\(.*?(data:image\/.{1,6};base64,)/', $markdown, $matches, PREG_OFFSET_CAPTURE);
preg_match_all('/!\[.*?]\(.*?(data:image\/.*?)[)"\s]/', $markdown, $matches);
foreach ($matches[1] as $base64MatchPair) {
[$dataUri, $index] = $base64MatchPair;
for ($i = strlen($dataUri) + $index; $i < $contentLength; $i++) {
$char = $markdown[$i];
if ($char === ')' || $char === ' ' || $char === "\n" || $char === '"') {
break;
}
$dataUri .= $char;
}
$newUrl = $this->base64ImageUriToUploadedImageUrl($dataUri);
$replacements[] = [$dataUri, $newUrl];
}
foreach ($replacements as [$dataUri, $newUrl]) {
$markdown = str_replace($dataUri, $newUrl, $markdown);
foreach ($matches[1] as $base64Match) {
$newUrl = $this->base64ImageUriToUploadedImageUrl($base64Match);
$markdown = str_replace($base64Match, $newUrl, $markdown);
}
return $markdown;
@@ -214,9 +219,6 @@ class PageContent
$html .= $doc->saveHTML($childNode);
}
// Perform required string-level tweaks
$html = str_replace(' ', '&nbsp;', $html);
return $html;
}

View File

@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Builder;
class PageEditActivity
{
protected Page $page;
protected $page;
/**
* PageEditActivity constructor.

View File

@@ -1,115 +0,0 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
class PageEditorData
{
protected Page $page;
protected PageRepo $pageRepo;
protected string $requestedEditor;
protected array $viewData;
protected array $warnings;
public function __construct(Page $page, PageRepo $pageRepo, string $requestedEditor)
{
$this->page = $page;
$this->pageRepo = $pageRepo;
$this->requestedEditor = $requestedEditor;
$this->viewData = $this->build();
}
public function getViewData(): array
{
return $this->viewData;
}
public function getWarnings(): array
{
return $this->warnings;
}
protected function build(): array
{
$page = clone $this->page;
$isDraft = boolval($this->page->draft);
$templates = $this->pageRepo->getTemplates(10);
$draftsEnabled = auth()->check();
$isDraftRevision = false;
$this->warnings = [];
$editActivity = new PageEditActivity($page);
if ($editActivity->hasActiveEditing()) {
$this->warnings[] = $editActivity->activeEditingMessage();
}
// Check for a current draft version for this user
$userDraft = $this->pageRepo->getUserDraft($page);
if ($userDraft !== null) {
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
$isDraftRevision = true;
$this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
}
$editorType = $this->getEditorType($page);
$this->updateContentForEditor($page, $editorType);
return [
'page' => $page,
'book' => $page->book,
'isDraft' => $isDraft,
'isDraftRevision' => $isDraftRevision,
'draftsEnabled' => $draftsEnabled,
'templates' => $templates,
'editor' => $editorType,
];
}
protected function updateContentForEditor(Page $page, string $editorType): void
{
$isHtml = !empty($page->html) && empty($page->markdown);
// HTML to markdown-clean conversion
if ($editorType === 'markdown' && $isHtml && $this->requestedEditor === 'markdown-clean') {
$page->markdown = (new HtmlToMarkdown($page->html))->convert();
}
// Markdown to HTML conversion if we don't have HTML
if ($editorType === 'wysiwyg' && !$isHtml) {
$page->html = (new MarkdownToHtml($page->markdown))->convert();
}
}
/**
* Get the type of editor to show for editing the given page.
* Defaults based upon the current content of the page otherwise will fall back
* to system default but will take a requested type (if provided) if permissions allow.
*/
protected function getEditorType(Page $page): string
{
$editorType = $page->editor ?: self::getSystemDefaultEditor();
// Use requested editor if valid and if we have permission
$requestedType = explode('-', $this->requestedEditor)[0];
if (($requestedType === 'markdown' || $requestedType === 'wysiwyg') && userCan('editor-change')) {
$editorType = $requestedType;
}
return $editorType;
}
/**
* Get the configured system default editor.
*/
public static function getSystemDefaultEditor(): string
{
return setting('app-editor') === 'markdown' ? 'markdown' : 'wysiwyg';
}
}

View File

@@ -360,7 +360,7 @@ class SearchRunner
/** @var Connection $connection */
$connection = $query->getConnection();
$tagValue = (float) trim($connection->getPdo()->quote($tagValue), "'");
$query->whereRaw("value {$tagOperator} {$tagValue}");
$query->whereRaw("value ${tagOperator} ${tagValue}");
} else {
$query->where('value', $tagOperator, $tagValue);
}

View File

@@ -101,10 +101,6 @@ class Handler extends ExceptionHandler
$code = $e->status;
}
if (method_exists($e, 'getStatus')) {
$code = $e->getStatus();
}
$responseData['error']['code'] = $code;
return new JsonResponse($responseData, $code, $headers);

View File

@@ -3,29 +3,24 @@
namespace BookStack\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
class JsonDebugException extends Exception
{
protected array $data;
protected $data;
/**
* JsonDebugException constructor.
*/
public function __construct(array $data)
public function __construct($data)
{
$this->data = $data;
parent::__construct();
}
/**
* Convert this exception into a response.
* We add a manual data conversion to UTF8 to ensure any binary data is presentable as a JSON string.
* Covert this exception into a response.
*/
public function render(): JsonResponse
public function render()
{
$cleaned = mb_convert_encoding($this->data, 'UTF-8');
return response()->json($cleaned);
return response()->json($this->data);
}
}

View File

@@ -9,24 +9,17 @@ class NotifyException extends Exception implements Responsable
{
public $message;
public $redirectLocation;
protected $status;
public function __construct(string $message, string $redirectLocation = '/', int $status = 500)
/**
* NotifyException constructor.
*/
public function __construct(string $message, string $redirectLocation = '/')
{
$this->message = $message;
$this->redirectLocation = $redirectLocation;
$this->status = $status;
parent::__construct();
}
/**
* Get the desired status code for this exception.
*/
public function getStatus(): int
{
return $this->status;
}
/**
* Send the response for this type of exception.
*
@@ -36,11 +29,6 @@ class NotifyException extends Exception implements Responsable
{
$message = $this->getMessage();
// Front-end JSON handling. API-side handling managed via handler.
if ($request->wantsJson()) {
return response()->json(['error' => $message], 403);
}
if (!empty($message)) {
session()->flash('error', $message);
}

View File

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

View File

@@ -15,14 +15,10 @@ abstract class ApiController extends Controller
* Provide a paginated listing JSON response in a standard format
* taking into account any pagination parameters passed by the user.
*/
protected function apiListingResponse(Builder $query, array $fields, array $modifiers = []): JsonResponse
protected function apiListingResponse(Builder $query, array $fields): JsonResponse
{
$listing = new ListingResponseBuilder($query, request(), $fields);
foreach ($modifiers as $modifier) {
$listing->modifyResults($modifier);
}
return $listing->toResponse();
}
@@ -30,7 +26,7 @@ abstract class ApiController extends Controller
* Get the validation rules for this controller.
* Defaults to a $rules property but can be a rules() method.
*/
public function getValidationRules(): array
public function getValdationRules(): array
{
if (method_exists($this, 'rules')) {
return $this->rules();

View File

@@ -87,33 +87,14 @@ class AttachmentApiController extends ApiController
'markdown' => $attachment->markdownLink(),
]);
// Simply return a JSON response of the attachment for link-based attachments
if ($attachment->external) {
if (!$attachment->external) {
$attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
$attachment->setAttribute('content', base64_encode($attachmentContents));
} else {
$attachment->setAttribute('content', $attachment->path);
return response()->json($attachment);
}
// Build and split our core JSON, at point of content.
$splitter = 'CONTENT_SPLIT_LOCATION_' . time() . '_' . rand(1, 40000);
$attachment->setAttribute('content', $splitter);
$json = $attachment->toJson();
$jsonParts = explode($splitter, $json);
// Get a stream for the file data from storage
$stream = $this->attachmentService->streamAttachmentFromStorage($attachment);
return response()->stream(function () use ($jsonParts, $stream) {
// Output the pre-content JSON data
echo $jsonParts[0];
// Stream out our attachment data as base64 content
stream_filter_append($stream, 'convert.base64-encode', STREAM_FILTER_READ);
fpassthru($stream);
fclose($stream);
// Output our post-content JSON data
echo $jsonParts[1];
}, 200, ['Content-Type' => 'application/json']);
return response()->json($attachment);
}
/**

View File

@@ -11,20 +11,21 @@ use Illuminate\Validation\ValidationException;
class BookshelfApiController extends ApiController
{
protected BookshelfRepo $bookshelfRepo;
/**
* @var BookshelfRepo
*/
protected $bookshelfRepo;
protected $rules = [
'create' => [
'name' => ['required', 'string', 'max:255'],
'description' => ['string', 'max:1000'],
'books' => ['array'],
'tags' => ['array'],
],
'update' => [
'name' => ['string', 'min:1', 'max:255'],
'description' => ['string', 'max:1000'],
'books' => ['array'],
'tags' => ['array'],
],
];

View File

@@ -12,7 +12,7 @@ use Illuminate\Http\Request;
class PageApiController extends ApiController
{
protected PageRepo $pageRepo;
protected $pageRepo;
protected $rules = [
'create' => [
@@ -24,8 +24,8 @@ class PageApiController extends ApiController
'tags' => ['array'],
],
'update' => [
'book_id' => ['integer'],
'chapter_id' => ['integer'],
'book_id' => ['required', 'integer'],
'chapter_id' => ['required', 'integer'],
'name' => ['string', 'min:1', 'max:255'],
'html' => ['string'],
'markdown' => ['string'],
@@ -103,8 +103,6 @@ class PageApiController extends ApiController
*/
public function update(Request $request, string $id)
{
$requestData = $this->validate($request, $this->rules['update']);
$page = $this->pageRepo->getById($id, []);
$this->checkOwnablePermission('page-update', $page);
@@ -129,7 +127,7 @@ class PageApiController extends ApiController
}
}
$updatedPage = $this->pageRepo->update($page, $requestData);
$updatedPage = $this->pageRepo->update($page, $request->all());
return response()->json($updatedPage->forJsonDisplay());
}

View File

@@ -1,90 +0,0 @@
<?php
namespace BookStack\Http\Controllers\Api;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Deletion;
use BookStack\Entities\Repos\DeletionRepo;
use Closure;
use Illuminate\Database\Eloquent\Builder;
class RecycleBinApiController extends ApiController
{
public function __construct()
{
$this->middleware(function ($request, $next) {
$this->checkPermission('settings-manage');
$this->checkPermission('restrictions-manage-all');
return $next($request);
});
}
/**
* Get a top-level listing of the items in the recycle bin.
* The "deletable" property will reflect the main item deleted.
* For books and chapters, counts of child pages/chapters will
* be loaded within this "deletable" data.
* For chapters & pages, the parent item will be loaded within this "deletable" data.
* Requires permission to manage both system settings and permissions.
*/
public function list()
{
return $this->apiListingResponse(Deletion::query()->with('deletable'), [
'id',
'deleted_by',
'created_at',
'updated_at',
'deletable_type',
'deletable_id',
], [Closure::fromCallable([$this, 'listFormatter'])]);
}
/**
* Restore a single deletion from the recycle bin.
* Requires permission to manage both system settings and permissions.
*/
public function restore(DeletionRepo $deletionRepo, string $deletionId)
{
$restoreCount = $deletionRepo->restore(intval($deletionId));
return response()->json(['restore_count' => $restoreCount]);
}
/**
* Remove a single deletion from the recycle bin.
* Use this endpoint carefully as it will entirely remove the underlying deleted items from the system.
* Requires permission to manage both system settings and permissions.
*/
public function destroy(DeletionRepo $deletionRepo, string $deletionId)
{
$deleteCount = $deletionRepo->destroy(intval($deletionId));
return response()->json(['delete_count' => $deleteCount]);
}
/**
* Load some related details for the deletion listing.
*/
protected function listFormatter(Deletion $deletion)
{
$deletable = $deletion->deletable;
$withTrashedQuery = fn (Builder $query) => $query->withTrashed();
if ($deletable instanceof BookChild) {
$parent = $deletable->getParent();
$parent->setAttribute('type', $parent->getType());
$deletable->setRelation('parent', $parent);
}
if ($deletable instanceof Book || $deletable instanceof Chapter) {
$countsToLoad = ['pages' => $withTrashedQuery];
if ($deletable instanceof Book) {
$countsToLoad['chapters'] = $withTrashedQuery;
}
$deletable->loadCount($countsToLoad);
}
}
}

View File

@@ -1,168 +0,0 @@
<?php
namespace BookStack\Http\Controllers\Api;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserUpdateException;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\Rules\Unique;
class UserApiController extends ApiController
{
protected $userRepo;
protected $fieldsToExpose = [
'email', 'created_at', 'updated_at', 'last_activity_at', 'external_auth_id',
];
public function __construct(UserRepo $userRepo)
{
$this->userRepo = $userRepo;
// Checks for all endpoints in this controller
$this->middleware(function ($request, $next) {
$this->checkPermission('users-manage');
$this->preventAccessInDemoMode();
return $next($request);
});
}
protected function rules(int $userId = null): array
{
return [
'create' => [
'name' => ['required', 'min:2'],
'email' => [
'required', 'min:2', 'email', new Unique('users', 'email'),
],
'external_auth_id' => ['string'],
'language' => ['string'],
'password' => [Password::default()],
'roles' => ['array'],
'roles.*' => ['integer'],
'send_invite' => ['boolean'],
],
'update' => [
'name' => ['min:2'],
'email' => [
'min:2',
'email',
(new Unique('users', 'email'))->ignore($userId ?? null),
],
'external_auth_id' => ['string'],
'language' => ['string'],
'password' => [Password::default()],
'roles' => ['array'],
'roles.*' => ['integer'],
],
'delete' => [
'migrate_ownership_id' => ['integer', 'exists:users,id'],
],
];
}
/**
* Get a listing of users in the system.
* Requires permission to manage users.
*/
public function list()
{
$users = User::query()->select(['*'])
->scopes('withLastActivityAt')
->with(['avatar']);
return $this->apiListingResponse($users, [
'id', 'name', 'slug', 'email', 'external_auth_id',
'created_at', 'updated_at', 'last_activity_at',
], [Closure::fromCallable([$this, 'listFormatter'])]);
}
/**
* Create a new user in the system.
* Requires permission to manage users.
*/
public function create(Request $request)
{
$data = $this->validate($request, $this->rules()['create']);
$sendInvite = ($data['send_invite'] ?? false) === true;
$user = null;
DB::transaction(function () use ($data, $sendInvite, &$user) {
$user = $this->userRepo->create($data, $sendInvite);
});
$this->singleFormatter($user);
return response()->json($user);
}
/**
* View the details of a single user.
* Requires permission to manage users.
*/
public function read(string $id)
{
$user = $this->userRepo->getById($id);
$this->singleFormatter($user);
return response()->json($user);
}
/**
* Update an existing user in the system.
* Requires permission to manage users.
*
* @throws UserUpdateException
*/
public function update(Request $request, string $id)
{
$data = $this->validate($request, $this->rules($id)['update']);
$user = $this->userRepo->getById($id);
$this->userRepo->update($user, $data, userCan('users-manage'));
$this->singleFormatter($user);
return response()->json($user);
}
/**
* Delete a user from the system.
* Can optionally accept a user id via `migrate_ownership_id` to indicate
* who should be the new owner of their related content.
* Requires permission to manage users.
*/
public function delete(Request $request, string $id)
{
$user = $this->userRepo->getById($id);
$newOwnerId = $request->get('migrate_ownership_id', null);
$this->userRepo->destroy($user, $newOwnerId);
return response('', 204);
}
/**
* Format the given user model for single-result display.
*/
protected function singleFormatter(User $user)
{
$this->listFormatter($user);
$user->load('roles:id,display_name');
$user->makeVisible(['roles']);
}
/**
* Format the given user model for a listing multi-result display.
*/
protected function listFormatter(User $user)
{
$user->makeVisible($this->fieldsToExpose);
$user->setAttribute('profile_url', $user->getProfileUrl());
$user->setAttribute('edit_url', $user->getEditUrl());
$user->setAttribute('avatar_url', $user->getAvatar());
}
}

View File

@@ -15,8 +15,8 @@ use Illuminate\Validation\ValidationException;
class AttachmentController extends Controller
{
protected AttachmentService $attachmentService;
protected PageRepo $pageRepo;
protected $attachmentService;
protected $pageRepo;
/**
* AttachmentController constructor.
@@ -230,13 +230,13 @@ class AttachmentController extends Controller
}
$fileName = $attachment->getFileName();
$attachmentStream = $this->attachmentService->streamAttachmentFromStorage($attachment);
$attachmentContents = $this->attachmentService->getAttachmentFromStorage($attachment);
if ($request->get('open') === 'true') {
return $this->streamedInlineDownloadResponse($attachmentStream, $fileName);
return $this->inlineDownloadResponse($attachmentContents, $fileName);
}
return $this->streamedDownloadResponse($attachmentStream, $fileName);
return $this->downloadResponse($attachmentContents, $fileName);
}
/**

View File

@@ -2,14 +2,13 @@
namespace BookStack\Http\Controllers\Auth;
use BookStack\Auth\Access\Oidc\OidcException;
use BookStack\Auth\Access\Oidc\OidcService;
use BookStack\Http\Controllers\Controller;
use Illuminate\Http\Request;
class OidcController extends Controller
{
protected OidcService $oidcService;
protected $oidcService;
/**
* OpenIdController constructor.
@@ -25,14 +24,7 @@ class OidcController extends Controller
*/
public function login()
{
try {
$loginDetails = $this->oidcService->login();
} catch (OidcException $exception) {
$this->showErrorNotification($exception->getMessage());
return redirect('/login');
}
$loginDetails = $this->oidcService->login();
session()->flash('oidc_state', $loginDetails['state']);
return redirect($loginDetails['url']);
@@ -53,13 +45,7 @@ class OidcController extends Controller
return redirect('/login');
}
try {
$this->oidcService->processAuthorizeResponse($request->query('code'));
} catch (OidcException $oidcException) {
$this->showErrorNotification($oidcException->getMessage());
return redirect('/login');
}
$this->oidcService->processAuthorizeResponse($request->query('code'));
return redirect()->intended();
}

View File

@@ -2,17 +2,16 @@
namespace BookStack\Http\Controllers;
use BookStack\Exceptions\NotifyException;
use BookStack\Facades\Activity;
use BookStack\Interfaces\Loggable;
use BookStack\Model;
use BookStack\Util\WebSafeMimeSniffer;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller as BaseController;
use Symfony\Component\HttpFoundation\StreamedResponse;
abstract class Controller extends BaseController
{
@@ -54,9 +53,14 @@ abstract class Controller extends BaseController
*/
protected function showPermissionError()
{
$message = request()->wantsJson() ? trans('errors.permissionJson') : trans('errors.permission');
if (request()->wantsJson()) {
$response = response()->json(['error' => trans('errors.permissionJson')], 403);
} else {
$response = redirect('/');
$this->showErrorNotification(trans('errors.permission'));
}
throw new NotifyException($message, '/', 403);
throw new HttpResponseException($response);
}
/**
@@ -116,30 +120,7 @@ abstract class Controller extends BaseController
{
return response()->make($content, 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . str_replace('"', '', $fileName) . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
/**
* Create a response that forces a download, from a given stream of content.
*/
protected function streamedDownloadResponse($stream, string $fileName): StreamedResponse
{
return response()->stream(function () use ($stream) {
// End & flush the output buffer, if we're in one, otherwise we still use memory.
// Output buffer may or may not exist depending on PHP `output_buffering` setting.
// Ignore in testing since output buffers are used to gather a response.
if (!empty(ob_get_status()) && !app()->runningUnitTests()) {
ob_end_clean();
}
fpassthru($stream);
fclose($stream);
}, 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="' . str_replace('"', '', $fileName) . '"',
'Content-Disposition' => 'attachment; filename="' . $fileName . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
@@ -154,28 +135,7 @@ abstract class Controller extends BaseController
return response()->make($content, 200, [
'Content-Type' => $mime,
'Content-Disposition' => 'inline; filename="' . str_replace('"', '', $fileName) . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
/**
* Create a file download response that provides the file with a content-type
* correct for the file, in a way so the browser can show the content in browser,
* for a given content stream.
*/
protected function streamedInlineDownloadResponse($stream, string $fileName): StreamedResponse
{
$sniffContent = fread($stream, 1000);
$mime = (new WebSafeMimeSniffer())->sniff($sniffContent);
return response()->stream(function () use ($sniffContent, $stream) {
echo $sniffContent;
fpassthru($stream);
fclose($stream);
}, 200, [
'Content-Type' => $mime,
'Content-Disposition' => 'inline; filename="' . str_replace('"', '', $fileName) . '"',
'Content-Disposition' => 'inline; filename="' . $fileName . '"',
'X-Content-Type-Options' => 'nosniff',
]);
}
@@ -219,6 +179,6 @@ abstract class Controller extends BaseController
*/
protected function getImageValidationRules(): array
{
return ['image_extension', 'mimes:jpeg,png,gif,webp,svg', 'max:' . (config('app.upload_limit') * 1000)];
return ['image_extension', 'mimes:jpeg,png,gif,webp', 'max:' . (config('app.upload_limit') * 1000)];
}
}

View File

@@ -107,6 +107,14 @@ class HomeController extends Controller
return view('home.default', $commonData);
}
/**
* Get custom head HTML, Used in ajax calls to show in editor.
*/
public function customHeadContent()
{
return view('common.custom-head');
}
/**
* Show the view for /robots.txt.
*/

View File

@@ -76,11 +76,8 @@ class DrawioImageController extends Controller
return $this->jsonError('Image data could not be found');
}
$isSvg = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'svg';
$uriPrefix = $isSvg ? 'data:image/svg+xml;base64,' : 'data:image/png;base64,';
return response()->json([
'content' => $uriPrefix . base64_encode($imageData),
'content' => base64_encode($imageData),
]);
}
}

View File

@@ -10,7 +10,6 @@ use BookStack\Entities\Tools\Cloner;
use BookStack\Entities\Tools\NextPreviousContentLocator;
use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditActivity;
use BookStack\Entities\Tools\PageEditorData;
use BookStack\Entities\Tools\PermissionsUpdater;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\PermissionsException;
@@ -22,7 +21,7 @@ use Throwable;
class PageController extends Controller
{
protected PageRepo $pageRepo;
protected $pageRepo;
/**
* PageController constructor.
@@ -83,15 +82,22 @@ class PageController extends Controller
*
* @throws NotFoundException
*/
public function editDraft(Request $request, string $bookSlug, int $pageId)
public function editDraft(string $bookSlug, int $pageId)
{
$draft = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-create', $draft->getParent());
$editorData = new PageEditorData($draft, $this->pageRepo, $request->query('editor', ''));
$this->setPageTitle(trans('entities.pages_edit_draft'));
return view('pages.edit', $editorData->getViewData());
$draftsEnabled = $this->isSignedIn();
$templates = $this->pageRepo->getTemplates(10);
return view('pages.edit', [
'page' => $draft,
'book' => $draft->book,
'isDraft' => true,
'draftsEnabled' => $draftsEnabled,
'templates' => $templates,
]);
}
/**
@@ -182,19 +188,43 @@ class PageController extends Controller
*
* @throws NotFoundException
*/
public function edit(Request $request, string $bookSlug, string $pageSlug)
public function edit(string $bookSlug, string $pageSlug)
{
$page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
$this->checkOwnablePermission('page-update', $page);
$editorData = new PageEditorData($page, $this->pageRepo, $request->query('editor', ''));
if ($editorData->getWarnings()) {
$this->showWarningNotification(implode("\n", $editorData->getWarnings()));
$page->isDraft = false;
$editActivity = new PageEditActivity($page);
// Check for active editing
$warnings = [];
if ($editActivity->hasActiveEditing()) {
$warnings[] = $editActivity->activeEditingMessage();
}
// Check for a current draft version for this user
$userDraft = $this->pageRepo->getUserDraft($page);
if ($userDraft !== null) {
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
$page->isDraft = true;
$warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
}
if (count($warnings) > 0) {
$this->showWarningNotification(implode("\n", $warnings));
}
$templates = $this->pageRepo->getTemplates(10);
$draftsEnabled = $this->isSignedIn();
$this->setPageTitle(trans('entities.pages_editing_named', ['pageName' => $page->getShortName()]));
return view('pages.edit', $editorData->getViewData());
return view('pages.edit', [
'page' => $page,
'book' => $page->book,
'current' => $page,
'draftsEnabled' => $draftsEnabled,
'templates' => $templates,
]);
}
/**

View File

@@ -124,8 +124,11 @@ class PageRevisionController extends Controller
throw new NotFoundException("Revision #{$revId} not found");
}
// Check if it's the latest revision, cannot delete the latest revision.
if (intval($page->currentRevision->id ?? null) === intval($revId)) {
// Get the current revision for the page
$currentRevision = $page->getCurrentRevision();
// Check if its the latest revision, cannot delete latest revision.
if (intval($currentRevision->id) === intval($revId)) {
$this->showErrorNotification(trans('entities.revision_cannot_delete_latest'));
return redirect($page->getUrl('/revisions'));

View File

@@ -5,7 +5,6 @@ namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType;
use BookStack\Entities\Models\Deletion;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Repos\DeletionRepo;
use BookStack\Entities\Tools\TrashCan;
class RecycleBinController extends Controller
@@ -74,9 +73,12 @@ class RecycleBinController extends Controller
*
* @throws \Exception
*/
public function restore(DeletionRepo $deletionRepo, string $id)
public function restore(string $id)
{
$restoreCount = $deletionRepo->restore((int) $id);
/** @var Deletion $deletion */
$deletion = Deletion::query()->findOrFail($id);
$this->logActivity(ActivityType::RECYCLE_BIN_RESTORE, $deletion);
$restoreCount = (new TrashCan())->restoreFromDeletion($deletion);
$this->showSuccessNotification(trans('settings.recycle_bin_restore_notification', ['count' => $restoreCount]));
@@ -101,9 +103,12 @@ class RecycleBinController extends Controller
*
* @throws \Exception
*/
public function destroy(DeletionRepo $deletionRepo, string $id)
public function destroy(string $id)
{
$deleteCount = $deletionRepo->destroy((int) $id);
/** @var Deletion $deletion */
$deletion = Deletion::query()->findOrFail($id);
$this->logActivity(ActivityType::RECYCLE_BIN_DESTROY, $deletion);
$deleteCount = (new TrashCan())->destroyFromDeletion($deletion);
$this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));

View File

@@ -9,37 +9,28 @@ use Illuminate\Http\Request;
class SettingController extends Controller
{
protected ImageRepo $imageRepo;
protected array $settingCategories = ['features', 'customization', 'registration'];
protected $imageRepo;
/**
* SettingController constructor.
*/
public function __construct(ImageRepo $imageRepo)
{
$this->imageRepo = $imageRepo;
}
/**
* Handle requests to the settings index path.
* Display a listing of the settings.
*/
public function index()
{
return redirect('/settings/features');
}
/**
* Display the settings for the given category.
*/
public function category(string $category)
{
$this->ensureCategoryExists($category);
$this->checkPermission('settings-manage');
$this->setPageTitle(trans('settings.settings'));
// Get application version
$version = trim(file_get_contents(base_path('version')));
return view('settings.' . $category, [
'category' => $category,
return view('settings.index', [
'version' => $version,
'guestUser' => User::getDefault(),
]);
@@ -48,9 +39,8 @@ class SettingController extends Controller
/**
* Update the specified settings in storage.
*/
public function update(Request $request, string $category)
public function update(Request $request)
{
$this->ensureCategoryExists($category);
$this->preventAccessInDemoMode();
$this->checkPermission('settings-manage');
$this->validate($request, [
@@ -67,7 +57,7 @@ class SettingController extends Controller
}
// Update logo image if set
if ($category === 'customization' && $request->hasFile('app_logo')) {
if ($request->hasFile('app_logo')) {
$logoFile = $request->file('app_logo');
$this->imageRepo->destroyByType('system');
$image = $this->imageRepo->saveNew($logoFile, 'system', 0, null, 86);
@@ -75,21 +65,16 @@ class SettingController extends Controller
}
// Clear logo image if requested
if ($category === 'customization' && $request->get('app_logo_reset', null)) {
if ($request->get('app_logo_reset', null)) {
$this->imageRepo->destroyByType('system');
setting()->remove('app-logo');
}
$this->logActivity(ActivityType::SETTINGS_UPDATE, $category);
$section = $request->get('section', '');
$this->logActivity(ActivityType::SETTINGS_UPDATE, $section);
$this->showSuccessNotification(trans('settings.settings_save_success'));
$redirectLocation = '/settings#' . $section;
return redirect("/settings/{$category}");
}
protected function ensureCategoryExists(string $category): void
{
if (!in_array($category, $this->settingCategories)) {
abort(404);
}
return redirect(rtrim($redirectLocation, '#'));
}
}

View File

@@ -2,9 +2,9 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityType;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\Queries\AllUsersPaginatedAndSorted;
use BookStack\Auth\Role;
use BookStack\Auth\Access\UserInviteService;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\ImageUploadException;
@@ -13,20 +13,25 @@ use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
class UserController extends Controller
{
protected $user;
protected $userRepo;
protected $inviteService;
protected $imageRepo;
/**
* UserController constructor.
*/
public function __construct(UserRepo $userRepo, ImageRepo $imageRepo)
public function __construct(User $user, UserRepo $userRepo, UserInviteService $inviteService, ImageRepo $imageRepo)
{
$this->user = $user;
$this->userRepo = $userRepo;
$this->inviteService = $inviteService;
$this->imageRepo = $imageRepo;
}
@@ -41,16 +46,12 @@ class UserController extends Controller
'search' => $request->get('search', ''),
'sort' => $request->get('sort', 'name'),
];
$users = (new AllUsersPaginatedAndSorted())->run(20, $listDetails);
$users = $this->userRepo->getAllUsersPaginatedAndSorted(20, $listDetails);
$this->setPageTitle(trans('settings.users'));
$users->appends($listDetails);
return view('users.index', [
'users' => $users,
'listDetails' => $listDetails,
]);
return view('users.index', ['users' => $users, 'listDetails' => $listDetails]);
}
/**
@@ -60,41 +61,61 @@ class UserController extends Controller
{
$this->checkPermission('users-manage');
$authMethod = config('auth.method');
$roles = Role::query()->orderBy('display_name', 'asc')->get();
$this->setPageTitle(trans('settings.users_add_new'));
$roles = $this->userRepo->getAllRoles();
return view('users.create', ['authMethod' => $authMethod, 'roles' => $roles]);
}
/**
* Store a new user in storage.
* Store a newly created user in storage.
*
* @throws UserUpdateException
* @throws ValidationException
*/
public function store(Request $request)
{
$this->checkPermission('users-manage');
$validationRules = [
'name' => ['required'],
'email' => ['required', 'email', 'unique:users,email'],
];
$authMethod = config('auth.method');
$sendInvite = ($request->get('send_invite', 'false') === 'true');
$externalAuth = $authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'oidc';
$passwordRequired = ($authMethod === 'standard' && !$sendInvite);
$validationRules = [
'name' => ['required'],
'email' => ['required', 'email', 'unique:users,email'],
'language' => ['string'],
'roles' => ['array'],
'roles.*' => ['integer'],
'password' => $passwordRequired ? ['required', Password::default()] : null,
'password-confirm' => $passwordRequired ? ['required', 'same:password'] : null,
'external_auth_id' => $externalAuth ? ['required'] : null,
];
if ($authMethod === 'standard' && !$sendInvite) {
$validationRules['password'] = ['required', Password::default()];
$validationRules['password-confirm'] = ['required', 'same:password'];
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
$validationRules['external_auth_id'] = ['required'];
}
$this->validate($request, $validationRules);
$validated = $this->validate($request, array_filter($validationRules));
$user = $this->user->fill($request->all());
DB::transaction(function () use ($validated, $sendInvite) {
$this->userRepo->create($validated, $sendInvite);
if ($authMethod === 'standard') {
$user->password = bcrypt($request->get('password', Str::random(32)));
} elseif ($authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'openid') {
$user->external_auth_id = $request->get('external_auth_id');
}
$user->refreshSlug();
DB::transaction(function () use ($user, $sendInvite, $request) {
$user->save();
if ($sendInvite) {
$this->inviteService->sendInvitation($user);
}
if ($request->filled('roles')) {
$roles = $request->get('roles');
$this->userRepo->setUserRoles($user, $roles);
}
$this->userRepo->downloadAndAssignUserAvatar($user);
$this->logActivity(ActivityType::USER_CREATE, $user);
});
return redirect('/settings/users');
@@ -108,14 +129,14 @@ class UserController extends Controller
$this->checkPermissionOrCurrentUser('users-manage', $id);
/** @var User $user */
$user = User::query()->with(['apiTokens', 'mfaValues'])->findOrFail($id);
$user = $this->user->newQuery()->with(['apiTokens', 'mfaValues'])->findOrFail($id);
$authMethod = ($user->system_name) ? 'system' : config('auth.method');
$activeSocialDrivers = $socialAuthService->getActiveDrivers();
$mfaMethods = $user->mfaValues->groupBy('method');
$this->setPageTitle(trans('settings.user_profile'));
$roles = Role::query()->orderBy('display_name', 'asc')->get();
$roles = $this->userRepo->getAllRoles();
return view('users.edit', [
'user' => $user,
@@ -138,20 +159,51 @@ class UserController extends Controller
$this->preventAccessInDemoMode();
$this->checkPermissionOrCurrentUser('users-manage', $id);
$validated = $this->validate($request, [
$this->validate($request, [
'name' => ['min:2'],
'email' => ['min:2', 'email', 'unique:users,email,' . $id],
'password' => ['required_with:password_confirm', Password::default()],
'password-confirm' => ['same:password', 'required_with:password'],
'language' => ['string'],
'roles' => ['array'],
'roles.*' => ['integer'],
'external_auth_id' => ['string'],
'setting' => ['array'],
'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()),
]);
$user = $this->userRepo->getById($id);
$this->userRepo->update($user, $validated, userCan('users-manage'));
$user->fill($request->except(['email']));
// Email updates
if (userCan('users-manage') && $request->filled('email')) {
$user->email = $request->get('email');
}
// Refresh the slug if the user's name has changed
if ($user->isDirty('name')) {
$user->refreshSlug();
}
// Role updates
if (userCan('users-manage') && $request->filled('roles')) {
$roles = $request->get('roles');
$this->userRepo->setUserRoles($user, $roles);
}
// Password updates
if ($request->filled('password')) {
$password = $request->get('password');
$user->password = bcrypt($password);
}
// External auth id updates
if (user()->can('users-manage') && $request->filled('external_auth_id')) {
$user->external_auth_id = $request->get('external_auth_id');
}
// Save an user-specific settings
if ($request->filled('setting')) {
foreach ($request->get('setting') as $key => $value) {
setting()->putUser($user, $key, $value);
}
}
// Save profile image if in request
if ($request->hasFile('profile_image')) {
@@ -159,7 +211,6 @@ class UserController extends Controller
$this->imageRepo->destroyImage($user->avatar);
$image = $this->imageRepo->saveNew($imageUpload, 'user', $user->id);
$user->image_id = $image->id;
$user->save();
}
// Delete the profile image if reset option is in request
@@ -167,7 +218,11 @@ class UserController extends Controller
$this->imageRepo->destroyImage($user->avatar);
}
$redirectUrl = userCan('users-manage') ? '/settings/users' : "/settings/users/{$user->id}";
$user->save();
$this->showSuccessNotification(trans('settings.users_edit_success'));
$this->logActivity(ActivityType::USER_UPDATE, $user);
$redirectUrl = userCan('users-manage') ? '/settings/users' : ('/settings/users/' . $user->id);
return redirect($redirectUrl);
}
@@ -198,7 +253,21 @@ class UserController extends Controller
$user = $this->userRepo->getById($id);
$newOwnerId = $request->get('new_owner_id', null);
if ($this->userRepo->isOnlyAdmin($user)) {
$this->showErrorNotification(trans('errors.users_cannot_delete_only_admin'));
return redirect($user->getEditUrl());
}
if ($user->system_name === 'public') {
$this->showErrorNotification(trans('errors.users_cannot_delete_guest'));
return redirect($user->getEditUrl());
}
$this->userRepo->destroy($user, $newOwnerId);
$this->showSuccessNotification(trans('settings.users_delete_success'));
$this->logActivity(ActivityType::USER_DELETE, $user);
return redirect('/settings/users');
}
@@ -283,7 +352,7 @@ class UserController extends Controller
$newState = $request->get('expand', 'false');
$user = $this->userRepo->getById($id);
$user = $this->user->findOrFail($id);
setting()->putUser($user, 'section_expansion#' . $key, $newState);
return response('', 204);
@@ -306,7 +375,7 @@ class UserController extends Controller
$order = 'asc';
}
$user = $this->userRepo->getById($userId);
$user = $this->user->findOrFail($userId);
$sortKey = $listName . '_sort';
$orderKey = $listName . '_sort_order';
setting()->putUser($user, $sortKey, $sort);

View File

@@ -3,8 +3,6 @@
namespace BookStack\Http\Controllers;
use BookStack\Actions\ActivityQueries;
use BookStack\Auth\Queries\UserContentCounts;
use BookStack\Auth\Queries\UserRecentlyCreatedContent;
use BookStack\Auth\UserRepo;
class UserProfileController extends Controller
@@ -17,8 +15,8 @@ class UserProfileController extends Controller
$user = $repo->getBySlug($slug);
$userActivity = $activities->userActivity($user);
$recentlyCreated = (new UserRecentlyCreatedContent())->run($user, 5);
$assetCounts = (new UserContentCounts())->run($user);
$recentlyCreated = $repo->getRecentlyCreated($user, 5);
$assetCounts = $repo->getAssetCounts($user);
$this->setPageTitle($user->name);

View File

@@ -8,7 +8,10 @@ use Illuminate\Http\Request;
class ApplyCspRules
{
protected CspService $cspService;
/**
* @var CspService
*/
protected $cspService;
public function __construct(CspService $cspService)
{
@@ -32,8 +35,10 @@ class ApplyCspRules
$response = $next($request);
$cspHeader = $this->cspService->getCspHeader();
$response->headers->set('Content-Security-Policy', $cspHeader, false);
$this->cspService->setFrameAncestors($response);
$this->cspService->setScriptSrc($response);
$this->cspService->setObjectSrc($response);
$this->cspService->setBaseUri($response);
return $response;
}

View File

@@ -11,7 +11,7 @@ class Localization
/**
* Array of right-to-left locales.
*/
protected $rtlLocales = ['ar', 'fa', 'he'];
protected $rtlLocales = ['ar', 'he'];
/**
* Map of BookStack locale names to best-estimate system locale names.
@@ -29,8 +29,6 @@ class Localization
'es' => 'es_ES',
'es_AR' => 'es_AR',
'et' => 'et_EE',
'eu' => 'eu_ES',
'fa' => 'fa_IR',
'fr' => 'fr_FR',
'he' => 'he_IL',
'hr' => 'hr_HR',

View File

@@ -8,38 +8,20 @@ class Request extends LaravelRequest
{
/**
* Override the default request methods to get the scheme and host
* to directly use the custom APP_URL, if set.
* to set the custom APP_URL, if set.
*
* @return string
* @return \Illuminate\Config\Repository|mixed|string
*/
public function getSchemeAndHttpHost()
{
$appUrl = config('app.url', null);
$base = config('app.url', null);
if ($appUrl) {
return implode('/', array_slice(explode('/', $appUrl), 0, 3));
if ($base) {
$base = trim($base, '/');
} else {
$base = $this->getScheme() . '://' . $this->getHttpHost();
}
return parent::getSchemeAndHttpHost();
}
/**
* Override the default request methods to get the base URL
* to directly use the custom APP_URL, if set.
* The base URL never ends with a / but should start with one if not empty.
*
* @return string
*/
public function getBaseUrl()
{
$appUrl = config('app.url', null);
if ($appUrl) {
$parsedBaseUrl = rtrim(implode('/', array_slice(explode('/', $appUrl), 3)), '/');
return empty($parsedBaseUrl) ? '' : ('/' . $parsedBaseUrl);
}
return parent::getBaseUrl();
return $base;
}
}

View File

@@ -2,33 +2,35 @@
namespace BookStack\Notifications;
use BookStack\Auth\User;
use Illuminate\Notifications\Messages\MailMessage;
class UserInvite extends MailNotification
{
public $token;
/**
* Create a new notification instance.
*
* @param string $token
*/
public function __construct(string $token)
public function __construct($token)
{
$this->token = $token;
}
/**
* Get the mail representation of the notification.
*
* @param mixed $notifiable
*
* @return \Illuminate\Notifications\Messages\MailMessage
*/
public function toMail(User $notifiable): MailMessage
public function toMail($notifiable)
{
$appName = ['appName' => setting('app-name')];
$language = setting()->getUser($notifiable, 'language');
return $this->newMailMessage()
->subject(trans('auth.user_invite_email_subject', $appName, $language))
->greeting(trans('auth.user_invite_email_greeting', $appName, $language))
->line(trans('auth.user_invite_email_text', [], $language))
->action(trans('auth.user_invite_email_action', [], $language), url('/register/invite/' . $this->token));
->subject(trans('auth.user_invite_email_subject', $appName))
->greeting(trans('auth.user_invite_email_greeting', $appName))
->line(trans('auth.user_invite_email_text'))
->action(trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token));
}
}

View File

@@ -51,12 +51,12 @@ class AppServiceProvider extends ServiceProvider
// Allow longer string lengths after upgrade to utf8mb4
Schema::defaultStringLength(191);
// Set morph-map for our relations to friendlier aliases
Relation::enforceMorphMap([
'bookshelf' => Bookshelf::class,
'book' => Book::class,
'chapter' => Chapter::class,
'page' => Page::class,
// Set morph-map due to namespace changes
Relation::morphMap([
'BookStack\\Bookshelf' => Bookshelf::class,
'BookStack\\Book' => Book::class,
'BookStack\\Chapter' => Chapter::class,
'BookStack\\Page' => Page::class,
]);
// View Composers

View File

@@ -23,7 +23,6 @@ class AuthServiceProvider extends ServiceProvider
public function boot()
{
// Password Configuration
// Changes here must be reflected in ApiDocsGenerate@getValidationAsString.
Password::defaults(function () {
return Password::min(8);
});

View File

@@ -93,8 +93,6 @@ class ThemeEvents
* @param string $event
* @param \BookStack\Actions\Webhook $webhook
* @param string|\BookStack\Interfaces\Loggable $detail
* @param \BookStack\Auth\User $initiator
* @param int $initiatedTime
*/
const WEBHOOK_CALL_BEFORE = 'webhook_call_before';
}

View File

@@ -14,7 +14,7 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
class AttachmentService
{
protected FilesystemManager $fileSystem;
protected $fileSystem;
/**
* AttachmentService constructor.
@@ -73,18 +73,6 @@ class AttachmentService
return $this->getStorageDisk()->get($this->adjustPathForStorageDisk($attachment->path));
}
/**
* Stream an attachment from storage.
*
* @throws FileNotFoundException
*
* @return resource|null
*/
public function streamAttachmentFromStorage(Attachment $attachment)
{
return $this->getStorageDisk()->readStream($this->adjustPathForStorageDisk($attachment->path));
}
/**
* Store a new attachment upon user upload.
*
@@ -223,6 +211,8 @@ class AttachmentService
*/
protected function putFileInStorage(UploadedFile $uploadedFile): string
{
$attachmentData = file_get_contents($uploadedFile->getRealPath());
$storage = $this->getStorageDisk();
$basePath = 'uploads/files/' . date('Y-m-M') . '/';
@@ -231,11 +221,10 @@ class AttachmentService
$uploadFileName = Str::random(3) . $uploadFileName;
}
$attachmentStream = fopen($uploadedFile->getRealPath(), 'r');
$attachmentPath = $basePath . $uploadFileName;
try {
$storage->writeStream($this->adjustPathForStorageDisk($attachmentPath), $attachmentStream);
$storage->put($this->adjustPathForStorageDisk($attachmentPath), $attachmentData);
} catch (Exception $e) {
Log::error('Error when attempting file upload:' . $e->getMessage());

View File

@@ -148,8 +148,7 @@ class ImageRepo
*/
public function saveDrawing(string $base64Uri, int $uploadedTo): Image
{
$isSvg = strpos($base64Uri, 'data:image/svg+xml;') === 0;
$name = 'Drawing-' . user()->id . '-' . time() . ($isSvg ? '.svg' : '.png');
$name = 'Drawing-' . user()->id . '-' . time() . '.png';
return $this->imageService->saveNewFromBase64Uri($base64Uri, $name, 'drawio', $uploadedTo);
}

View File

@@ -5,7 +5,6 @@ namespace BookStack\Uploads;
use BookStack\Exceptions\ImageUploadException;
use ErrorException;
use Exception;
use GuzzleHttp\Psr7\Utils;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Contracts\Filesystem\Filesystem as Storage;
@@ -15,7 +14,6 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Intervention\Image\Exception\NotSupportedException;
use Intervention\Image\Image as InterventionImage;
use Intervention\Image\ImageManager;
use League\Flysystem\Util;
use Psr\SimpleCache\InvalidArgumentException;
@@ -30,7 +28,7 @@ class ImageService
protected $image;
protected $fileSystem;
protected static $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
protected static $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
/**
* ImageService constructor.
@@ -230,14 +228,6 @@ class ImageService
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
}
/**
* Check if the given image is an SVG image file.
*/
protected function isSvg(Image $image): bool
{
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'svg';
}
/**
* Check if the given image and image data is apng.
*/
@@ -263,8 +253,8 @@ class ImageService
*/
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false): string
{
// Do not resize GIF images where we're not cropping or SVG images.
if (($keepRatio && $this->isGif($image)) || $this->isSvg($image)) {
// Do not resize GIF images where we're not cropping
if ($keepRatio && $this->isGif($image)) {
return $this->getPublicUrl($image->path);
}
@@ -318,8 +308,6 @@ class ImageService
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
}
$this->orientImageToOriginalExif($thumb, $imageData);
if ($keepRatio) {
$thumb->resize($width, $height, function ($constraint) {
$constraint->aspectRatio();
@@ -340,49 +328,6 @@ class ImageService
return $thumbData;
}
/**
* Orientate the given intervention image based upon the given original image data.
* Intervention does have an `orientate` method but the exif data it needs is lost before it
* can be used (At least when created using binary string data) so we need to do some
* implementation on our side to use the original image data.
* Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
* Copyright (c) Oliver Vogel, MIT License.
*/
protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
{
if (!extension_loaded('exif')) {
return;
}
$stream = Utils::streamFor($originalData)->detach();
$exif = @exif_read_data($stream);
$orientation = $exif ? ($exif['Orientation'] ?? null) : null;
switch ($orientation) {
case 2:
$image->flip();
break;
case 3:
$image->rotate(180);
break;
case 4:
$image->rotate(180)->flip();
break;
case 5:
$image->rotate(270)->flip();
break;
case 6:
$image->rotate(270);
break;
case 7:
$image->rotate(90)->flip();
break;
case 8:
$image->rotate(90);
break;
}
}
/**
* Get the raw data content from an image.
*

View File

@@ -3,10 +3,12 @@
namespace BookStack\Util;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class CspService
{
protected string $nonce;
/** @var string */
protected $nonce;
public function __construct(string $nonce = '')
{
@@ -22,34 +24,37 @@ class CspService
}
/**
* Get the CSP headers for the application.
* Sets CSP 'script-src' headers to restrict the forms of script that can
* run on the page.
*/
public function getCspHeader(): string
public function setScriptSrc(Response $response)
{
$headers = [
$this->getFrameAncestors(),
$this->getFrameSrc(),
$this->getScriptSrc(),
$this->getObjectSrc(),
$this->getBaseUri(),
if (config('app.allow_content_scripts')) {
return;
}
$parts = [
'http:',
'https:',
'\'nonce-' . $this->nonce . '\'',
'\'strict-dynamic\'',
];
return implode('; ', array_filter($headers));
$value = 'script-src ' . implode(' ', $parts);
$response->headers->set('Content-Security-Policy', $value, false);
}
/**
* Get the CSP rules for the application for a HTML meta tag.
* Sets CSP "frame-ancestors" headers to restrict the hosts that BookStack can be
* iframed within. Also adjusts the cookie samesite options so that cookies will
* operate in the third-party context.
*/
public function getCspMetaTagValue(): string
public function setFrameAncestors(Response $response)
{
$headers = [
$this->getFrameSrc(),
$this->getScriptSrc(),
$this->getObjectSrc(),
$this->getBaseUri(),
];
return implode('; ', array_filter($headers));
$iframeHosts = $this->getAllowedIframeHosts();
array_unshift($iframeHosts, "'self'");
$cspValue = 'frame-ancestors ' . implode(' ', $iframeHosts);
$response->headers->set('Content-Security-Policy', $cspValue, false);
}
/**
@@ -61,67 +66,25 @@ class CspService
}
/**
* Create CSP 'script-src' rule to restrict the forms of script that can run on the page.
*/
protected function getScriptSrc(): string
{
if (config('app.allow_content_scripts')) {
return '';
}
$parts = [
'http:',
'https:',
'\'nonce-' . $this->nonce . '\'',
'\'strict-dynamic\'',
];
return 'script-src ' . implode(' ', $parts);
}
/**
* Create CSP "frame-ancestors" rule to restrict the hosts that BookStack can be iframed within.
*/
protected function getFrameAncestors(): string
{
$iframeHosts = $this->getAllowedIframeHosts();
array_unshift($iframeHosts, "'self'");
return 'frame-ancestors ' . implode(' ', $iframeHosts);
}
/**
* Creates CSP "frame-src" rule to restrict what hosts/sources can be loaded
* within iframes to provide an allow-list-style approach to iframe content.
*/
protected function getFrameSrc(): string
{
$iframeHosts = $this->getAllowedIframeSources();
array_unshift($iframeHosts, "'self'");
return 'frame-src ' . implode(' ', $iframeHosts);
}
/**
* Creates CSP 'object-src' rule to restrict the types of dynamic content
* Sets CSP 'object-src' headers to restrict the types of dynamic content
* that can be embedded on the page.
*/
protected function getObjectSrc(): string
public function setObjectSrc(Response $response)
{
if (config('app.allow_content_scripts')) {
return '';
return;
}
return "object-src 'self'";
$response->headers->set('Content-Security-Policy', 'object-src \'self\'', false);
}
/**
* Creates CSP 'base-uri' rule to restrict what base tags can be set on
* Sets CSP 'base-uri' headers to restrict what base tags can be set on
* the page to prevent manipulation of relative links.
*/
protected function getBaseUri(): string
public function setBaseUri(Response $response)
{
return "base-uri 'self'";
$response->headers->set('Content-Security-Policy', 'base-uri \'self\'', false);
}
protected function getAllowedIframeHosts(): array
@@ -130,21 +93,4 @@ class CspService
return array_filter(explode(' ', $hosts));
}
protected function getAllowedIframeSources(): array
{
$sources = config('app.iframe_sources', '');
$hosts = array_filter(explode(' ', $sources));
// Extract drawing service url to allow embedding if active
$drawioConfigValue = config('services.drawio');
if ($drawioConfigValue) {
$drawioSource = is_string($drawioConfigValue) ? $drawioConfigValue : 'https://embed.diagrams.net/';
$drawioSourceParsed = parse_url($drawioSource);
$drawioHost = $drawioSourceParsed['scheme'] . '://' . $drawioSourceParsed['host'];
$hosts[] = $drawioHost;
}
return $hosts;
}
}

View File

@@ -8,7 +8,7 @@
"license": "MIT",
"type": "project",
"require": {
"php": "^7.4|^8.0",
"php": "^7.3|^8.0",
"ext-curl": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
@@ -17,8 +17,8 @@
"ext-mbstring": "*",
"ext-xml": "*",
"bacon/bacon-qr-code": "^2.0",
"barryvdh/laravel-dompdf": "^1.0",
"barryvdh/laravel-snappy": "^1.0",
"barryvdh/laravel-dompdf": "^0.9.0",
"barryvdh/laravel-snappy": "^0.4.8",
"doctrine/dbal": "^3.1",
"filp/whoops": "^2.14",
"guzzlehttp/guzzle": "^7.4",
@@ -95,7 +95,7 @@
"preferred-install": "dist",
"sort-packages": true,
"platform": {
"php": "7.4.0"
"php": "7.3.0"
}
},
"extra": {

1224
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,62 +0,0 @@
<?php
use Carbon\Carbon;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class AddEditorChangeFieldAndPermission extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
// Add the new 'editor' column to the pages table
Schema::table('pages', function (Blueprint $table) {
$table->string('editor', 50)->default('');
});
// Populate the new 'editor' column
// We set it to 'markdown' for pages currently with markdown content
DB::table('pages')->where('markdown', '!=', '')->update(['editor' => 'markdown']);
// We set it to 'wysiwyg' where we have HTML but no markdown
DB::table('pages')->where('markdown', '=', '')
->where('html', '!=', '')
->update(['editor' => 'wysiwyg']);
// Give the admin user permission to change the editor
$adminRoleId = DB::table('roles')->where('system_name', '=', 'admin')->first()->id;
$permissionId = DB::table('role_permissions')->insertGetId([
'name' => 'editor-change',
'display_name' => 'Change page editor',
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
DB::table('permission_role')->insert([
'role_id' => $adminRoleId,
'permission_id' => $permissionId,
]);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
// Drop the new column from the pages table
Schema::table('pages', function (Blueprint $table) {
$table->dropColumn('editor');
});
// Remove traces of the role permission
DB::table('role_permissions')->where('name', '=', 'editor-change')->delete();
}
}

View File

@@ -1,64 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
class UpdatePolymorphicTypes extends Migration
{
/**
* Mapping of old polymorphic types to new simpler values.
*/
protected $changeMap = [
'BookStack\\Bookshelf' => 'bookshelf',
'BookStack\\Book' => 'book',
'BookStack\\Chapter' => 'chapter',
'BookStack\\Page' => 'page',
];
/**
* Mapping of tables and columns that contain polymorphic types.
*/
protected $columnsByTable = [
'activities' => 'entity_type',
'comments' => 'entity_type',
'deletions' => 'deletable_type',
'entity_permissions' => 'restrictable_type',
'favourites' => 'favouritable_type',
'joint_permissions' => 'entity_type',
'search_terms' => 'entity_type',
'tags' => 'entity_type',
'views' => 'viewable_type',
];
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
foreach ($this->columnsByTable as $table => $column) {
foreach ($this->changeMap as $oldVal => $newVal) {
DB::table($table)
->where([$column => $oldVal])
->update([$column => $newVal]);
}
}
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
foreach ($this->columnsByTable as $table => $column) {
foreach ($this->changeMap as $oldVal => $newVal) {
DB::table($table)
->where([$column => $newVal])
->update([$column => $oldVal]);
}
}
}
}

View File

@@ -1,7 +0,0 @@
{
"name": "Dan Brown",
"email": "dannyb@example.com",
"roles": [1],
"language": "fr",
"send_invite": true
}

View File

@@ -1,3 +0,0 @@
{
"migrate_ownership_id": 5
}

View File

@@ -1,7 +0,0 @@
{
"name": "Dan Spaggleforth",
"email": "dspaggles@example.com",
"roles": [2],
"language": "de",
"password": "hunter2000"
}

View File

@@ -1,3 +0,0 @@
{
"delete_count": 2
}

View File

@@ -1,64 +0,0 @@
{
"data": [
{
"id": 18,
"deleted_by": 1,
"created_at": "2022-04-20T12:57:46.000000Z",
"updated_at": "2022-04-20T12:57:46.000000Z",
"deletable_type": "page",
"deletable_id": 2582,
"deletable": {
"id": 2582,
"book_id": 25,
"chapter_id": 0,
"name": "A Wonderful Page",
"slug": "a-wonderful-page",
"priority": 9,
"created_at": "2022-02-08T00:44:45.000000Z",
"updated_at": "2022-04-20T12:57:46.000000Z",
"created_by": 1,
"updated_by": 1,
"draft": false,
"revision_count": 1,
"template": false,
"owned_by": 1,
"editor": "wysiwyg",
"book_slug": "a-great-book",
"parent": {
"id": 25,
"name": "A Great Book",
"slug": "a-great-book",
"description": "",
"created_at": "2022-01-24T16:14:28.000000Z",
"updated_at": "2022-03-06T15:14:50.000000Z",
"created_by": 1,
"updated_by": 1,
"owned_by": 1,
"type": "book"
}
}
},
{
"id": 19,
"deleted_by": 1,
"created_at": "2022-04-25T16:07:46.000000Z",
"updated_at": "2022-04-25T16:07:46.000000Z",
"deletable_type": "book",
"deletable_id": 13,
"deletable": {
"id": 13,
"name": "A Big Book!",
"slug": "a-big-book",
"description": "This is a very large book with loads of cool stuff in it!",
"created_at": "2021-11-08T11:26:43.000000Z",
"updated_at": "2022-04-25T16:07:47.000000Z",
"created_by": 27,
"updated_by": 1,
"owned_by": 1,
"pages_count": 208,
"chapters_count": 50
}
}
],
"total": 2
}

View File

@@ -1,3 +0,0 @@
{
"restore_count": 2
}

View File

@@ -1,19 +0,0 @@
{
"id": 1,
"name": "Dan Brown",
"email": "dannyb@example.com",
"created_at": "2022-02-03T16:27:55.000000Z",
"updated_at": "2022-02-03T16:27:55.000000Z",
"external_auth_id": "abc123456",
"slug": "dan-brown",
"last_activity_at": "2022-02-03T16:27:55.000000Z",
"profile_url": "https://docs.example.com/user/dan-brown",
"edit_url": "https://docs.example.com/settings/users/1",
"avatar_url": "https://docs.example.com/uploads/images/user/2021-10/thumbs-50-50/profile-2021.jpg",
"roles": [
{
"id": 1,
"display_name": "Admin"
}
]
}

View File

@@ -1,33 +0,0 @@
{
"data": [
{
"id": 1,
"name": "Dan Brown",
"email": "dannyb@example.com",
"created_at": "2022-02-03T16:27:55.000000Z",
"updated_at": "2022-02-03T16:27:55.000000Z",
"external_auth_id": "abc123456",
"slug": "dan-brown",
"user_id": 1,
"last_activity_at": "2022-02-03T16:27:55.000000Z",
"profile_url": "https://docs.example.com/user/dan-brown",
"edit_url": "https://docs.example.com/settings/users/1",
"avatar_url": "https://docs.example.com/uploads/images/user/2021-10/thumbs-50-50/profile-2021.jpg"
},
{
"id": 2,
"name": "Benny",
"email": "benny@example.com",
"created_at": "2022-01-31T20:39:24.000000Z",
"updated_at": "2021-11-18T17:10:58.000000Z",
"external_auth_id": "",
"slug": "benny",
"user_id": 2,
"last_activity_at": "2022-01-31T20:39:24.000000Z",
"profile_url": "https://docs.example.com/user/benny",
"edit_url": "https://docs.example.com/settings/users/2",
"avatar_url": "https://docs.example.com/uploads/images/user/2021-11/thumbs-50-50/guest.jpg"
}
],
"total": 28
}

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