mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-05-04 18:08:46 +03:00
Compare commits
441 Commits
lexical_ma
...
v25.11.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46001d61d0 | ||
|
|
8dd238ceae | ||
|
|
bb7fd59de9 | ||
|
|
ad8fc95521 | ||
|
|
cca066a258 | ||
|
|
bbda5fd468 | ||
|
|
8429cc93eb | ||
|
|
fef61f054a | ||
|
|
8082c95ec3 | ||
|
|
fcabf478de | ||
|
|
8de2c28497 | ||
|
|
0838d5ea16 | ||
|
|
449ac40114 | ||
|
|
3131050acd | ||
|
|
c0d2874892 | ||
|
|
5940a91809 | ||
|
|
9a4651badb | ||
|
|
92d15d9cf2 | ||
|
|
b06147fef7 | ||
|
|
841350a937 | ||
|
|
12183bac07 | ||
|
|
e65b4b63a2 | ||
|
|
7cac3f4780 | ||
|
|
92cd11d105 | ||
|
|
13115ace84 | ||
|
|
73f9834e6f | ||
|
|
3afe855156 | ||
|
|
bfde896f0b | ||
|
|
1cdc0a7a3d | ||
|
|
d19b86640b | ||
|
|
2936ba609b | ||
|
|
573a2dd22a | ||
|
|
b55cc803d3 | ||
|
|
304ade418e | ||
|
|
997931c42f | ||
|
|
268e353431 | ||
|
|
b491b5fbca | ||
|
|
387c786768 | ||
|
|
2641586a6f | ||
|
|
6d2cd20e80 | ||
|
|
b0c574356a | ||
|
|
07e45a20e5 | ||
|
|
14056c69e6 | ||
|
|
fb9c840c46 | ||
|
|
5fba4a5399 | ||
|
|
c0b377050e | ||
|
|
f3efb6441d | ||
|
|
0cf313a21e | ||
|
|
26aadffb20 | ||
|
|
a5f48e3202 | ||
|
|
b0dda6e6a7 | ||
|
|
d4025d95e7 | ||
|
|
d6021f4d22 | ||
|
|
b9a3290731 | ||
|
|
48f235ea5a | ||
|
|
047771b9f4 | ||
|
|
b5375114d3 | ||
|
|
fc13e56cea | ||
|
|
77fc37ac25 | ||
|
|
3424351e84 | ||
|
|
606f9d92d0 | ||
|
|
a5e25abb9c | ||
|
|
b310e87e4c | ||
|
|
425baf9d6e | ||
|
|
825c369ad9 | ||
|
|
10bab70438 | ||
|
|
350e0b281b | ||
|
|
08805ea3c8 | ||
|
|
9441e32c69 | ||
|
|
530fc37067 | ||
|
|
369e499dce | ||
|
|
655815de6d | ||
|
|
457adc1fee | ||
|
|
e86a90967e | ||
|
|
5d08f7cf14 | ||
|
|
8744eb2d62 | ||
|
|
d8383cfa80 | ||
|
|
4626278447 | ||
|
|
c61af9c22b | ||
|
|
72521d0906 | ||
|
|
7e44b195c5 | ||
|
|
5b45eac5e1 | ||
|
|
c1d30341e7 | ||
|
|
80d2b4913b | ||
|
|
3f473528b1 | ||
|
|
d0dcd4f61b | ||
|
|
bde66a1396 | ||
|
|
4de5a2d9bf | ||
|
|
27bf4299cf | ||
|
|
164f01bb25 | ||
|
|
f563a005f5 | ||
|
|
a14d8e30cc | ||
|
|
a9194ffb63 | ||
|
|
2f9c1b7127 | ||
|
|
bbea76668b | ||
|
|
becc630acf | ||
|
|
4ac8ecad6b | ||
|
|
903e88c700 | ||
|
|
ed96aa820e | ||
|
|
63ec079b7b | ||
|
|
d485fcb3db | ||
|
|
0f895668a4 | ||
|
|
6c577ac3bf | ||
|
|
31cc2423d2 | ||
|
|
c9ed32e518 | ||
|
|
6b4c3a0969 | ||
|
|
2dad92d1bd | ||
|
|
c1fb7ab7dc | ||
|
|
98315f3899 | ||
|
|
8c82aaabd6 | ||
|
|
ce9b536b78 | ||
|
|
d9c50e5bc1 | ||
|
|
bf075f7dd8 | ||
|
|
a4fd673285 | ||
|
|
e794c977bc | ||
|
|
0b088ef1d3 | ||
|
|
bf6a6af683 | ||
|
|
914790fd99 | ||
|
|
edb0c6a9e8 | ||
|
|
84049de696 | ||
|
|
da0531e63b | ||
|
|
421dc75f4e | ||
|
|
8ae91df038 | ||
|
|
64b41dd626 | ||
|
|
ebd6e4d3a2 | ||
|
|
80374aea5c | ||
|
|
2ac9efae7d | ||
|
|
a11d565ba4 | ||
|
|
1fdf854ea7 | ||
|
|
e9c9792cb9 | ||
|
|
5ae524c25a | ||
|
|
0d7287fc8b | ||
|
|
e77c96f6b7 | ||
|
|
9b8a10dd3a | ||
|
|
49200ca5ce | ||
|
|
34aa4dbf10 | ||
|
|
5ee79d16c9 | ||
|
|
a1ea4006e0 | ||
|
|
9078188939 | ||
|
|
ed0aad1a7a | ||
|
|
5c59cfb020 | ||
|
|
3ca15ad68a | ||
|
|
60014989f5 | ||
|
|
57b10f195e | ||
|
|
b1e95eb39f | ||
|
|
b3da77b8f9 | ||
|
|
1a345b74bb | ||
|
|
8ffc3a4abf | ||
|
|
7233c1c7b2 | ||
|
|
1309a01131 | ||
|
|
0333185b6d | ||
|
|
83f89f64e8 | ||
|
|
11a1a6fb16 | ||
|
|
882c609296 | ||
|
|
176a0dcd59 | ||
|
|
94b0f70bfa | ||
|
|
08b2a77d41 | ||
|
|
3e8e9a23cf | ||
|
|
58b83b64c8 | ||
|
|
dfe4cde6ee | ||
|
|
d11144d9e2 | ||
|
|
f96b0ea5f3 | ||
|
|
815f8d79ed | ||
|
|
b62dab32e0 | ||
|
|
262f863981 | ||
|
|
a4c94390a1 | ||
|
|
53f3cca85d | ||
|
|
ed08bbcecc | ||
|
|
de97ebf9b7 | ||
|
|
f492a660a8 | ||
|
|
09436836a5 | ||
|
|
bb455d7788 | ||
|
|
009212ab80 | ||
|
|
ba9cb591c8 | ||
|
|
d00ac2f34e | ||
|
|
bd4dc6d463 | ||
|
|
d91180a909 | ||
|
|
bc2913a5cb | ||
|
|
4802394562 | ||
|
|
1755556468 | ||
|
|
01cdbdb7ae | ||
|
|
fc8bbf3eab | ||
|
|
3cdab19319 | ||
|
|
5661d20e87 | ||
|
|
91f80123e8 | ||
|
|
7a0636d0f8 | ||
|
|
0fe5bdfbac | ||
|
|
f88687e977 | ||
|
|
68d437d05b | ||
|
|
1e56aaea04 | ||
|
|
dab170a6fe | ||
|
|
a8de717d9b | ||
|
|
78fe95b6fc | ||
|
|
e0c24e41aa | ||
|
|
fa8553839b | ||
|
|
b8fcefc794 | ||
|
|
88bcb68fcb | ||
|
|
7c000553ae | ||
|
|
391fa35c80 | ||
|
|
c6773a8c9f | ||
|
|
9b226e7d39 | ||
|
|
9865446267 | ||
|
|
926abbe776 | ||
|
|
4fabef3a57 | ||
|
|
5ef4cd80c3 | ||
|
|
e01f23583f | ||
|
|
7792cb3915 | ||
|
|
be26253a18 | ||
|
|
1bdd1f8189 | ||
|
|
fa62c79b17 | ||
|
|
d7d8fa1e5b | ||
|
|
18562f1e10 | ||
|
|
86090a694f | ||
|
|
1ee8287c73 | ||
|
|
8eb98cd591 | ||
|
|
0f9ba21b05 | ||
|
|
834f8e7046 | ||
|
|
32e3399334 | ||
|
|
2d8698a218 | ||
|
|
454fb883a2 | ||
|
|
6f4a6ab8ea | ||
|
|
9c4b6f36f1 | ||
|
|
78886b1e67 | ||
|
|
d9debaf032 | ||
|
|
d4360d6347 | ||
|
|
175b1785c0 | ||
|
|
c8740c0171 | ||
|
|
91ee895a74 | ||
|
|
a045e46571 | ||
|
|
44eaa65c3b | ||
|
|
0a22af7b14 | ||
|
|
b54702ab08 | ||
|
|
c4fdcfc5d1 | ||
|
|
cb8117e8df | ||
|
|
5a218d5056 | ||
|
|
8dbc5cf9c6 | ||
|
|
71e81615a3 | ||
|
|
611d37da04 | ||
|
|
0e799a3857 | ||
|
|
b91d6e2bfa | ||
|
|
ea16ad7e94 | ||
|
|
ba6eb54552 | ||
|
|
f705e7683b | ||
|
|
dc996adb20 | ||
|
|
a64c638ccc | ||
|
|
359c067279 | ||
|
|
66a746e297 | ||
|
|
a4d43ee24b | ||
|
|
f7793a70a9 | ||
|
|
ceba3d31fb | ||
|
|
eecc08edde | ||
|
|
eb19aadc75 | ||
|
|
06c81e69b9 | ||
|
|
3dc3d4a639 | ||
|
|
94c59c1e3d | ||
|
|
4d2205853a | ||
|
|
751772b87a | ||
|
|
76e30869e1 | ||
|
|
3edc9fe9eb | ||
|
|
616c62703e | ||
|
|
ecd56917e7 | ||
|
|
e22c9cae91 | ||
|
|
29ddb6e1b9 | ||
|
|
2ff90e2ff0 | ||
|
|
04ecc128a2 | ||
|
|
87d1d3423b | ||
|
|
4818192a2a | ||
|
|
965dd97f54 | ||
|
|
195b74926c | ||
|
|
2120db12b2 | ||
|
|
ed563fef28 | ||
|
|
0d31a8e3f1 | ||
|
|
b8354b974b | ||
|
|
034c1e289d | ||
|
|
f31605a3de | ||
|
|
e7cc75c74d | ||
|
|
4b79d5e4e8 | ||
|
|
34854915b3 | ||
|
|
af6f34b529 | ||
|
|
fb82a2b896 | ||
|
|
5b464938b6 | ||
|
|
81f954890d | ||
|
|
0e2bbcec62 | ||
|
|
fdd339f525 | ||
|
|
8cf7d6a83d | ||
|
|
58a5008718 | ||
|
|
c44a8df55d | ||
|
|
ff1494c519 | ||
|
|
b8ce8fd852 | ||
|
|
75e7454a5f | ||
|
|
2558ea8931 | ||
|
|
ac0f47a4b2 | ||
|
|
4f16129869 | ||
|
|
64a8037fdd | ||
|
|
7502ba1bc8 | ||
|
|
33a04697ef | ||
|
|
b70a5c0cdb | ||
|
|
9443ae9f40 | ||
|
|
220c2a4102 | ||
|
|
e9914eb301 | ||
|
|
934512d09c | ||
|
|
9102c90986 | ||
|
|
c3e74219c4 | ||
|
|
13c9d7bc2d | ||
|
|
119b539586 | ||
|
|
29a5c180f0 | ||
|
|
7906602291 | ||
|
|
6dafe773ff | ||
|
|
25bc28a1be | ||
|
|
4c561c7fa0 | ||
|
|
95b3e78573 | ||
|
|
63a345bc93 | ||
|
|
e093a172cb | ||
|
|
4b01f8934b | ||
|
|
bc116b45b5 | ||
|
|
a059960b9e | ||
|
|
7770966fed | ||
|
|
d7adcf6c69 | ||
|
|
04a364dcc3 | ||
|
|
db83ac7eaa | ||
|
|
3ca9dddf61 | ||
|
|
bf74f53ca7 | ||
|
|
9d67efb4a4 | ||
|
|
3a39b9f440 | ||
|
|
27f7aab375 | ||
|
|
337da0c467 | ||
|
|
f56b3560c4 | ||
|
|
02dfe11ce6 | ||
|
|
83d06beb70 | ||
|
|
a8cfc059c8 | ||
|
|
1614b2bab0 | ||
|
|
4bdec0d214 | ||
|
|
6a7d7e7c2b | ||
|
|
30d4674657 | ||
|
|
9f961f95f8 | ||
|
|
bab99a26ec | ||
|
|
9a7fecd269 | ||
|
|
a8dc0d449b | ||
|
|
a0381f76bf | ||
|
|
6102f66daa | ||
|
|
c6134d162d | ||
|
|
2046f9b9de | ||
|
|
ac3ba594a4 | ||
|
|
22df25a480 | ||
|
|
8b30c7f02e | ||
|
|
757cdddc7c | ||
|
|
df95e99680 | ||
|
|
5a6d544db7 | ||
|
|
16117d329c | ||
|
|
e90da18ada | ||
|
|
a08d80e1cc | ||
|
|
6258175922 | ||
|
|
15736777a0 | ||
|
|
75915e8a94 | ||
|
|
9bde0ae4ea | ||
|
|
0c802d1f86 | ||
|
|
b7a96c6466 | ||
|
|
4b645a82c7 | ||
|
|
d599b77b6f | ||
|
|
26e93dc8c1 | ||
|
|
a4c9a8491b | ||
|
|
70ee636d87 | ||
|
|
b35f6dbb03 | ||
|
|
67d9e24d8f | ||
|
|
3903fda6ca | ||
|
|
441e46ebaa | ||
|
|
1f4260f359 | ||
|
|
dc0bf8ad4e | ||
|
|
102e326e6a | ||
|
|
2b25bf6f3b | ||
|
|
f93280696d | ||
|
|
1787391b07 | ||
|
|
a74a8ee483 | ||
|
|
7fa5405cb7 | ||
|
|
6725ddcc41 | ||
|
|
bce941db3f | ||
|
|
6d926048ec | ||
|
|
5335c973b4 | ||
|
|
15c3e5c96e | ||
|
|
a5d5904969 | ||
|
|
598758b991 | ||
|
|
9926e23bc8 | ||
|
|
5d3264bc63 | ||
|
|
d71f819f95 | ||
|
|
ee13509760 | ||
|
|
82d7bb1f32 | ||
|
|
cdfda508d8 | ||
|
|
da941e584f | ||
|
|
65874d7b96 | ||
|
|
ac9b8f405c | ||
|
|
8d1419a12e | ||
|
|
04f7a7d301 | ||
|
|
c10d2a1493 | ||
|
|
97bbf79ffd | ||
|
|
f7b01ae53d | ||
|
|
d704e1dbba | ||
|
|
ef2ff5e093 | ||
|
|
7caed3b0db | ||
|
|
45641d0754 | ||
|
|
4b1d08ba99 | ||
|
|
160fa99ba4 | ||
|
|
d2a5ab49ed | ||
|
|
c6404d8917 | ||
|
|
7113807f12 | ||
|
|
be711215e8 | ||
|
|
7e3b404240 | ||
|
|
e86901ca20 | ||
|
|
bdfa61c8b2 | ||
|
|
2cc36787f5 | ||
|
|
448ac61b48 | ||
|
|
753f6394f7 | ||
|
|
b1faf65934 | ||
|
|
09f478bd74 | ||
|
|
a0497feddd | ||
|
|
789693bde9 | ||
|
|
1fe933e4ea | ||
|
|
724b4b5a70 | ||
|
|
1778a56146 | ||
|
|
744865fcb2 | ||
|
|
7f8c8b448d | ||
|
|
a67c53826d | ||
|
|
14b131e850 | ||
|
|
9b55a52b85 | ||
|
|
db1d10e80f | ||
|
|
1be576966f | ||
|
|
b97e792c5f | ||
|
|
8dec674cc3 | ||
|
|
f784c03746 | ||
|
|
148e172fe8 | ||
|
|
56ae86646f | ||
|
|
1d2b6fdfa2 | ||
|
|
4fc75beed4 | ||
|
|
3b3bc0c4bf | ||
|
|
910faab88e | ||
|
|
f184d763ad | ||
|
|
a91d42634d | ||
|
|
f517ef3616 | ||
|
|
e99507ddcf | ||
|
|
d2cacf1945 | ||
|
|
448ac1405b | ||
|
|
6ad21ce885 |
@@ -351,25 +351,10 @@ EXPORT_PDF_COMMAND_TIMEOUT=15
|
||||
# Only used if 'ALLOW_UNTRUSTED_SERVER_FETCHING=true' which disables security protections.
|
||||
WKHTMLTOPDF=false
|
||||
|
||||
# Allow JavaScript, and other potentiall dangerous content in page content.
|
||||
# This also removes CSP-level JavaScript control.
|
||||
# Allow <script> tags in page content
|
||||
# Note, if set to 'true' the page editor may still escape scripts.
|
||||
# DEPRECATED: Use 'APP_CONTENT_FILTERING' instead as detailed below. Activiting this option
|
||||
# effectively sets APP_CONTENT_FILTERING='' (No filtering)
|
||||
ALLOW_CONTENT_SCRIPTS=false
|
||||
|
||||
# Control the behaviour of content filtering, primarily used for page content.
|
||||
# This setting is a string of characters which represent different available filters:
|
||||
# - j - Filter out JavaScript and unknown binary data based content
|
||||
# - h - Filter out unexpected, and potentially dangerous, HTML elements
|
||||
# - f - Filter out unexpected form elements
|
||||
# - a - Run content through a more complex allowlist filter
|
||||
# This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
|
||||
# Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
|
||||
# Note: The default value will always be the most-strict, so it's advised to leave this unset in your own configuration
|
||||
# to ensure you are always using the full range of filters.
|
||||
APP_CONTENT_FILTERING="jfha"
|
||||
|
||||
# Indicate if robots/crawlers should crawl your instance.
|
||||
# Can be 'true', 'false' or 'null'.
|
||||
# The behaviour of the default 'null' option will depend on the 'app-public' admin setting.
|
||||
|
||||
86
.github/CODE_OF_CONDUCT.md
vendored
86
.github/CODE_OF_CONDUCT.md
vendored
@@ -1,2 +1,84 @@
|
||||
Please find our community rules on our website here:
|
||||
https://www.bookstackapp.com/about/community-rules/
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, gender identity and expression, level of experience,
|
||||
education, socio-economic status, nationality, personal appearance, race,
|
||||
religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
### Project Maintainer Standards
|
||||
|
||||
Project maintainers should generally follow these additional standards:
|
||||
|
||||
* Avoid using a negative or harsh tone in communication, Even if the other party
|
||||
is being negative themselves.
|
||||
* When providing criticism, try to make it constructive to lead the other person
|
||||
down the correct path.
|
||||
* Keep the [project definition](https://github.com/BookStackApp/BookStack#project-definition)
|
||||
in mind when deciding what's in scope of the Project.
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior. In addition, Project
|
||||
maintainers are responsible for following the standards themselves.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at the email address shown on [the profile here](https://github.com/ssddanbrown). All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
10
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
10
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -56,13 +56,3 @@ body:
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: ai-thoughts
|
||||
attributes:
|
||||
label: Have you used generative AI/LLMs to create any thoughts in this request?
|
||||
description: |
|
||||
We ask that no machine generated thoughts or ideas are provided, to avoid us spending time considering the ideas
|
||||
of a machine instead of a human. Further guidance on this can be found [in the BookStack community rules](https://www.bookstackapp.com/about/community-rules/#use-of-llmsai).
|
||||
options:
|
||||
- label: This request only contains the thoughts & ideas of a human
|
||||
required: true
|
||||
|
||||
11
.github/pull_request_template.md
vendored
11
.github/pull_request_template.md
vendored
@@ -1,11 +0,0 @@
|
||||
## Details
|
||||
|
||||
<!-- Write details of your pull request in here -->
|
||||
<!-- Include references to any relevant issues/discussions -->
|
||||
|
||||
## Checklist
|
||||
|
||||
<!-- Put an 'x' in between the brackets below to confirm these elements -->
|
||||
|
||||
- [ ] I have read the [BookStack community rules](https://www.bookstackapp.com/about/community-rules/).
|
||||
- [ ] This PR does not feature significant use of LLM/AI generation as per the community rules above.
|
||||
27
.github/translators.txt
vendored
27
.github/translators.txt
vendored
@@ -444,7 +444,7 @@ Irjan Olsen (Irch) :: Norwegian Bokmal
|
||||
Aleksandar Jovanovic (jovanoviczaleksandar) :: Serbian (Cyrillic)
|
||||
Red (RedVortex) :: Hebrew
|
||||
xgrug :: Chinese Simplified
|
||||
Calle Calmar (HrCalmar) :: Danish
|
||||
HrCalmar :: Danish
|
||||
Avishay Rapp (AvishayRapp) :: Hebrew
|
||||
matthias4217 :: French
|
||||
Berke BOYLU2 (berkeboylu2) :: Turkish
|
||||
@@ -512,28 +512,3 @@ David Olsen (dawin) :: Danish
|
||||
ltnzr :: French
|
||||
Frank Holler (holler.frank) :: German; German Informal
|
||||
Korab Arifi (korabidev) :: Albanian
|
||||
Petr Husák (petrhusak) :: Czech
|
||||
Bernardo Maia (bernardo.bmaia2) :: Portuguese, Brazilian
|
||||
Amr (amr3k) :: Arabic
|
||||
Tahsin Ahmed (tahsinahmed2012) :: Bengali
|
||||
bojan_che :: Serbian (Cyrillic)
|
||||
setiawan setiawan (culture.setiawan) :: Indonesian
|
||||
Donald Mac Kenzie (kiuman) :: Norwegian Bokmal
|
||||
Gabriel Silver (GabrielBSilver) :: Hebrew
|
||||
Tomas Darius Davainis (Tomasdd) :: Lithuanian
|
||||
CriedHero :: Chinese Simplified
|
||||
Henrik (henrik2105) :: Norwegian Bokmal
|
||||
FoW (fofwisdom) :: Korean
|
||||
serinf-lauza :: French
|
||||
Diyan Nikolaev (nikolaev.diyan) :: Bulgarian
|
||||
Shadluk Avan (quldosh) :: Uzbek
|
||||
Marci (MartonPoto) :: Hungarian
|
||||
Michał Sadurski (wheeskeey) :: Polish
|
||||
JanDziaslo :: Polish
|
||||
Charllys Fernandes (CharllysFernandes) :: Portuguese, Brazilian
|
||||
Ilgiz Zigangirov (inov8) :: Russian
|
||||
Max Israelsson (Blezie) :: Swedish
|
||||
Skiddybison5924 (chris-devel0per) :: German
|
||||
Veyilla Nightwhisper (Veyilla) :: German
|
||||
João Barbosa (hypeedd) :: Portuguese
|
||||
Abcdefg Hijklmn (collatek) :: Korean
|
||||
|
||||
2
.github/workflows/test-js.yml
vendored
2
.github/workflows/test-js.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install NPM deps
|
||||
run: npm ci
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -2,17 +2,16 @@
|
||||
/node_modules
|
||||
/.vscode
|
||||
/composer
|
||||
/composer.phar
|
||||
/coverage
|
||||
Homestead.yaml
|
||||
.env
|
||||
.idea
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/public/dist
|
||||
/public/dist/*.map
|
||||
/public/plugins
|
||||
/public/css
|
||||
/public/js
|
||||
/public/css/*.map
|
||||
/public/js/*.map
|
||||
/public/bower
|
||||
/public/build/
|
||||
/public/favicon.ico
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-2026, Dan Brown and the BookStack project contributors.
|
||||
Copyright (c) 2015-2025, Dan Brown and the BookStack project contributors.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -45,11 +45,11 @@ class ForgotPasswordController extends Controller
|
||||
);
|
||||
|
||||
if ($response === Password::RESET_LINK_SENT) {
|
||||
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->input('email'));
|
||||
$this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->get('email'));
|
||||
}
|
||||
|
||||
if (in_array($response, [Password::RESET_LINK_SENT, Password::INVALID_USER, Password::RESET_THROTTLED])) {
|
||||
$message = trans('auth.reset_password_sent', ['email' => $request->input('email')]);
|
||||
$message = trans('auth.reset_password_sent', ['email' => $request->get('email')]);
|
||||
$this->showSuccessNotification($message);
|
||||
|
||||
return redirect('/password/email')->with('status', trans($response));
|
||||
|
||||
@@ -32,12 +32,12 @@ class LoginController extends Controller
|
||||
{
|
||||
$socialDrivers = $this->socialDriverManager->getActive();
|
||||
$authMethod = config('auth.method');
|
||||
$preventInitiation = $request->input('prevent_auto_init') === 'true';
|
||||
$preventInitiation = $request->get('prevent_auto_init') === 'true';
|
||||
|
||||
if ($request->has('email')) {
|
||||
session()->flashInput([
|
||||
'email' => $request->input('email'),
|
||||
'password' => (config('app.env') === 'demo') ? $request->input('password', '') : '',
|
||||
'email' => $request->get('email'),
|
||||
'password' => (config('app.env') === 'demo') ? $request->get('password', '') : '',
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ class LoginController extends Controller
|
||||
public function login(Request $request)
|
||||
{
|
||||
$this->validateLogin($request);
|
||||
$username = $request->input($this->username());
|
||||
$username = $request->get($this->username());
|
||||
|
||||
// Check login throttling attempts to see if they've gone over the limit
|
||||
if ($this->hasTooManyLoginAttempts($request)) {
|
||||
|
||||
@@ -84,7 +84,7 @@ class MfaBackupCodesController extends Controller
|
||||
],
|
||||
]);
|
||||
|
||||
$updatedCodes = $codeService->removeInputCodeFromSet($request->input('code'), $codes);
|
||||
$updatedCodes = $codeService->removeInputCodeFromSet($request->get('code'), $codes);
|
||||
MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, $updatedCodes);
|
||||
|
||||
$mfaSession->markVerifiedForUser($user);
|
||||
|
||||
@@ -51,14 +51,14 @@ class MfaController extends Controller
|
||||
*/
|
||||
public function verify(Request $request)
|
||||
{
|
||||
$desiredMethod = $request->input('method');
|
||||
$desiredMethod = $request->get('method');
|
||||
$userMethods = $this->currentOrLastAttemptedUser()
|
||||
->mfaValues()
|
||||
->get(['id', 'method'])
|
||||
->groupBy('method');
|
||||
|
||||
// Basic search for the default option for a user.
|
||||
// (Prioritises TOTP over backup codes)
|
||||
// (Prioritises totp over backup codes)
|
||||
$method = $userMethods->has($desiredMethod) ? $desiredMethod : $userMethods->keys()->sort()->reverse()->first();
|
||||
$otherMethods = $userMethods->keys()->filter(function ($userMethod) use ($method) {
|
||||
return $method !== $userMethod;
|
||||
|
||||
@@ -9,9 +9,11 @@ use Illuminate\Http\Request;
|
||||
|
||||
class OidcController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected OidcService $oidcService
|
||||
) {
|
||||
protected OidcService $oidcService;
|
||||
|
||||
public function __construct(OidcService $oidcService)
|
||||
{
|
||||
$this->oidcService = $oidcService;
|
||||
$this->middleware('guard:oidc');
|
||||
}
|
||||
|
||||
@@ -28,7 +30,7 @@ class OidcController extends Controller
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
session()->put('oidc_state', time() . ':' . $loginDetails['state']);
|
||||
session()->flash('oidc_state', $loginDetails['state']);
|
||||
|
||||
return redirect($loginDetails['url']);
|
||||
}
|
||||
@@ -39,16 +41,10 @@ class OidcController extends Controller
|
||||
*/
|
||||
public function callback(Request $request)
|
||||
{
|
||||
$storedState = session()->pull('oidc_state');
|
||||
$responseState = $request->query('state');
|
||||
$splitState = explode(':', session()->pull('oidc_state', ':'), 2);
|
||||
if (count($splitState) !== 2) {
|
||||
$splitState = [null, null];
|
||||
}
|
||||
|
||||
[$storedStateTime, $storedState] = $splitState;
|
||||
$threeMinutesAgo = time() - 3 * 60;
|
||||
|
||||
if (!$storedState || $storedState !== $responseState || intval($storedStateTime) < $threeMinutesAgo) {
|
||||
if ($storedState !== $responseState) {
|
||||
$this->showErrorNotification(trans('errors.oidc_fail_authed', ['system' => config('oidc.name')]));
|
||||
|
||||
return redirect('/login');
|
||||
@@ -66,7 +62,7 @@ class OidcController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the user out, then start the OIDC RP-initiated logout process.
|
||||
* Log the user out then start the OIDC RP-initiated logout process.
|
||||
*/
|
||||
public function logout()
|
||||
{
|
||||
|
||||
@@ -48,7 +48,8 @@ class RegisterController extends Controller
|
||||
public function postRegister(Request $request)
|
||||
{
|
||||
$this->registrationService->ensureRegistrationAllowed();
|
||||
$userData = $this->validator($request->all())->validate();
|
||||
$this->validator($request->all())->validate();
|
||||
$userData = $request->all();
|
||||
|
||||
try {
|
||||
$user = $this->registrationService->registerUser($userData);
|
||||
|
||||
@@ -48,7 +48,7 @@ class ResetPasswordController extends Controller
|
||||
|
||||
// Here we will attempt to reset the user's password. If it is successful we
|
||||
// will update the password on an actual user model and persist it to the
|
||||
// database. Otherwise, we will parse the error and return the response.
|
||||
// database. Otherwise we will parse the error and return the response.
|
||||
$credentials = $request->only('email', 'password', 'password_confirmation', 'token');
|
||||
$response = Password::broker()->reset($credentials, function (User $user, string $password) {
|
||||
$user->password = Hash::make($password);
|
||||
@@ -63,7 +63,7 @@ class ResetPasswordController extends Controller
|
||||
// redirect them back to where they came from with their error message.
|
||||
return $response === Password::PASSWORD_RESET
|
||||
? $this->sendResetResponse()
|
||||
: $this->sendResetFailedResponse($request, $response, $request->input('token'));
|
||||
: $this->sendResetFailedResponse($request, $response, $request->get('token'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,7 +78,7 @@ class Saml2Controller extends Controller
|
||||
*/
|
||||
public function startAcs(Request $request)
|
||||
{
|
||||
$samlResponse = $request->input('SAMLResponse', null);
|
||||
$samlResponse = $request->get('SAMLResponse', null);
|
||||
|
||||
if (empty($samlResponse)) {
|
||||
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
|
||||
@@ -100,7 +100,7 @@ class Saml2Controller extends Controller
|
||||
*/
|
||||
public function processAcs(Request $request)
|
||||
{
|
||||
$acsId = $request->input('id', null);
|
||||
$acsId = $request->get('id', null);
|
||||
$cacheKey = 'saml2_acs:' . $acsId;
|
||||
$samlResponse = null;
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class SocialController extends Controller
|
||||
if ($request->has('error') && $request->has('error_description')) {
|
||||
throw new SocialSignInException(trans('errors.social_login_bad_response', [
|
||||
'socialAccount' => $socialDriver,
|
||||
'error' => $request->input('error_description'),
|
||||
'error' => $request->get('error_description'),
|
||||
]), '/login');
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ class UserInviteController extends Controller
|
||||
}
|
||||
|
||||
$user = $this->userRepo->getById($userId);
|
||||
$user->password = Hash::make($request->input('password'));
|
||||
$user->password = Hash::make($request->get('password'));
|
||||
$user->email_confirmed = true;
|
||||
$user->save();
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace BookStack\Access;
|
||||
use BookStack\Access\Notifications\ConfirmEmailNotification;
|
||||
use BookStack\Exceptions\ConfirmationEmailException;
|
||||
use BookStack\Users\Models\User;
|
||||
use Exception;
|
||||
|
||||
class EmailConfirmationService extends UserTokenService
|
||||
{
|
||||
@@ -17,7 +16,6 @@ class EmailConfirmationService extends UserTokenService
|
||||
* Also removes any existing old ones.
|
||||
*
|
||||
* @throws ConfirmationEmailException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function sendConfirmation(User $user): void
|
||||
{
|
||||
|
||||
@@ -71,7 +71,7 @@ class LoginService
|
||||
}
|
||||
|
||||
$lastLoginDetails = $this->getLastLoginAttemptDetails();
|
||||
$this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember']);
|
||||
$this->login($user, $lastLoginDetails['method'], $lastLoginDetails['remember'] ?? false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,16 +48,17 @@ class MfaValue extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the decrypted MFA value for the given user and method.
|
||||
* Easily get the decrypted MFA value for the given user and method.
|
||||
*/
|
||||
public static function getValueForUser(User $user, string $method): ?string
|
||||
{
|
||||
/** @var MfaValue $mfaVal */
|
||||
$mfaVal = static::query()
|
||||
->where('user_id', '=', $user->id)
|
||||
->where('method', '=', $method)
|
||||
->first();
|
||||
|
||||
return $mfaVal?->getValue();
|
||||
return $mfaVal ? $mfaVal->getValue() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,9 +14,10 @@ use PragmaRX\Google2FA\Support\Constants;
|
||||
|
||||
class TotpService
|
||||
{
|
||||
public function __construct(
|
||||
protected Google2FA $google2fa
|
||||
) {
|
||||
protected $google2fa;
|
||||
|
||||
public function __construct(Google2FA $google2fa)
|
||||
{
|
||||
$this->google2fa = $google2fa;
|
||||
// Use SHA1 as a default, Personal testing of other options in 2021 found
|
||||
// many apps lack support for other algorithms yet still will scan
|
||||
@@ -34,7 +35,7 @@ class TotpService
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a TOTP URL from a secret key.
|
||||
* Generate a TOTP URL from secret key.
|
||||
*/
|
||||
public function generateUrl(string $secret, User $user): string
|
||||
{
|
||||
|
||||
@@ -9,7 +9,10 @@ use phpseclib3\Math\BigInteger;
|
||||
|
||||
class OidcJwtSigningKey
|
||||
{
|
||||
protected PublicKey $key;
|
||||
/**
|
||||
* @var PublicKey
|
||||
*/
|
||||
protected $key;
|
||||
|
||||
/**
|
||||
* Can be created either from a JWK parameter array or local file path to load a certificate from.
|
||||
@@ -17,13 +20,15 @@ class OidcJwtSigningKey
|
||||
* 'file:///var/www/cert.pem'
|
||||
* ['kty' => 'RSA', 'alg' => 'RS256', 'n' => 'abc123...'].
|
||||
*
|
||||
* @param array|string $jwkOrKeyPath
|
||||
*
|
||||
* @throws OidcInvalidKeyException
|
||||
*/
|
||||
public function __construct(array|string $jwkOrKeyPath)
|
||||
public function __construct($jwkOrKeyPath)
|
||||
{
|
||||
if (is_array($jwkOrKeyPath)) {
|
||||
$this->loadFromJwkArray($jwkOrKeyPath);
|
||||
} elseif (str_starts_with($jwkOrKeyPath, 'file://')) {
|
||||
} elseif (is_string($jwkOrKeyPath) && strpos($jwkOrKeyPath, 'file://') === 0) {
|
||||
$this->loadFromPath($jwkOrKeyPath);
|
||||
} else {
|
||||
throw new OidcInvalidKeyException('Unexpected type of key value provided');
|
||||
@@ -33,7 +38,7 @@ class OidcJwtSigningKey
|
||||
/**
|
||||
* @throws OidcInvalidKeyException
|
||||
*/
|
||||
protected function loadFromPath(string $path): void
|
||||
protected function loadFromPath(string $path)
|
||||
{
|
||||
try {
|
||||
$key = PublicKeyLoader::load(
|
||||
@@ -53,7 +58,7 @@ class OidcJwtSigningKey
|
||||
/**
|
||||
* @throws OidcInvalidKeyException
|
||||
*/
|
||||
protected function loadFromJwkArray(array $jwk): void
|
||||
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.
|
||||
@@ -77,7 +82,7 @@ class OidcJwtSigningKey
|
||||
throw new OidcInvalidKeyException('A "n" parameter on the provided key is expected');
|
||||
}
|
||||
|
||||
$n = strtr($jwk['n'], '-_', '+/');
|
||||
$n = strtr($jwk['n'] ?? '', '-_', '+/');
|
||||
|
||||
try {
|
||||
$key = PublicKeyLoader::load([
|
||||
|
||||
@@ -102,12 +102,12 @@ class OidcJwtWithClaims implements ProvidesClaims
|
||||
protected function validateTokenStructure(): void
|
||||
{
|
||||
foreach (['header', 'payload'] as $prop) {
|
||||
if (empty($this->$prop)) {
|
||||
if (empty($this->$prop) || !is_array($this->$prop)) {
|
||||
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($this->signature)) {
|
||||
if (empty($this->signature) || !is_string($this->signature)) {
|
||||
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,11 +49,6 @@ class OidcService
|
||||
$url = $provider->getAuthorizationUrl();
|
||||
session()->put('oidc_pkce_code', $provider->getPkceCode() ?? '');
|
||||
|
||||
$returnUrl = Theme::dispatch(ThemeEvents::OIDC_AUTH_PRE_REDIRECT, $url);
|
||||
if (is_string($returnUrl)) {
|
||||
$url = $returnUrl;
|
||||
}
|
||||
|
||||
return [
|
||||
'url' => $url,
|
||||
'state' => $provider->getState(),
|
||||
|
||||
@@ -39,7 +39,7 @@ class OidcUserDetails
|
||||
): void {
|
||||
$this->externalId = $claims->getClaim($idClaim) ?? $this->externalId;
|
||||
$this->email = $claims->getClaim('email') ?? $this->email;
|
||||
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?: $this->name;
|
||||
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name;
|
||||
$this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups;
|
||||
$this->picture = static::getPicture($claims) ?: $this->picture;
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ class RegistrationService
|
||||
// Email restriction
|
||||
$this->ensureEmailDomainAllowed($userEmail);
|
||||
|
||||
// Ensure the user does not already exist
|
||||
// Ensure user does not already exist
|
||||
$alreadyUser = !is_null($this->userRepo->getByEmail($userEmail));
|
||||
if ($alreadyUser) {
|
||||
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login');
|
||||
@@ -99,7 +99,7 @@ class RegistrationService
|
||||
$newUser = $this->userRepo->createWithoutActivity($userData, $emailConfirmed);
|
||||
$newUser->attachDefaultRole();
|
||||
|
||||
// Assign a social account if given
|
||||
// Assign social account if given
|
||||
if ($socialAccount) {
|
||||
$newUser->socialAccounts()->save($socialAccount);
|
||||
}
|
||||
@@ -107,7 +107,7 @@ class RegistrationService
|
||||
Activity::add(ActivityType::AUTH_REGISTER, $socialAccount ?? $newUser);
|
||||
Theme::dispatch(ThemeEvents::AUTH_REGISTER, $authSystem, $newUser);
|
||||
|
||||
// Start the email confirmation flow if required
|
||||
// Start email confirmation flow if required
|
||||
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
|
||||
$newUser->save();
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ class Saml2Service
|
||||
/**
|
||||
* Extract the details of a user from a SAML response.
|
||||
*
|
||||
* @return array{external_id: string, name: string, email: string|null, saml_id: string}
|
||||
* @return array{external_id: string, name: string, email: string, saml_id: string}
|
||||
*/
|
||||
protected function getUserDetails(string $samlID, $samlAttributes): array
|
||||
{
|
||||
@@ -357,7 +357,7 @@ class Saml2Service
|
||||
]);
|
||||
}
|
||||
|
||||
if (empty($userDetails['email'])) {
|
||||
if ($userDetails['email'] === null) {
|
||||
throw new SamlException(trans('errors.saml_no_email_address'));
|
||||
}
|
||||
|
||||
|
||||
@@ -117,14 +117,14 @@ class SocialAuthService
|
||||
}
|
||||
|
||||
// When a user is logged in and the social account exists and is already linked to the current user.
|
||||
if ($isLoggedIn && $socialAccount->user->id === $currentUser->id) {
|
||||
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) {
|
||||
session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver]));
|
||||
|
||||
return redirect('/my-account/auth#social_accounts');
|
||||
}
|
||||
|
||||
// When a user is logged in, A social account exists but the users do not match.
|
||||
if ($isLoggedIn && $socialAccount->user->id != $currentUser->id) {
|
||||
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) {
|
||||
session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver]));
|
||||
|
||||
return redirect('/my-account/auth#social_accounts');
|
||||
|
||||
@@ -17,19 +17,19 @@ class AuditLogController extends Controller
|
||||
$this->checkPermission(Permission::SettingsManage);
|
||||
$this->checkPermission(Permission::UsersManage);
|
||||
|
||||
$sort = $request->input('sort', 'activity_date');
|
||||
$order = $request->input('order', 'desc');
|
||||
$sort = $request->get('sort', 'activity_date');
|
||||
$order = $request->get('order', 'desc');
|
||||
$listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([
|
||||
'created_at' => trans('settings.audit_table_date'),
|
||||
'type' => trans('settings.audit_table_event'),
|
||||
]);
|
||||
|
||||
$filters = [
|
||||
'event' => $request->input('event', ''),
|
||||
'date_from' => $request->input('date_from', ''),
|
||||
'date_to' => $request->input('date_to', ''),
|
||||
'user' => $request->input('user', ''),
|
||||
'ip' => $request->input('ip', ''),
|
||||
'event' => $request->get('event', ''),
|
||||
'date_from' => $request->get('date_from', ''),
|
||||
'date_to' => $request->get('date_to', ''),
|
||||
'user' => $request->get('user', ''),
|
||||
'ip' => $request->get('ip', ''),
|
||||
];
|
||||
|
||||
$query = Activity::query()
|
||||
|
||||
@@ -20,7 +20,7 @@ class FavouriteController extends Controller
|
||||
public function index(Request $request, QueryTopFavourites $topFavourites)
|
||||
{
|
||||
$viewCount = 20;
|
||||
$page = intval($request->input('page', 1));
|
||||
$page = intval($request->get('page', 1));
|
||||
$favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount));
|
||||
|
||||
$hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null;
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace BookStack\Activity\Controllers;
|
||||
|
||||
use BookStack\Activity\TagRepo;
|
||||
use BookStack\Http\ApiController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Endpoints to query data about tags in the system.
|
||||
* You'll only see results based on tags applied to content you have access to.
|
||||
* There are no general create/update/delete endpoints here since tags do not exist
|
||||
* by themselves, they are managed via the items they are assigned to.
|
||||
*/
|
||||
class TagApiController extends ApiController
|
||||
{
|
||||
public function __construct(
|
||||
protected TagRepo $tagRepo,
|
||||
) {
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'listValues' => [
|
||||
'name' => ['required', 'string'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of tag names used in the system.
|
||||
* Only the name field can be used in filters.
|
||||
*/
|
||||
public function listNames(): JsonResponse
|
||||
{
|
||||
$tagQuery = $this->tagRepo
|
||||
->queryWithTotalsForApi('');
|
||||
|
||||
return $this->apiListingResponse($tagQuery, [
|
||||
'name', 'values', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count',
|
||||
], [], [
|
||||
'name'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of tag values, which have been set for the given tag name,
|
||||
* which must be provided as a query parameter on the request.
|
||||
* Only the value field can be used in filters.
|
||||
*/
|
||||
public function listValues(Request $request): JsonResponse
|
||||
{
|
||||
$data = $this->validate($request, $this->rules()['listValues']);
|
||||
$name = $data['name'];
|
||||
|
||||
$tagQuery = $this->tagRepo->queryWithTotalsForApi($name);
|
||||
|
||||
return $this->apiListingResponse($tagQuery, [
|
||||
'name', 'value', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count',
|
||||
], [], [
|
||||
'value',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,9 @@ class TagController extends Controller
|
||||
'usages' => trans('entities.tags_usages'),
|
||||
]);
|
||||
|
||||
$nameFilter = $request->input('name', '');
|
||||
$nameFilter = $request->get('name', '');
|
||||
$tags = $this->tagRepo
|
||||
->queryWithTotalsForList($listOptions, $nameFilter)
|
||||
->queryWithTotals($listOptions, $nameFilter)
|
||||
->paginate(50)
|
||||
->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [
|
||||
'name' => $nameFilter,
|
||||
@@ -46,7 +46,7 @@ class TagController extends Controller
|
||||
*/
|
||||
public function getNameSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->input('search', '');
|
||||
$searchTerm = $request->get('search', '');
|
||||
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
|
||||
|
||||
return response()->json($suggestions);
|
||||
@@ -57,8 +57,8 @@ class TagController extends Controller
|
||||
*/
|
||||
public function getValueSuggestions(Request $request)
|
||||
{
|
||||
$searchTerm = $request->input('search', '');
|
||||
$tagName = $request->input('name', '');
|
||||
$searchTerm = $request->get('search', '');
|
||||
$tagName = $request->get('name', '');
|
||||
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
|
||||
|
||||
return response()->json($suggestions);
|
||||
|
||||
@@ -8,8 +8,6 @@ use BookStack\Permissions\PermissionApplicator;
|
||||
use BookStack\Users\Models\HasCreatorAndUpdater;
|
||||
use BookStack\Users\Models\OwnableInterface;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use BookStack\Util\HtmlToPlainText;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -84,14 +82,7 @@ class Comment extends Model implements Loggable, OwnableInterface
|
||||
|
||||
public function safeHtml(): string
|
||||
{
|
||||
$filter = new HtmlContentFilter(new HtmlContentFilterConfig());
|
||||
return $filter->filterString($this->html ?? '');
|
||||
}
|
||||
|
||||
public function getPlainText(): string
|
||||
{
|
||||
$converter = new HtmlToPlainText();
|
||||
return $converter->convert($this->html ?? '');
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($this->html ?? '');
|
||||
}
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $mentionable_type
|
||||
* @property int $mentionable_id
|
||||
* @property int $from_user_id
|
||||
* @property int $to_user_id
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
*/
|
||||
class MentionHistory extends Model
|
||||
{
|
||||
protected $table = 'mention_history';
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Handlers;
|
||||
|
||||
use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Models\MentionHistory;
|
||||
use BookStack\Activity\Notifications\Messages\CommentMentionNotification;
|
||||
use BookStack\Activity\Tools\MentionParser;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Settings\UserNotificationPreferences;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class CommentMentionNotificationHandler extends BaseNotificationHandler
|
||||
{
|
||||
public function handle(Activity $activity, Loggable|string $detail, User $user): void
|
||||
{
|
||||
if (!($detail instanceof Comment) || !($detail->entity instanceof Page)) {
|
||||
throw new \InvalidArgumentException("Detail for comment mention notifications must be a comment on a page");
|
||||
}
|
||||
|
||||
/** @var Page $page */
|
||||
$page = $detail->entity;
|
||||
|
||||
$parser = new MentionParser();
|
||||
$mentionedUserIds = $parser->parseUserIdsFromHtml($detail->html);
|
||||
$realMentionedUsers = User::whereIn('id', $mentionedUserIds)->get();
|
||||
|
||||
$receivingNotifications = $realMentionedUsers->filter(function (User $user) {
|
||||
$prefs = new UserNotificationPreferences($user);
|
||||
return $prefs->notifyOnCommentMentions();
|
||||
});
|
||||
$receivingNotificationsUserIds = $receivingNotifications->pluck('id')->toArray();
|
||||
|
||||
$userMentionsToLog = $realMentionedUsers;
|
||||
|
||||
// When an edit, we check our history to see if we've already notified the user about this comment before
|
||||
// so that we can filter them out to avoid double notifications.
|
||||
if ($activity->type === ActivityType::COMMENT_UPDATE) {
|
||||
$previouslyNotifiedUserIds = $this->getPreviouslyNotifiedUserIds($detail);
|
||||
$receivingNotificationsUserIds = array_values(array_diff($receivingNotificationsUserIds, $previouslyNotifiedUserIds));
|
||||
$userMentionsToLog = $userMentionsToLog->filter(function (User $user) use ($previouslyNotifiedUserIds) {
|
||||
return !in_array($user->id, $previouslyNotifiedUserIds);
|
||||
});
|
||||
}
|
||||
|
||||
$this->logMentions($userMentionsToLog, $detail, $user);
|
||||
$this->sendNotificationToUserIds(CommentMentionNotification::class, $receivingNotificationsUserIds, $user, $detail, $page);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<User> $mentionedUsers
|
||||
*/
|
||||
protected function logMentions(Collection $mentionedUsers, Comment $comment, User $fromUser): void
|
||||
{
|
||||
$mentions = [];
|
||||
$now = Carbon::now();
|
||||
|
||||
foreach ($mentionedUsers as $mentionedUser) {
|
||||
$mentions[] = [
|
||||
'mentionable_type' => $comment->getMorphClass(),
|
||||
'mentionable_id' => $comment->id,
|
||||
'from_user_id' => $fromUser->id,
|
||||
'to_user_id' => $mentionedUser->id,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
MentionHistory::query()->insert($mentions);
|
||||
}
|
||||
|
||||
protected function getPreviouslyNotifiedUserIds(Comment $comment): array
|
||||
{
|
||||
return MentionHistory::query()
|
||||
->where('mentionable_id', $comment->id)
|
||||
->where('mentionable_type', $comment->getMorphClass())
|
||||
->pluck('to_user_id')
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ class CommentCreationNotification extends BaseActivityNotification
|
||||
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
||||
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
||||
$locale->trans('notifications.detail_commenter') => $this->user->name,
|
||||
$locale->trans('notifications.detail_comment') => $comment->getPlainText(),
|
||||
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
|
||||
]);
|
||||
|
||||
return $this->newMailMessage($locale)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Notifications\Messages;
|
||||
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Activity\Notifications\MessageParts\EntityLinkMessageLine;
|
||||
use BookStack\Activity\Notifications\MessageParts\ListMessageLine;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class CommentMentionNotification extends BaseActivityNotification
|
||||
{
|
||||
public function toMail(User $notifiable): MailMessage
|
||||
{
|
||||
/** @var Comment $comment */
|
||||
$comment = $this->detail;
|
||||
/** @var Page $page */
|
||||
$page = $comment->entity;
|
||||
|
||||
$locale = $notifiable->getLocale();
|
||||
|
||||
$listLines = array_filter([
|
||||
$locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page),
|
||||
$locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable),
|
||||
$locale->trans('notifications.detail_commenter') => $this->user->name,
|
||||
$locale->trans('notifications.detail_comment') => $comment->getPlainText(),
|
||||
]);
|
||||
|
||||
return $this->newMailMessage($locale)
|
||||
->subject($locale->trans('notifications.comment_mention_subject', ['pageName' => $page->getShortName()]))
|
||||
->line($locale->trans('notifications.comment_mention_intro', ['appName' => setting('app-name')]))
|
||||
->line(new ListMessageLine($listLines))
|
||||
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
|
||||
->line($this->buildReasonFooterLine($locale));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ use BookStack\Activity\ActivityType;
|
||||
use BookStack\Activity\Models\Activity;
|
||||
use BookStack\Activity\Models\Loggable;
|
||||
use BookStack\Activity\Notifications\Handlers\CommentCreationNotificationHandler;
|
||||
use BookStack\Activity\Notifications\Handlers\CommentMentionNotificationHandler;
|
||||
use BookStack\Activity\Notifications\Handlers\NotificationHandler;
|
||||
use BookStack\Activity\Notifications\Handlers\PageCreationNotificationHandler;
|
||||
use BookStack\Activity\Notifications\Handlers\PageUpdateNotificationHandler;
|
||||
@@ -15,14 +14,14 @@ use BookStack\Users\Models\User;
|
||||
class NotificationManager
|
||||
{
|
||||
/**
|
||||
* @var array<string, class-string<NotificationHandler>[]>
|
||||
* @var class-string<NotificationHandler>[]
|
||||
*/
|
||||
protected array $handlersByActivity = [];
|
||||
protected array $handlers = [];
|
||||
|
||||
public function handle(Activity $activity, string|Loggable $detail, User $user): void
|
||||
{
|
||||
$activityType = $activity->type;
|
||||
$handlersToRun = $this->handlersByActivity[$activityType] ?? [];
|
||||
$handlersToRun = $this->handlers[$activityType] ?? [];
|
||||
foreach ($handlersToRun as $handlerClass) {
|
||||
/** @var NotificationHandler $handler */
|
||||
$handler = new $handlerClass();
|
||||
@@ -35,12 +34,12 @@ class NotificationManager
|
||||
*/
|
||||
public function registerHandler(string $activityType, string $handlerClass): void
|
||||
{
|
||||
if (!isset($this->handlersByActivity[$activityType])) {
|
||||
$this->handlersByActivity[$activityType] = [];
|
||||
if (!isset($this->handlers[$activityType])) {
|
||||
$this->handlers[$activityType] = [];
|
||||
}
|
||||
|
||||
if (!in_array($handlerClass, $this->handlersByActivity[$activityType])) {
|
||||
$this->handlersByActivity[$activityType][] = $handlerClass;
|
||||
if (!in_array($handlerClass, $this->handlers[$activityType])) {
|
||||
$this->handlers[$activityType][] = $handlerClass;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +48,5 @@ class NotificationManager
|
||||
$this->registerHandler(ActivityType::PAGE_CREATE, PageCreationNotificationHandler::class);
|
||||
$this->registerHandler(ActivityType::PAGE_UPDATE, PageUpdateNotificationHandler::class);
|
||||
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentCreationNotificationHandler::class);
|
||||
$this->registerHandler(ActivityType::COMMENT_CREATE, CommentMentionNotificationHandler::class);
|
||||
$this->registerHandler(ActivityType::COMMENT_UPDATE, CommentMentionNotificationHandler::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,9 @@ class TagRepo
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query against all tags in the system, with total counts for their usage,
|
||||
* suitable for a system interface list with listing options.
|
||||
* Start a query against all tags in the system.
|
||||
*/
|
||||
public function queryWithTotalsForList(SimpleListOptions $listOptions, string $nameFilter): Builder
|
||||
public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder
|
||||
{
|
||||
$searchTerm = $listOptions->getSearch();
|
||||
$sort = $listOptions->getSort();
|
||||
@@ -29,34 +28,17 @@ class TagRepo
|
||||
$sort = 'value';
|
||||
}
|
||||
|
||||
$query = $this->baseQueryWithTotals($nameFilter, $searchTerm)
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query against all tags in the system, with total counts for their usage,
|
||||
* which can be used via the API.
|
||||
*/
|
||||
public function queryWithTotalsForApi(string $nameFilter): Builder
|
||||
{
|
||||
$query = $this->baseQueryWithTotals($nameFilter, '');
|
||||
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
}
|
||||
|
||||
protected function baseQueryWithTotals(string $nameFilter, string $searchTerm): Builder
|
||||
{
|
||||
$query = Tag::query()
|
||||
->select([
|
||||
'name',
|
||||
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
|
||||
DB::raw('COUNT(id) as usages'),
|
||||
DB::raw('CAST(SUM(IF(entity_type = \'page\', 1, 0)) as UNSIGNED) as page_count'),
|
||||
DB::raw('CAST(SUM(IF(entity_type = \'chapter\', 1, 0)) as UNSIGNED) as chapter_count'),
|
||||
DB::raw('CAST(SUM(IF(entity_type = \'book\', 1, 0)) as UNSIGNED) as book_count'),
|
||||
DB::raw('CAST(SUM(IF(entity_type = \'bookshelf\', 1, 0)) as UNSIGNED) as shelf_count'),
|
||||
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'),
|
||||
])
|
||||
->orderBy($sort, $listOptions->getOrder())
|
||||
->whereHas('entity');
|
||||
|
||||
if ($nameFilter) {
|
||||
@@ -75,7 +57,7 @@ class TagRepo
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Activity\Tools;
|
||||
|
||||
use BookStack\Util\HtmlDocument;
|
||||
use DOMElement;
|
||||
|
||||
class MentionParser
|
||||
{
|
||||
public function parseUserIdsFromHtml(string $html): array
|
||||
{
|
||||
$doc = new HtmlDocument($html);
|
||||
|
||||
$ids = [];
|
||||
$mentionLinks = $doc->queryXPath('//a[@data-mention-user-id]');
|
||||
|
||||
foreach ($mentionLinks as $link) {
|
||||
if ($link instanceof DOMElement) {
|
||||
$id = intval($link->getAttribute('data-mention-user-id'));
|
||||
if ($id > 0) {
|
||||
$ids[] = $id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
}
|
||||
@@ -17,14 +17,7 @@ use ReflectionMethod;
|
||||
|
||||
class ApiDocsGenerator
|
||||
{
|
||||
/**
|
||||
* @var array<string, ReflectionClass>
|
||||
*/
|
||||
protected array $reflectionClasses = [];
|
||||
|
||||
/**
|
||||
* @var array<string, ApiController>
|
||||
*/
|
||||
protected array $controllerClasses = [];
|
||||
|
||||
/**
|
||||
@@ -114,6 +107,7 @@ class ApiDocsGenerator
|
||||
*/
|
||||
protected function getBodyParamsFromClass(string $className, string $methodName): ?array
|
||||
{
|
||||
/** @var ApiController $class */
|
||||
$class = $this->controllerClasses[$className] ?? null;
|
||||
if ($class === null) {
|
||||
$class = app()->make($className);
|
||||
@@ -159,7 +153,7 @@ class ApiDocsGenerator
|
||||
$matches = [];
|
||||
preg_match_all('/^\s*?\*\s?($|((?![\/@\s]).*?))$/m', $comment, $matches);
|
||||
|
||||
$text = implode(' ', $matches[1]);
|
||||
$text = implode(' ', $matches[1] ?? []);
|
||||
return str_replace(' ', "\n", $text);
|
||||
}
|
||||
|
||||
@@ -195,12 +189,11 @@ class ApiDocsGenerator
|
||||
protected function getFlatApiRoutes(): Collection
|
||||
{
|
||||
return collect(Route::getRoutes()->getRoutes())->filter(function ($route) {
|
||||
return str_starts_with($route->uri, 'api/');
|
||||
return strpos($route->uri, 'api/') === 0;
|
||||
})->map(function ($route) {
|
||||
[$controller, $controllerMethod] = explode('@', $route->action['uses']);
|
||||
$baseModelName = explode('.', explode('/', $route->uri)[1])[0];
|
||||
$controllerMethodKebab = Str::kebab($controllerMethod);
|
||||
$shortName = $baseModelName . '-' . $controllerMethodKebab;
|
||||
$shortName = $baseModelName . '-' . $controllerMethod;
|
||||
|
||||
return [
|
||||
'name' => $shortName,
|
||||
@@ -208,7 +201,7 @@ class ApiDocsGenerator
|
||||
'method' => $route->methods[0],
|
||||
'controller' => $controller,
|
||||
'controller_method' => $controllerMethod,
|
||||
'controller_method_kebab' => $controllerMethodKebab,
|
||||
'controller_method_kebab' => Str::kebab($controllerMethod),
|
||||
'base_model' => $baseModelName,
|
||||
];
|
||||
});
|
||||
|
||||
@@ -74,21 +74,18 @@ class ApiEntityListFormatter
|
||||
|
||||
/**
|
||||
* Include parent book/chapter info in the formatted data.
|
||||
* These functions are careful to not load the relation themselves, since they should
|
||||
* have already been loaded in a more efficient manner, with permissions applied, by the time
|
||||
* the parent fields are handled here.
|
||||
*/
|
||||
public function withParents(): self
|
||||
{
|
||||
$this->withField('book', function (Entity $entity) {
|
||||
if ($entity instanceof BookChild && $entity->relationLoaded('book') && $entity->getRelationValue('book')) {
|
||||
if ($entity instanceof BookChild && $entity->book) {
|
||||
return $entity->book->only(['id', 'name', 'slug']);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
$this->withField('chapter', function (Entity $entity) {
|
||||
if ($entity instanceof Page && $entity->relationLoaded('chapter') && $entity->getRelationValue('chapter')) {
|
||||
if ($entity instanceof Page && $entity->chapter) {
|
||||
return $entity->chapter->only(['id', 'name', 'slug']);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -17,14 +17,29 @@ class ApiTokenGuard implements Guard
|
||||
use GuardHelpers;
|
||||
|
||||
/**
|
||||
* The last auth exception thrown in this request.
|
||||
* The request instance.
|
||||
*/
|
||||
protected ApiAuthException|null $lastAuthException = null;
|
||||
protected $request;
|
||||
|
||||
public function __construct(
|
||||
protected Request $request,
|
||||
protected LoginService $loginService
|
||||
) {
|
||||
/**
|
||||
* @var LoginService
|
||||
*/
|
||||
protected $loginService;
|
||||
|
||||
/**
|
||||
* The last auth exception thrown in this request.
|
||||
*
|
||||
* @var ApiAuthException
|
||||
*/
|
||||
protected $lastAuthException;
|
||||
|
||||
/**
|
||||
* ApiTokenGuard constructor.
|
||||
*/
|
||||
public function __construct(Request $request, LoginService $loginService)
|
||||
{
|
||||
$this->request = $request;
|
||||
$this->loginService = $loginService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,7 +67,7 @@ class ApiTokenGuard implements Guard
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the current user is authenticated. If not, throw an exception.
|
||||
* Determine if current user is authenticated. If not, throw an exception.
|
||||
*
|
||||
* @throws ApiAuthException
|
||||
*
|
||||
@@ -106,7 +121,7 @@ class ApiTokenGuard implements Guard
|
||||
throw new ApiAuthException(trans('errors.api_no_authorization_found'));
|
||||
}
|
||||
|
||||
if (!str_contains($authToken, ':') || !str_starts_with($authToken, 'Token ')) {
|
||||
if (strpos($authToken, ':') === false || strpos($authToken, 'Token ') !== 0) {
|
||||
throw new ApiAuthException(trans('errors.api_bad_authorization_format'));
|
||||
}
|
||||
}
|
||||
@@ -140,7 +155,7 @@ class ApiTokenGuard implements Guard
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function validate(array $credentials = []): bool
|
||||
public function validate(array $credentials = [])
|
||||
{
|
||||
if (empty($credentials['id']) || empty($credentials['secret'])) {
|
||||
return false;
|
||||
@@ -160,7 +175,7 @@ class ApiTokenGuard implements Guard
|
||||
/**
|
||||
* "Log out" the currently authenticated user.
|
||||
*/
|
||||
public function logout(): void
|
||||
public function logout()
|
||||
{
|
||||
$this->user = null;
|
||||
}
|
||||
|
||||
@@ -18,13 +18,6 @@ class ListingResponseBuilder
|
||||
*/
|
||||
protected array $fields;
|
||||
|
||||
/**
|
||||
* Which fields are filterable.
|
||||
* When null, the $fields above are used instead (Allow all fields).
|
||||
* @var string[]|null
|
||||
*/
|
||||
protected array|null $filterableFields = null;
|
||||
|
||||
/**
|
||||
* @var array<callable>
|
||||
*/
|
||||
@@ -61,7 +54,7 @@ class ListingResponseBuilder
|
||||
{
|
||||
$filteredQuery = $this->filterQuery($this->query);
|
||||
|
||||
$total = $filteredQuery->getCountForPagination();
|
||||
$total = $filteredQuery->count();
|
||||
$data = $this->fetchData($filteredQuery)->each(function ($model) {
|
||||
foreach ($this->resultModifiers as $modifier) {
|
||||
$modifier($model);
|
||||
@@ -84,14 +77,6 @@ class ListingResponseBuilder
|
||||
$this->resultModifiers[] = $modifier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit filtering to just the given set of fields.
|
||||
*/
|
||||
public function setFilterableFields(array $fields): void
|
||||
{
|
||||
$this->filterableFields = $fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the data to return within the response.
|
||||
*/
|
||||
@@ -109,7 +94,7 @@ class ListingResponseBuilder
|
||||
protected function filterQuery(Builder $query): Builder
|
||||
{
|
||||
$query = clone $query;
|
||||
$requestFilters = $this->request->input('filter', []);
|
||||
$requestFilters = $this->request->get('filter', []);
|
||||
if (!is_array($requestFilters)) {
|
||||
return $query;
|
||||
}
|
||||
@@ -129,11 +114,10 @@ class ListingResponseBuilder
|
||||
protected function requestFilterToQueryFilter($fieldKey, $value): ?array
|
||||
{
|
||||
$splitKey = explode(':', $fieldKey);
|
||||
$field = strtolower($splitKey[0]);
|
||||
$field = $splitKey[0];
|
||||
$filterOperator = $splitKey[1] ?? 'eq';
|
||||
|
||||
$filterFields = $this->filterableFields ?? $this->fields;
|
||||
if (!in_array($field, $filterFields)) {
|
||||
if (!in_array($field, $this->fields)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -156,8 +140,8 @@ class ListingResponseBuilder
|
||||
$defaultSortName = $this->fields[0];
|
||||
$direction = 'asc';
|
||||
|
||||
$sort = $this->request->input('sort', '');
|
||||
if (str_starts_with($sort, '-')) {
|
||||
$sort = $this->request->get('sort', '');
|
||||
if (strpos($sort, '-') === 0) {
|
||||
$direction = 'desc';
|
||||
}
|
||||
|
||||
@@ -176,9 +160,9 @@ class ListingResponseBuilder
|
||||
protected function countAndOffsetQuery(Builder $query): Builder
|
||||
{
|
||||
$query = clone $query;
|
||||
$offset = max(0, $this->request->input('offset', 0));
|
||||
$offset = max(0, $this->request->get('offset', 0));
|
||||
$maxCount = config('api.max_item_count');
|
||||
$count = $this->request->input('count', config('api.default_item_count'));
|
||||
$count = $this->request->get('count', config('api.default_item_count'));
|
||||
$count = max(min($maxCount, $count), 1);
|
||||
|
||||
return $query->skip($offset)->take($count);
|
||||
|
||||
@@ -48,11 +48,11 @@ class UserApiTokenController extends Controller
|
||||
$secret = Str::random(32);
|
||||
|
||||
$token = (new ApiToken())->forceFill([
|
||||
'name' => $request->input('name'),
|
||||
'name' => $request->get('name'),
|
||||
'token_id' => Str::random(32),
|
||||
'secret' => Hash::make($secret),
|
||||
'user_id' => $user->id,
|
||||
'expires_at' => $request->input('expires_at') ?: ApiToken::defaultExpiry(),
|
||||
'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(),
|
||||
]);
|
||||
|
||||
while (ApiToken::query()->where('token_id', '=', $token->token_id)->exists()) {
|
||||
@@ -100,8 +100,8 @@ class UserApiTokenController extends Controller
|
||||
|
||||
[$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);
|
||||
$token->fill([
|
||||
'name' => $request->input('name'),
|
||||
'expires_at' => $request->input('expires_at') ?: ApiToken::defaultExpiry(),
|
||||
'name' => $request->get('name'),
|
||||
'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(),
|
||||
])->save();
|
||||
|
||||
$this->logActivity(ActivityType::API_TOKEN_UPDATE, $token);
|
||||
|
||||
@@ -83,7 +83,7 @@ class HomeController extends Controller
|
||||
if ($homepageOption === 'bookshelves') {
|
||||
$shelves = $this->queries->shelves->visibleForListWithCover()
|
||||
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
||||
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
|
||||
->paginate(18);
|
||||
$data = array_merge($commonData, ['shelves' => $shelves]);
|
||||
|
||||
return view('home.shelves', $data);
|
||||
@@ -92,7 +92,7 @@ class HomeController extends Controller
|
||||
if ($homepageOption === 'books') {
|
||||
$books = $this->queries->books->visibleForListWithCover()
|
||||
->orderBy($commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder())
|
||||
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
|
||||
->paginate(18);
|
||||
$data = array_merge($commonData, ['books' => $books]);
|
||||
|
||||
return view('home.books', $data);
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace BookStack\App\Providers;
|
||||
|
||||
use BookStack\Access\SocialDriverManager;
|
||||
use BookStack\Activity\Models\Comment;
|
||||
use BookStack\Activity\Tools\ActivityLogger;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
@@ -65,13 +64,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
URL::forceScheme($isHttps ? 'https' : 'http');
|
||||
}
|
||||
|
||||
// Set SMTP mail driver to use a local domain matching the app domain,
|
||||
// which helps avoid defaulting to a 127.0.0.1 domain
|
||||
if ($appUrl) {
|
||||
$hostName = parse_url($appUrl, PHP_URL_HOST) ?: null;
|
||||
config()->set('mail.mailers.smtp.local_domain', $hostName);
|
||||
}
|
||||
|
||||
// Allow longer string lengths after upgrade to utf8mb4
|
||||
Schema::defaultStringLength(191);
|
||||
|
||||
@@ -81,7 +73,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
'book' => Book::class,
|
||||
'chapter' => Chapter::class,
|
||||
'page' => Page::class,
|
||||
'comment' => Comment::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ namespace BookStack\App\Providers;
|
||||
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use BookStack\Theming\ThemeService;
|
||||
use BookStack\Theming\ThemeViews;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class ThemeServiceProvider extends ServiceProvider
|
||||
@@ -26,26 +24,7 @@ class ThemeServiceProvider extends ServiceProvider
|
||||
{
|
||||
// Boot up the theme system
|
||||
$themeService = $this->app->make(ThemeService::class);
|
||||
$viewFactory = $this->app->make('view');
|
||||
$themeViews = new ThemeViews($viewFactory->getFinder());
|
||||
|
||||
// Use a custom include so that we can insert theme views before/after includes.
|
||||
// This is done, even if no theme is active, so that view caching does not create problems
|
||||
// when switching between themes or when switching a theme on/off.
|
||||
$viewFactory->share('__themeViews', $themeViews);
|
||||
Blade::directive('include', function ($expression) {
|
||||
return "<?php echo \$__themeViews->handleViewInclude({$expression}, array_diff_key(get_defined_vars(), ['__data' => 1, '__path' => 1])); ?>";
|
||||
});
|
||||
|
||||
if (!$themeService->getTheme()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$themeService->loadModules();
|
||||
$themeService->readThemeActions();
|
||||
$themeService->dispatch(ThemeEvents::APP_BOOT, $this->app);
|
||||
|
||||
$themeViews->registerViewPathsForTheme($themeService->getModules());
|
||||
$themeService->dispatch(ThemeEvents::THEME_REGISTER_VIEWS, $themeViews);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ namespace BookStack\App;
|
||||
/**
|
||||
* Assigned to models that can have slugs.
|
||||
* Must have the below properties.
|
||||
*
|
||||
* @property string $slug
|
||||
*/
|
||||
interface SluggableInterface
|
||||
{
|
||||
/**
|
||||
* Regenerate the slug for this model.
|
||||
*/
|
||||
public function refreshSlug(): string;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,8 @@ function setting(?string $key = null, mixed $default = null): mixed
|
||||
|
||||
/**
|
||||
* Get a path to a theme resource.
|
||||
* Returns null if a theme is not configured, and therefore a full path is not available for use.
|
||||
* Returns null if a theme is not configured and
|
||||
* therefore a full path is not available for use.
|
||||
*/
|
||||
function theme_path(string $path = ''): ?string
|
||||
{
|
||||
|
||||
@@ -37,15 +37,10 @@ return [
|
||||
// The limit for all uploaded files, including images and attachments in MB.
|
||||
'upload_limit' => env('FILE_UPLOAD_SIZE_LIMIT', 50),
|
||||
|
||||
// Control the behaviour of content filtering, primarily used for page content.
|
||||
// This setting is a string of characters which represent different available filters:
|
||||
// - j - Filter out JavaScript and unknown binary data based content
|
||||
// - h - Filter out unexpected, and potentially dangerous, HTML elements
|
||||
// - f - Filter out unexpected form elements
|
||||
// - a - Run content through a more complex allowlist filter
|
||||
// This defaults to using all filters, unless ALLOW_CONTENT_SCRIPTS is set to true in which case no filters are used.
|
||||
// Note: These filters are a best-attempt and may not be 100% effective. They are typically a layer used in addition to other security measures.
|
||||
'content_filtering' => env('APP_CONTENT_FILTERING', env('ALLOW_CONTENT_SCRIPTS', false) === true ? '' : 'jhfa'),
|
||||
// Allow <script> tags to entered within page content.
|
||||
// <script> tags are escaped by default.
|
||||
// Even when overridden the WYSIWYG editor may still escape script content.
|
||||
'allow_content_scripts' => env('ALLOW_CONTENT_SCRIPTS', false),
|
||||
|
||||
// Allow server-side fetches to be performed to potentially unknown
|
||||
// and user-provided locations. Primarily used in exports when loading
|
||||
@@ -53,8 +48,8 @@ return [
|
||||
'allow_untrusted_server_fetching' => env('ALLOW_UNTRUSTED_SERVER_FETCHING', false),
|
||||
|
||||
// Override the default behaviour for allowing crawlers to crawl the instance.
|
||||
// May be ignored if the underlying view has been overridden or modified.
|
||||
// Defaults to null in which case the 'app-public' status is used instead.
|
||||
// May be ignored if view has be overridden or modified.
|
||||
// Defaults to null since, if not set, 'app-public' status used instead.
|
||||
'allow_robots' => env('ALLOW_ROBOTS', null),
|
||||
|
||||
// Application Base URL, Used by laravel in development commands
|
||||
|
||||
@@ -81,8 +81,7 @@ return [
|
||||
'strict' => false,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
// @phpstan-ignore class.notFound
|
||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ return [
|
||||
* Times-Roman, Times-Bold, Times-BoldItalic, Times-Italic,
|
||||
* Symbol, ZapfDingbats.
|
||||
*/
|
||||
'font_dir' => storage_path('fonts/dompdf'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
|
||||
'font_dir' => storage_path('fonts/'), // advised by dompdf (https://github.com/dompdf/dompdf/pull/782)
|
||||
|
||||
/**
|
||||
* The location of the DOMPDF font cache directory.
|
||||
@@ -78,7 +78,7 @@ return [
|
||||
*
|
||||
* Note: This directory must exist and be writable by the webserver process.
|
||||
*/
|
||||
'font_cache' => storage_path('fonts/dompdf/cache'),
|
||||
'font_cache' => storage_path('fonts/'),
|
||||
|
||||
/**
|
||||
* The location of a temporary directory.
|
||||
|
||||
@@ -41,7 +41,6 @@ return [
|
||||
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
|
||||
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
|
||||
'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'),
|
||||
'notifications#comment-mentions' => true,
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||
*/
|
||||
|
||||
// Join up possible view locations
|
||||
$viewPaths = [realpath(base_path('resources/views'))];
|
||||
if ($theme = env('APP_THEME', false)) {
|
||||
array_unshift($viewPaths, base_path('themes/' . $theme));
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
// App theme
|
||||
@@ -20,7 +26,7 @@ return [
|
||||
// Most templating systems load templates from disk. Here you may specify
|
||||
// an array of paths that should be checked for your views. Of course
|
||||
// the usual Laravel view path has already been registered for you.
|
||||
'paths' => [realpath(base_path('resources/views'))],
|
||||
'paths' => $viewPaths,
|
||||
|
||||
// Compiled View Path
|
||||
// This option determines where all the compiled Blade templates will be
|
||||
|
||||
@@ -32,7 +32,7 @@ class AssignSortRuleCommand extends Command
|
||||
*/
|
||||
public function handle(BookSorter $sorter): int
|
||||
{
|
||||
$sortRuleId = intval($this->argument('sort-rule'));
|
||||
$sortRuleId = intval($this->argument('sort-rule')) ?? 0;
|
||||
if ($sortRuleId === 0) {
|
||||
return $this->listSortRules();
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ class CopyShelfPermissionsCommand extends Command
|
||||
{
|
||||
$shelfSlug = $this->option('slug');
|
||||
$cascadeAll = $this->option('all');
|
||||
$noInteraction = boolval($this->option('no-interaction'));
|
||||
$shelves = null;
|
||||
|
||||
if (!$cascadeAll && !$shelfSlug) {
|
||||
@@ -42,16 +41,14 @@ class CopyShelfPermissionsCommand extends Command
|
||||
}
|
||||
|
||||
if ($cascadeAll) {
|
||||
if (!$noInteraction) {
|
||||
$continue = $this->confirm(
|
||||
'Permission settings for all shelves will be cascaded. ' .
|
||||
'Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. ' .
|
||||
'Are you sure you want to proceed?',
|
||||
);
|
||||
$continue = $this->confirm(
|
||||
'Permission settings for all shelves will be cascaded. ' .
|
||||
'Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. ' .
|
||||
'Are you sure you want to proceed?'
|
||||
);
|
||||
|
||||
if (!$continue) {
|
||||
return 0;
|
||||
}
|
||||
if (!$continue && !$this->hasOption('no-interaction')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$shelves = $queries->start()->get(['id']);
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Http\HttpRequestService;
|
||||
use BookStack\Theming\ThemeModule;
|
||||
use BookStack\Theming\ThemeModuleException;
|
||||
use BookStack\Theming\ThemeModuleManager;
|
||||
use BookStack\Theming\ThemeModuleZip;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class InstallModuleCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bookstack:install-module
|
||||
{location : The URL or path of the module file}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Install a module to the currently configured theme';
|
||||
|
||||
protected array $cleanupActions = [];
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$location = $this->argument('location');
|
||||
|
||||
// Get the ZIP file containing the module files
|
||||
$zipPath = $this->getPathToZip($location);
|
||||
if (!$zipPath) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Validate module zip file (metadata, size, etc...) and get module instance
|
||||
$zip = new ThemeModuleZip($zipPath);
|
||||
$themeModule = $this->validateAndGetModuleInfoFromZip($zip);
|
||||
if (!$themeModule) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get the theme folder in use, attempting to create one if no active theme in use
|
||||
$themeFolder = $this->getThemeFolder();
|
||||
if (!$themeFolder) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get the modules folder of the theme, attempting to create it if not existing,
|
||||
// and create a new module manager instance.
|
||||
$moduleFolder = $this->getModuleFolder($themeFolder);
|
||||
if (!$moduleFolder) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
$manager = new ThemeModuleManager($moduleFolder);
|
||||
|
||||
// Handle existing modules with the same name
|
||||
$exitingModulesWithName = $manager->getByName($themeModule->name);
|
||||
$shouldContinue = $this->handleExistingModulesWithSameName($exitingModulesWithName, $manager);
|
||||
if (!$shouldContinue) {
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Extract module ZIP into the theme modules folder
|
||||
try {
|
||||
$newModule = $manager->addFromZip($themeModule->name, $zip);
|
||||
} catch (ThemeModuleException $exception) {
|
||||
$this->error("ERROR: Failed to install module with error: {$exception->getMessage()}");
|
||||
$this->cleanup();
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Module \"{$newModule->name}\" ({$newModule->getVersion()}) successfully installed!");
|
||||
$this->info("Install location: {$moduleFolder}/{$newModule->folderName}");
|
||||
$this->cleanup();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ThemeModule[] $existingModules
|
||||
*/
|
||||
protected function handleExistingModulesWithSameName(array $existingModules, ThemeModuleManager $manager): bool
|
||||
{
|
||||
if (count($existingModules) === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->warn("The following modules already exist with the same name:");
|
||||
foreach ($existingModules as $folder => $module) {
|
||||
$this->line("{$module->name} ({$folder}:{$module->getVersion()}) - {$module->description}");
|
||||
}
|
||||
$this->line('');
|
||||
|
||||
$choices = ['Cancel module install', 'Add alongside existing module'];
|
||||
if (count($existingModules) === 1) {
|
||||
$choices[] = 'Replace existing module';
|
||||
}
|
||||
$choice = $this->choice("What would you like to do?", $choices, 0, null, false);
|
||||
if ($choice === 'Cancel module install') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($choice === 'Replace existing module') {
|
||||
$existingModuleFolder = array_key_first($existingModules);
|
||||
$this->info("Replacing existing module in {$existingModuleFolder} folder");
|
||||
$manager->deleteModuleFolder($existingModuleFolder);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getModuleFolder(string $themeFolder): string|null
|
||||
{
|
||||
$path = $themeFolder . DIRECTORY_SEPARATOR . 'modules';
|
||||
|
||||
if (file_exists($path) && !is_dir($path)) {
|
||||
$this->error("ERROR: Cannot create a modules folder, file already exists at {$path}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!file_exists($path)) {
|
||||
$created = mkdir($path, 0755, true);
|
||||
if (!$created) {
|
||||
$this->error("ERROR: Failed to create a modules folder at {$path}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
protected function getThemeFolder(): string|null
|
||||
{
|
||||
$path = theme_path('');
|
||||
if (!$path || !is_dir($path)) {
|
||||
$shouldCreate = $this->confirm('No active theme folder found, would you like to create one?');
|
||||
if (!$shouldCreate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$folder = 'custom';
|
||||
while (file_exists(base_path("themes" . DIRECTORY_SEPARATOR . $folder))) {
|
||||
$folder = 'custom-' . Str::random(4);
|
||||
}
|
||||
|
||||
$path = base_path("themes/{$folder}");
|
||||
$created = mkdir($path, 0755, true);
|
||||
if (!$created) {
|
||||
$this->error('Failed to create a theme folder to use. This may be a permissions issue. Try manually configuring an active theme');
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->info("Created theme folder at {$path}");
|
||||
$this->warn("You will need to set APP_THEME={$folder} in your BookStack env configuration to enable this theme!");
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
protected function validateAndGetModuleInfoFromZip(ThemeModuleZip $zip): ThemeModule|null
|
||||
{
|
||||
if (!$zip->exists()) {
|
||||
$this->error("ERROR: Cannot open ZIP file at {$zip->getPath()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($zip->getContentsSize() > (50 * 1024 * 1024)) {
|
||||
$this->error("ERROR: Module ZIP file contents are too large. Maximum size is 50MB");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$themeModule = $zip->getModuleInstance();
|
||||
} catch (ThemeModuleException $exception) {
|
||||
$this->error("ERROR: Failed to read module metadata with error: {$exception->getMessage()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return $themeModule;
|
||||
}
|
||||
|
||||
protected function downloadModuleFile(string $location): string|null
|
||||
{
|
||||
$httpRequests = app()->make(HttpRequestService::class);
|
||||
$client = $httpRequests->buildClient(30, ['stream' => true]);
|
||||
$originalUrl = parse_url($location);
|
||||
$currentLocation = $location;
|
||||
$maxRedirects = 3;
|
||||
$redirectCount = 0;
|
||||
|
||||
// Follow redirects up to 3 times for the same hostname
|
||||
do {
|
||||
$resp = $client->sendRequest(new Request('GET', $currentLocation));
|
||||
$statusCode = $resp->getStatusCode();
|
||||
|
||||
if ($statusCode >= 300 && $statusCode < 400 && $redirectCount < $maxRedirects) {
|
||||
$redirectLocation = $resp->getHeaderLine('Location');
|
||||
if ($redirectLocation) {
|
||||
$redirectUrl = parse_url($redirectLocation);
|
||||
$redirectOriginMatches = ($originalUrl['host'] ?? '') === ($redirectUrl['host'] ?? '')
|
||||
&& ($originalUrl['scheme'] ?? '') === ($redirectUrl['scheme'] ?? '')
|
||||
&& ($originalUrl['port'] ?? '') === ($redirectUrl['port'] ?? '');
|
||||
|
||||
if (!$redirectOriginMatches) {
|
||||
$redirectOrigin = ($redirectUrl['scheme'] ?? '') . '://' . ($redirectUrl['host'] ?? '') . (isset($redirectUrl['port']) ? ':' . $redirectUrl['port'] : '');
|
||||
$this->info("The download URL is redirecting to a different site: {$redirectOrigin}");
|
||||
$shouldContinue = $this->confirm("Do you trust downloading the module from this site?");
|
||||
if (!$shouldContinue) {
|
||||
$this->error("Stopping module installation");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$currentLocation = $redirectLocation;
|
||||
$redirectCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
} while (true);
|
||||
|
||||
if ($resp->getStatusCode() >= 300) {
|
||||
$this->error("ERROR: Failed to download module from {$location}");
|
||||
$this->error("Download failed with status code {$resp->getStatusCode()}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'bookstack_module_');
|
||||
$fileHandle = fopen($tempFile, 'w');
|
||||
$respBody = $resp->getBody();
|
||||
$size = 0;
|
||||
$maxSize = 50 * 1024 * 1024;
|
||||
|
||||
while (!$respBody->eof()) {
|
||||
fwrite($fileHandle, $respBody->read(1024));
|
||||
$size += 1024;
|
||||
if ($size > $maxSize) {
|
||||
fclose($fileHandle);
|
||||
unlink($tempFile);
|
||||
$this->error("ERROR: Module ZIP file is too large. Maximum size is 50MB");
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
fclose($fileHandle);
|
||||
|
||||
$this->cleanupActions[] = function () use ($tempFile) {
|
||||
unlink($tempFile);
|
||||
};
|
||||
|
||||
return $tempFile;
|
||||
}
|
||||
|
||||
protected function getPathToZip(string $location): string|null
|
||||
{
|
||||
$lowerLocation = strtolower($location);
|
||||
$isRemote = str_starts_with($lowerLocation, 'http://') || str_starts_with($lowerLocation, 'https://');
|
||||
|
||||
if ($isRemote) {
|
||||
// Warning about fetching from source
|
||||
$host = parse_url($location, PHP_URL_HOST);
|
||||
$this->warn("\nThis will download a module from: {$host}\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.");
|
||||
$trustHost = $this->confirm('Are you sure you trust this source?');
|
||||
if (!$trustHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if the connection is http. If so, warn the user.
|
||||
if (str_starts_with($lowerLocation, 'http://')) {
|
||||
$this->warn("You are downloading a module from an insecure HTTP source.\nWe recommend only using HTTPS sources to avoid various security risks.");
|
||||
if (!$this->confirm('Are you sure you want to continue without HTTPS?')) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Download ZIP and get its location
|
||||
return $this->downloadModuleFile($location);
|
||||
}
|
||||
|
||||
// Validate the file and get the full location
|
||||
$zipPath = realpath($location);
|
||||
|
||||
if (!$zipPath || !is_file($zipPath)) {
|
||||
$this->error("ERROR: Module file not found at {$location}");
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->warn("\nThis will install a module from: {$zipPath}\n\nModules can contain code which would have the ability to do anything on the BookStack host server.\nYou should only install modules from trusted sources.");
|
||||
$trustHost = $this->confirm('Are you sure you want to install this module?');
|
||||
if (!$trustHost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $zipPath;
|
||||
}
|
||||
|
||||
protected function cleanup(): void
|
||||
{
|
||||
foreach ($this->cleanupActions as $action) {
|
||||
$action();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,14 +7,11 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Http\ApiController;
|
||||
use BookStack\Permissions\Permission;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@@ -24,7 +21,6 @@ class BookApiController extends ApiController
|
||||
protected BookRepo $bookRepo,
|
||||
protected BookQueries $queries,
|
||||
protected PageQueries $pageQueries,
|
||||
protected BookshelfQueries $shelfQueries,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -64,20 +60,13 @@ class BookApiController extends ApiController
|
||||
* View the details of a single book.
|
||||
* The response data will contain a 'content' property listing the chapter and pages directly within, in
|
||||
* the same structure as you'd see within the BookStack interface when viewing a book. Top-level
|
||||
* contents will have a 'type' property to distinguish between pages and chapters.
|
||||
* contents will have a 'type' property to distinguish between pages & chapters.
|
||||
*/
|
||||
public function read(string $id)
|
||||
{
|
||||
$book = $this->queries->findVisibleByIdOrFail(intval($id));
|
||||
$book = $this->forJsonDisplay($book);
|
||||
$book->load([
|
||||
'createdBy',
|
||||
'updatedBy',
|
||||
'ownedBy',
|
||||
'shelves' => function (BelongsToMany $query) {
|
||||
$query->select(['id', 'name', 'slug'])->scopes('visible');
|
||||
}
|
||||
]);
|
||||
$book->load(['createdBy', 'updatedBy', 'ownedBy']);
|
||||
|
||||
$contents = (new BookContents($book))->getTree(true, false)->all();
|
||||
$contentsApiData = (new ApiEntityListFormatter($contents))
|
||||
|
||||
@@ -8,7 +8,6 @@ use BookStack\Activity\Models\View;
|
||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\Cloner;
|
||||
@@ -32,7 +31,6 @@ class BookController extends Controller
|
||||
protected ShelfContext $shelfContext,
|
||||
protected BookRepo $bookRepo,
|
||||
protected BookQueries $queries,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected BookshelfQueries $shelfQueries,
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
) {
|
||||
@@ -52,7 +50,7 @@ class BookController extends Controller
|
||||
|
||||
$books = $this->queries->visibleForListWithCover()
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
||||
->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000));
|
||||
->paginate(18);
|
||||
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false;
|
||||
$popular = $this->queries->popularForList()->take(4)->get();
|
||||
$new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get();
|
||||
@@ -129,22 +127,13 @@ class BookController extends Controller
|
||||
*/
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
try {
|
||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
} catch (NotFoundException $exception) {
|
||||
$book = $this->entityQueries->findVisibleByOldSlugs('book', $slug);
|
||||
if (is_null($book)) {
|
||||
throw $exception;
|
||||
}
|
||||
return redirect($book->getUrl());
|
||||
}
|
||||
|
||||
$book = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$bookChildren = (new BookContents($book))->getTree(true);
|
||||
$bookParentShelves = $book->shelves()->scopes('visible')->get();
|
||||
|
||||
View::incrementFor($book);
|
||||
if ($request->has('shelf')) {
|
||||
$this->shelfContext->setShelfContext(intval($request->input('shelf')));
|
||||
$this->shelfContext->setShelfContext(intval($request->get('shelf')));
|
||||
}
|
||||
|
||||
$this->setPageTitle($book->getShortName());
|
||||
@@ -224,14 +213,9 @@ class BookController extends Controller
|
||||
{
|
||||
$book = $this->queries->findVisibleBySlugOrFail($bookSlug);
|
||||
$this->checkOwnablePermission(Permission::BookDelete, $book);
|
||||
$contextShelf = $this->shelfContext->getContextualShelfForBook($book);
|
||||
|
||||
$this->bookRepo->destroy($book);
|
||||
|
||||
if ($contextShelf) {
|
||||
return redirect($contextShelf->getUrl());
|
||||
}
|
||||
|
||||
return redirect('/books');
|
||||
}
|
||||
|
||||
@@ -263,7 +247,7 @@ class BookController extends Controller
|
||||
$this->checkOwnablePermission(Permission::BookView, $book);
|
||||
$this->checkPermission(Permission::BookCreateAll);
|
||||
|
||||
$newName = $request->input('name') ?: $book->name;
|
||||
$newName = $request->get('name') ?: $book->name;
|
||||
$bookCopy = $cloner->cloneBook($book, $newName);
|
||||
$this->showSuccessNotification(trans('entities.books_copy_success'));
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ class BookshelfApiController extends ApiController
|
||||
$this->checkPermission(Permission::BookshelfCreateAll);
|
||||
$requestData = $this->validate($request, $this->rules()['create']);
|
||||
|
||||
$bookIds = $request->input('books', []);
|
||||
$bookIds = $request->get('books', []);
|
||||
$shelf = $this->bookshelfRepo->create($requestData, $bookIds);
|
||||
|
||||
return response()->json($this->forJsonDisplay($shelf));
|
||||
@@ -88,7 +88,7 @@ class BookshelfApiController extends ApiController
|
||||
$this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf);
|
||||
|
||||
$requestData = $this->validate($request, $this->rules()['update']);
|
||||
$bookIds = $request->input('books', null);
|
||||
$bookIds = $request->get('books', null);
|
||||
|
||||
$shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds);
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ use BookStack\Activity\ActivityQueries;
|
||||
use BookStack\Activity\Models\View;
|
||||
use BookStack\Entities\Queries\BookQueries;
|
||||
use BookStack\Entities\Queries\BookshelfQueries;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Entities\Tools\ShelfContext;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
@@ -24,7 +23,6 @@ class BookshelfController extends Controller
|
||||
public function __construct(
|
||||
protected BookshelfRepo $shelfRepo,
|
||||
protected BookshelfQueries $queries,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected BookQueries $bookQueries,
|
||||
protected ShelfContext $shelfContext,
|
||||
protected ReferenceFetcher $referenceFetcher,
|
||||
@@ -45,7 +43,7 @@ class BookshelfController extends Controller
|
||||
|
||||
$shelves = $this->queries->visibleForListWithCover()
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder())
|
||||
->paginate(setting()->getInteger('lists-page-count-shelves', 18, 1, 1000));
|
||||
->paginate(18);
|
||||
$recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->get() : false;
|
||||
$popular = $this->queries->popularForList()->get();
|
||||
$new = $this->queries->visibleForList()
|
||||
@@ -94,7 +92,7 @@ class BookshelfController extends Controller
|
||||
'tags' => ['array'],
|
||||
]);
|
||||
|
||||
$bookIds = explode(',', $request->input('books', ''));
|
||||
$bookIds = explode(',', $request->get('books', ''));
|
||||
$shelf = $this->shelfRepo->create($validated, $bookIds);
|
||||
|
||||
return redirect($shelf->getUrl());
|
||||
@@ -107,16 +105,7 @@ class BookshelfController extends Controller
|
||||
*/
|
||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
||||
{
|
||||
try {
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
} catch (NotFoundException $exception) {
|
||||
$shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug);
|
||||
if (is_null($shelf)) {
|
||||
throw $exception;
|
||||
}
|
||||
return redirect($shelf->getUrl());
|
||||
}
|
||||
|
||||
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
|
||||
$this->checkOwnablePermission(Permission::BookshelfView, $shelf);
|
||||
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
|
||||
@@ -196,7 +185,7 @@ class BookshelfController extends Controller
|
||||
unset($validated['image']);
|
||||
}
|
||||
|
||||
$bookIds = explode(',', $request->input('books', ''));
|
||||
$bookIds = explode(',', $request->get('books', ''));
|
||||
$shelf = $this->shelfRepo->update($shelf, $validated, $bookIds);
|
||||
|
||||
return redirect($shelf->getUrl());
|
||||
|
||||
@@ -64,7 +64,7 @@ class ChapterApiController extends ApiController
|
||||
{
|
||||
$requestData = $this->validate($request, $this->rules['create']);
|
||||
|
||||
$bookId = $request->input('book_id');
|
||||
$bookId = $request->get('book_id');
|
||||
$book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId));
|
||||
$this->checkOwnablePermission(Permission::ChapterCreate, $book);
|
||||
|
||||
|
||||
@@ -77,15 +77,7 @@ class ChapterController extends Controller
|
||||
*/
|
||||
public function show(string $bookSlug, string $chapterSlug)
|
||||
{
|
||||
try {
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
} catch (NotFoundException $exception) {
|
||||
$chapter = $this->entityQueries->findVisibleByOldSlugs('chapter', $chapterSlug, $bookSlug);
|
||||
if (is_null($chapter)) {
|
||||
throw $exception;
|
||||
}
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
|
||||
$sidebarTree = (new BookContents($chapter->book))->getTree();
|
||||
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
|
||||
@@ -203,7 +195,7 @@ class ChapterController extends Controller
|
||||
$this->checkOwnablePermission(Permission::ChapterUpdate, $chapter);
|
||||
$this->checkOwnablePermission(Permission::ChapterDelete, $chapter);
|
||||
|
||||
$entitySelection = $request->input('entity_selection', null);
|
||||
$entitySelection = $request->get('entity_selection', null);
|
||||
if ($entitySelection === null || $entitySelection === '') {
|
||||
return redirect($chapter->getUrl());
|
||||
}
|
||||
@@ -248,7 +240,7 @@ class ChapterController extends Controller
|
||||
{
|
||||
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
|
||||
|
||||
$entitySelection = $request->input('entity_selection') ?: null;
|
||||
$entitySelection = $request->get('entity_selection') ?: null;
|
||||
$newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent();
|
||||
|
||||
if (!$newParentBook instanceof Book) {
|
||||
@@ -259,7 +251,7 @@ class ChapterController extends Controller
|
||||
|
||||
$this->checkOwnablePermission(Permission::ChapterCreate, $newParentBook);
|
||||
|
||||
$newName = $request->input('name') ?: $chapter->name;
|
||||
$newName = $request->get('name') ?: $chapter->name;
|
||||
$chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName);
|
||||
$this->showSuccessNotification(trans('entities.chapters_copy_success'));
|
||||
|
||||
|
||||
@@ -74,9 +74,9 @@ class PageApiController extends ApiController
|
||||
$this->validate($request, $this->rules['create']);
|
||||
|
||||
if ($request->has('chapter_id')) {
|
||||
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id')));
|
||||
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id')));
|
||||
} else {
|
||||
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('book_id')));
|
||||
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
|
||||
}
|
||||
$this->checkOwnablePermission(Permission::PageCreate, $parent);
|
||||
|
||||
@@ -133,9 +133,9 @@ class PageApiController extends ApiController
|
||||
|
||||
$parent = null;
|
||||
if ($request->has('chapter_id')) {
|
||||
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id')));
|
||||
$parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id')));
|
||||
} elseif ($request->has('book_id')) {
|
||||
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('book_id')));
|
||||
$parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id')));
|
||||
}
|
||||
|
||||
if ($parent && !$parent->matches($page->getParent())) {
|
||||
|
||||
@@ -17,12 +17,11 @@ use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditActivity;
|
||||
use BookStack\Entities\Tools\PageEditorData;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\NotifyException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\References\ReferenceFetcher;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -88,7 +87,7 @@ class PageController extends Controller
|
||||
|
||||
$page = $this->pageRepo->getNewDraftPage($parent);
|
||||
$this->pageRepo->publishDraft($page, [
|
||||
'name' => $request->input('name'),
|
||||
'name' => $request->get('name'),
|
||||
]);
|
||||
|
||||
return redirect($page->getUrl('/edit'));
|
||||
@@ -141,7 +140,9 @@ class PageController extends Controller
|
||||
try {
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
} catch (NotFoundException $e) {
|
||||
$page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug);
|
||||
$revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
|
||||
$page = $revision->page ?? null;
|
||||
|
||||
if (is_null($page)) {
|
||||
throw $e;
|
||||
}
|
||||
@@ -175,7 +176,7 @@ class PageController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a page from an ajax request.
|
||||
* Get page from an ajax request.
|
||||
*
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
@@ -185,10 +186,6 @@ class PageController extends Controller
|
||||
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
|
||||
$page->makeHidden(['book']);
|
||||
|
||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||
$filter = new HtmlContentFilter($filterConfig);
|
||||
$page->html = $filter->filterString($page->html);
|
||||
|
||||
return response()->json($page);
|
||||
}
|
||||
|
||||
@@ -408,7 +405,7 @@ class PageController extends Controller
|
||||
$this->checkOwnablePermission(Permission::PageUpdate, $page);
|
||||
$this->checkOwnablePermission(Permission::PageDelete, $page);
|
||||
|
||||
$entitySelection = $request->input('entity_selection', null);
|
||||
$entitySelection = $request->get('entity_selection', null);
|
||||
if ($entitySelection === null || $entitySelection === '') {
|
||||
return redirect($page->getUrl());
|
||||
}
|
||||
@@ -453,7 +450,7 @@ class PageController extends Controller
|
||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission(Permission::PageView, $page);
|
||||
|
||||
$entitySelection = $request->input('entity_selection') ?: null;
|
||||
$entitySelection = $request->get('entity_selection') ?: null;
|
||||
$newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent();
|
||||
|
||||
if (!$newParent instanceof Book && !$newParent instanceof Chapter) {
|
||||
@@ -464,7 +461,7 @@ class PageController extends Controller
|
||||
|
||||
$this->checkOwnablePermission(Permission::PageCreate, $newParent);
|
||||
|
||||
$newName = $request->input('name') ?: $page->name;
|
||||
$newName = $request->get('name') ?: $page->name;
|
||||
$pageCopy = $cloner->clonePage($page, $newParent, $newName);
|
||||
$this->showSuccessNotification(trans('entities.pages_copy_success'));
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Http\Controller;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Http\Request;
|
||||
use Ssddanbrown\HtmlDiff\Diff;
|
||||
@@ -34,7 +32,6 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function index(Request $request, string $bookSlug, string $pageSlug)
|
||||
{
|
||||
$this->checkPermission(Permission::RevisionViewAll);
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([
|
||||
'id' => trans('entities.pages_revisions_sort_number')
|
||||
@@ -66,8 +63,6 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function show(string $bookSlug, string $pageSlug, int $revisionId)
|
||||
{
|
||||
$this->checkPermission(Permission::RevisionViewAll);
|
||||
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||
@@ -97,8 +92,6 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function changes(string $bookSlug, string $pageSlug, int $revisionId)
|
||||
{
|
||||
$this->checkPermission(Permission::RevisionViewAll);
|
||||
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
|
||||
@@ -108,15 +101,12 @@ class PageRevisionController extends Controller
|
||||
|
||||
$prev = $revision->getPreviousRevision();
|
||||
$prevContent = $prev->html ?? '';
|
||||
|
||||
// TODO - Refactor PageContent so we can de-dupe these steps
|
||||
$rawDiff = Diff::excecute($prevContent, $revision->html);
|
||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||
$filter = new HtmlContentFilter($filterConfig);
|
||||
$diff = $filter->filterString($rawDiff);
|
||||
$diff = Diff::excecute($prevContent, $revision->html);
|
||||
|
||||
$page->fill($revision->toArray());
|
||||
$page->html = '';
|
||||
// TODO - Refactor PageContent so we don't need to juggle this
|
||||
$page->html = $revision->html;
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
|
||||
|
||||
return view('pages.revision', [
|
||||
@@ -134,7 +124,6 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function restore(string $bookSlug, string $pageSlug, int $revisionId)
|
||||
{
|
||||
$this->checkPermission(Permission::RevisionViewAll);
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission(Permission::PageUpdate, $page);
|
||||
|
||||
@@ -150,7 +139,6 @@ class PageRevisionController extends Controller
|
||||
*/
|
||||
public function destroy(string $bookSlug, string $pageSlug, int $revId)
|
||||
{
|
||||
$this->checkPermission(Permission::RevisionViewAll);
|
||||
$page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||
$this->checkOwnablePermission(Permission::PageDelete, $page);
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ class PageTemplateController extends Controller
|
||||
*/
|
||||
public function list(Request $request)
|
||||
{
|
||||
$page = $request->input('page', 1);
|
||||
$search = $request->input('search', '');
|
||||
$page = $request->get('page', 1);
|
||||
$search = $request->get('search', '');
|
||||
$count = 10;
|
||||
|
||||
$query = $this->pageQueries->visibleTemplates()
|
||||
|
||||
@@ -17,7 +17,7 @@ use Illuminate\Support\Collection;
|
||||
*
|
||||
* @property string $description
|
||||
* @property string $description_html
|
||||
* @property ?int $image_id
|
||||
* @property int $image_id
|
||||
* @property ?int $default_template_id
|
||||
* @property ?int $sort_rule_id
|
||||
* @property \Illuminate\Database\Eloquent\Collection $chapters
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
@@ -16,10 +17,34 @@ abstract class BookChild extends Entity
|
||||
{
|
||||
/**
|
||||
* Get the book this page sits in.
|
||||
* @return BelongsTo<Book, $this>
|
||||
*/
|
||||
public function book(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Book::class)->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the book that this entity belongs to.
|
||||
*/
|
||||
public function changeBook(int $newBookId): self
|
||||
{
|
||||
$oldUrl = $this->getUrl();
|
||||
$this->book_id = $newBookId;
|
||||
$this->unsetRelation('book');
|
||||
$this->refreshSlug();
|
||||
$this->save();
|
||||
|
||||
if ($oldUrl !== $this->getUrl()) {
|
||||
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
|
||||
}
|
||||
|
||||
// Update all child pages if a chapter
|
||||
if ($this instanceof Chapter) {
|
||||
foreach ($this->pages()->withTrashed()->get() as $page) {
|
||||
$page->changeBook($newBookId);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInter
|
||||
|
||||
public float $searchFactor = 1.2;
|
||||
|
||||
protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
|
||||
protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id'];
|
||||
protected $fillable = ['name'];
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ use BookStack\Activity\Models\Viewable;
|
||||
use BookStack\Activity\Models\Watch;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\App\SluggableInterface;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Permissions\JointPermissionBuilder;
|
||||
use BookStack\Permissions\Models\EntityPermission;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
@@ -404,6 +405,16 @@ abstract class Entity extends Model implements
|
||||
app()->make(SearchIndex::class)->indexEntity(clone $this);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function refreshSlug(): string
|
||||
{
|
||||
$this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
|
||||
|
||||
return $this->slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -430,14 +441,6 @@ abstract class Entity extends Model implements
|
||||
return $this->morphMany(Watch::class, 'watchable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the related slug history for this entity.
|
||||
*/
|
||||
public function slugHistory(): MorphMany
|
||||
{
|
||||
return $this->morphMany(SlugHistory::class, 'sluggable');
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
@@ -479,7 +482,6 @@ abstract class Entity extends Model implements
|
||||
'chapter' => new Chapter(),
|
||||
'book' => new Book(),
|
||||
'bookshelf' => new Bookshelf(),
|
||||
default => throw new \InvalidArgumentException("Invalid entity type: {$type}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
* @property bool $draft
|
||||
* @property int $revision_count
|
||||
* @property string $editor
|
||||
* @property Chapter|null $chapter
|
||||
* @property Chapter $chapter
|
||||
* @property Collection $attachments
|
||||
* @property Collection $revisions
|
||||
* @property PageRevision $currentRevision
|
||||
@@ -124,14 +124,6 @@ class Page extends BookChild
|
||||
return url('/' . implode('/', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID-based permalink for this page.
|
||||
*/
|
||||
public function getPermalink(): string
|
||||
{
|
||||
return url("/link/{$this->id}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this page for JSON display.
|
||||
*/
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\App\Model;
|
||||
use BookStack\Permissions\Models\JointPermission;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $sluggable_id
|
||||
* @property string $sluggable_type
|
||||
* @property string $slug
|
||||
* @property ?string $parent_slug
|
||||
*/
|
||||
class SlugHistory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'slug_history';
|
||||
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class, 'entity_id', 'sluggable_id')
|
||||
->whereColumn('joint_permissions.entity_type', '=', 'slug_history.sluggable_type');
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\EntityTable;
|
||||
use BookStack\Entities\Tools\SlugHistory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
@@ -19,7 +18,6 @@ class EntityQueries
|
||||
public ChapterQueries $chapters,
|
||||
public PageQueries $pages,
|
||||
public PageRevisionQueries $revisions,
|
||||
protected SlugHistory $slugHistory,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -33,30 +31,9 @@ class EntityQueries
|
||||
$explodedId = explode(':', $identifier);
|
||||
$entityType = $explodedId[0];
|
||||
$entityId = intval($explodedId[1]);
|
||||
$queries = $this->getQueriesForType($entityType);
|
||||
|
||||
return $this->findVisibleById($entityType, $entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an entity by its ID.
|
||||
*/
|
||||
public function findVisibleById(string $type, int $id): ?Entity
|
||||
{
|
||||
$queries = $this->getQueriesForType($type);
|
||||
return $queries->findVisibleById($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an entity by looking up old slugs in the slug history.
|
||||
*/
|
||||
public function findVisibleByOldSlugs(string $type, string $slug, string $parentSlug = ''): ?Entity
|
||||
{
|
||||
$id = $this->slugHistory->lookupEntityIdUsingSlugs($type, $slug, $parentSlug);
|
||||
if ($id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->findVisibleById($type, $id);
|
||||
return $queries->findVisibleById($entityId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,15 +8,12 @@ use BookStack\Entities\Models\HasCoverInterface;
|
||||
use BookStack\Entities\Models\HasDescriptionInterface;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Entities\Tools\SlugHistory;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\References\ReferenceStore;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use BookStack\Sorting\BookSorter;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Util\HtmlDescriptionFilter;
|
||||
use BookStack\Util\HtmlToPlainText;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class BaseRepo
|
||||
@@ -28,8 +25,6 @@ class BaseRepo
|
||||
protected ReferenceStore $referenceStore,
|
||||
protected PageQueries $pageQueries,
|
||||
protected BookSorter $bookSorter,
|
||||
protected SlugGenerator $slugGenerator,
|
||||
protected SlugHistory $slugHistory,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -48,7 +43,7 @@ class BaseRepo
|
||||
'updated_by' => user()->id,
|
||||
'owned_by' => user()->id,
|
||||
]);
|
||||
$this->refreshSlug($entity);
|
||||
$entity->refreshSlug();
|
||||
|
||||
if ($entity instanceof HasDescriptionInterface) {
|
||||
$this->updateDescription($entity, $input);
|
||||
@@ -83,7 +78,7 @@ class BaseRepo
|
||||
$entity->updated_by = user()->id;
|
||||
|
||||
if ($entity->isDirty('name') || empty($entity->slug)) {
|
||||
$this->refreshSlug($entity);
|
||||
$entity->refreshSlug();
|
||||
}
|
||||
|
||||
if ($entity instanceof HasDescriptionInterface) {
|
||||
@@ -152,22 +147,12 @@ class BaseRepo
|
||||
}
|
||||
|
||||
if (isset($input['description_html'])) {
|
||||
$plainTextConverter = new HtmlToPlainText();
|
||||
$entity->descriptionInfo()->set(
|
||||
HtmlDescriptionFilter::filterFromString($input['description_html']),
|
||||
$plainTextConverter->convert($input['description_html']),
|
||||
html_entity_decode(strip_tags($input['description_html']))
|
||||
);
|
||||
} else if (isset($input['description'])) {
|
||||
$entity->descriptionInfo()->set('', $input['description']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the slug for the given entity.
|
||||
*/
|
||||
public function refreshSlug(Entity $entity): void
|
||||
{
|
||||
$this->slugHistory->recordForEntity($entity);
|
||||
$this->slugGenerator->regenerateForEntity($entity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\ParentChanger;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
@@ -22,7 +21,6 @@ class ChapterRepo
|
||||
protected BaseRepo $baseRepo,
|
||||
protected EntityQueries $entityQueries,
|
||||
protected TrashCan $trashCan,
|
||||
protected ParentChanger $parentChanger,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -99,7 +97,7 @@ class ChapterRepo
|
||||
}
|
||||
|
||||
return (new DatabaseTransaction(function () use ($chapter, $parent) {
|
||||
$this->parentChanger->changeBook($chapter, $parent->id);
|
||||
$chapter = $chapter->changeBook($parent->id);
|
||||
$chapter->rebuildPermissions();
|
||||
Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditorType;
|
||||
use BookStack\Entities\Tools\ParentChanger;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
@@ -32,7 +31,6 @@ class PageRepo
|
||||
protected ReferenceStore $referenceStore,
|
||||
protected ReferenceUpdater $referenceUpdater,
|
||||
protected TrashCan $trashCan,
|
||||
protected ParentChanger $parentChanger,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -60,7 +58,7 @@ class PageRepo
|
||||
$page->book_id = $parent->id;
|
||||
}
|
||||
|
||||
$defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book->defaultTemplate()->get();
|
||||
$defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book?->defaultTemplate()->get();
|
||||
if ($defaultTemplate) {
|
||||
$page->forceFill([
|
||||
'html' => $defaultTemplate->html,
|
||||
@@ -244,7 +242,7 @@ class PageRepo
|
||||
}
|
||||
|
||||
$page->updated_by = user()->id;
|
||||
$this->baseRepo->refreshSlug($page);
|
||||
$page->refreshSlug();
|
||||
$page->save();
|
||||
$page->indexForSearch();
|
||||
$this->referenceStore->updateForEntity($page);
|
||||
@@ -286,7 +284,7 @@ class PageRepo
|
||||
return (new DatabaseTransaction(function () use ($page, $parent) {
|
||||
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
|
||||
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
|
||||
$this->parentChanger->changeBook($page, $newBookId);
|
||||
$page = $page->changeBook($newBookId);
|
||||
$page->rebuildPermissions();
|
||||
|
||||
Activity::add(ActivityType::PAGE_MOVE, $page);
|
||||
|
||||
@@ -13,47 +13,30 @@ use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\References\ReferenceChangeContext;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use BookStack\Uploads\Image;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class Cloner
|
||||
{
|
||||
protected ReferenceChangeContext $referenceChangeContext;
|
||||
|
||||
public function __construct(
|
||||
protected PageRepo $pageRepo,
|
||||
protected ChapterRepo $chapterRepo,
|
||||
protected BookRepo $bookRepo,
|
||||
protected ImageService $imageService,
|
||||
protected ReferenceUpdater $referenceUpdater,
|
||||
) {
|
||||
$this->referenceChangeContext = new ReferenceChangeContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone the given page into the given parent using the provided name.
|
||||
*/
|
||||
public function clonePage(Page $original, Entity $parent, string $newName): Page
|
||||
{
|
||||
$context = $this->newReferenceChangeContext();
|
||||
$page = $this->createPageClone($original, $parent, $newName);
|
||||
$this->referenceUpdater->changeReferencesUsingContext($context);
|
||||
return $page;
|
||||
}
|
||||
|
||||
protected function createPageClone(Page $original, Entity $parent, string $newName): Page
|
||||
{
|
||||
$copyPage = $this->pageRepo->getNewDraftPage($parent);
|
||||
$pageData = $this->entityToInputData($original);
|
||||
$pageData['name'] = $newName;
|
||||
|
||||
$newPage = $this->pageRepo->publishDraft($copyPage, $pageData);
|
||||
$this->referenceChangeContext->add($original, $newPage);
|
||||
|
||||
return $newPage;
|
||||
return $this->pageRepo->publishDraft($copyPage, $pageData);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,14 +44,6 @@ class Cloner
|
||||
* Clones all child pages.
|
||||
*/
|
||||
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
|
||||
{
|
||||
$context = $this->newReferenceChangeContext();
|
||||
$chapter = $this->createChapterClone($original, $parent, $newName);
|
||||
$this->referenceUpdater->changeReferencesUsingContext($context);
|
||||
return $chapter;
|
||||
}
|
||||
|
||||
protected function createChapterClone(Chapter $original, Book $parent, string $newName): Chapter
|
||||
{
|
||||
$chapterDetails = $this->entityToInputData($original);
|
||||
$chapterDetails['name'] = $newName;
|
||||
@@ -78,12 +53,10 @@ class Cloner
|
||||
if (userCan(Permission::PageCreate, $copyChapter)) {
|
||||
/** @var Page $page */
|
||||
foreach ($original->getVisiblePages() as $page) {
|
||||
$this->createPageClone($page, $copyChapter, $page->name);
|
||||
$this->clonePage($page, $copyChapter, $page->name);
|
||||
}
|
||||
}
|
||||
|
||||
$this->referenceChangeContext->add($original, $copyChapter);
|
||||
|
||||
return $copyChapter;
|
||||
}
|
||||
|
||||
@@ -92,14 +65,6 @@ class Cloner
|
||||
* Clones all child chapters and pages.
|
||||
*/
|
||||
public function cloneBook(Book $original, string $newName): Book
|
||||
{
|
||||
$context = $this->newReferenceChangeContext();
|
||||
$book = $this->createBookClone($original, $newName);
|
||||
$this->referenceUpdater->changeReferencesUsingContext($context);
|
||||
return $book;
|
||||
}
|
||||
|
||||
protected function createBookClone(Book $original, string $newName): Book
|
||||
{
|
||||
$bookDetails = $this->entityToInputData($original);
|
||||
$bookDetails['name'] = $newName;
|
||||
@@ -111,11 +76,11 @@ class Cloner
|
||||
$directChildren = $original->getDirectVisibleChildren();
|
||||
foreach ($directChildren as $child) {
|
||||
if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) {
|
||||
$this->createChapterClone($child, $copyBook, $child->name);
|
||||
$this->cloneChapter($child, $copyBook, $child->name);
|
||||
}
|
||||
|
||||
if ($child instanceof Page && !$child->draft && userCan(Permission::PageCreate, $copyBook)) {
|
||||
$this->createPageClone($child, $copyBook, $child->name);
|
||||
$this->clonePage($child, $copyBook, $child->name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,8 +92,6 @@ class Cloner
|
||||
}
|
||||
}
|
||||
|
||||
$this->referenceChangeContext->add($original, $copyBook);
|
||||
|
||||
return $copyBook;
|
||||
}
|
||||
|
||||
@@ -192,10 +155,4 @@ class Cloner
|
||||
|
||||
return $tags;
|
||||
}
|
||||
|
||||
protected function newReferenceChangeContext(): ReferenceChangeContext
|
||||
{
|
||||
$this->referenceChangeContext = new ReferenceChangeContext();
|
||||
return $this->referenceChangeContext;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
|
||||
class EntityHtmlDescription
|
||||
{
|
||||
@@ -51,13 +50,7 @@ class EntityHtmlDescription
|
||||
return $html;
|
||||
}
|
||||
|
||||
$isEmpty = empty(trim(strip_tags($html)));
|
||||
if ($isEmpty) {
|
||||
return '<p></p>';
|
||||
}
|
||||
|
||||
$filter = new HtmlContentFilter(new HtmlContentFilterConfig());
|
||||
return $filter->filterString($html);
|
||||
return HtmlContentFilter::removeScriptsFromHtmlString($html);
|
||||
}
|
||||
|
||||
public function getPlain(): string
|
||||
|
||||
@@ -17,8 +17,7 @@ class HierarchyTransformer
|
||||
protected BookRepo $bookRepo,
|
||||
protected BookshelfRepo $shelfRepo,
|
||||
protected Cloner $cloner,
|
||||
protected TrashCan $trashCan,
|
||||
protected ParentChanger $parentChanger,
|
||||
protected TrashCan $trashCan
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -36,7 +35,7 @@ class HierarchyTransformer
|
||||
foreach ($chapter->pages as $page) {
|
||||
$page->chapter_id = 0;
|
||||
$page->save();
|
||||
$this->parentChanger->changeBook($page, $book->id);
|
||||
$page->changeBook($book->id);
|
||||
}
|
||||
|
||||
$this->trashCan->destroyEntity($chapter);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\App\AppVersion;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Queries\PageQueries;
|
||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||
@@ -14,9 +13,7 @@ use BookStack\Uploads\ImageRepo;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Users\Models\User;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
use BookStack\Util\HtmlDocument;
|
||||
use BookStack\Util\HtmlToPlainText;
|
||||
use BookStack\Util\WebSafeMimeSniffer;
|
||||
use Closure;
|
||||
use DOMElement;
|
||||
@@ -40,14 +37,7 @@ class PageContent
|
||||
public function setNewHTML(string $html, User $updater): void
|
||||
{
|
||||
$html = $this->extractBase64ImagesFromHtml($html, $updater);
|
||||
$html = $this->formatHtml($html);
|
||||
|
||||
$themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_PRE_STORE, $html, $this->page);
|
||||
if (is_string($themeResult)) {
|
||||
$html = $themeResult;
|
||||
}
|
||||
|
||||
$this->page->html = $html;
|
||||
$this->page->html = $this->formatHtml($html);
|
||||
$this->page->text = $this->toPlainText();
|
||||
$this->page->markdown = '';
|
||||
}
|
||||
@@ -60,14 +50,7 @@ class PageContent
|
||||
$markdown = $this->extractBase64ImagesFromMarkdown($markdown, $updater);
|
||||
$this->page->markdown = $markdown;
|
||||
$html = (new MarkdownToHtml($markdown))->convert();
|
||||
$html = $this->formatHtml($html);
|
||||
|
||||
$themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_PRE_STORE, $html, $this->page);
|
||||
if (is_string($themeResult)) {
|
||||
$html = $themeResult;
|
||||
}
|
||||
|
||||
$this->page->html = $html;
|
||||
$this->page->html = $this->formatHtml($html);
|
||||
$this->page->text = $this->toPlainText();
|
||||
}
|
||||
|
||||
@@ -96,7 +79,7 @@ class PageContent
|
||||
|
||||
/**
|
||||
* Convert all inline base64 content to uploaded image files.
|
||||
* Regex is used to locate the start of data-uri definitions, then
|
||||
* 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.
|
||||
@@ -304,8 +287,8 @@ class PageContent
|
||||
public function toPlainText(): string
|
||||
{
|
||||
$html = $this->render(true);
|
||||
$converter = new HtmlToPlainText();
|
||||
return $converter->convert($html);
|
||||
|
||||
return html_entity_decode(strip_tags($html));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -316,7 +299,7 @@ class PageContent
|
||||
$html = $this->page->html ?? '';
|
||||
|
||||
if (empty($html)) {
|
||||
return $this->handlePostRender('');
|
||||
return $html;
|
||||
}
|
||||
|
||||
$doc = new HtmlDocument($html);
|
||||
@@ -334,36 +317,11 @@ class PageContent
|
||||
$this->updateIdsRecursively($doc->getBody(), 0, $idMap, $changeMap);
|
||||
}
|
||||
|
||||
$cacheKey = $this->getContentCacheKey($doc->getBodyInnerHtml());
|
||||
$cached = cache()->get($cacheKey, null);
|
||||
if ($cached !== null) {
|
||||
return $this->handlePostRender($cached);
|
||||
if (!config('app.allow_content_scripts')) {
|
||||
HtmlContentFilter::removeScriptsFromDocument($doc);
|
||||
}
|
||||
|
||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||
$filter = new HtmlContentFilter($filterConfig);
|
||||
$filtered = $filter->filterDocument($doc);
|
||||
|
||||
$cacheTime = 86400 * 7; // 1 week
|
||||
cache()->put($cacheKey, $filtered, $cacheTime);
|
||||
|
||||
return $this->handlePostRender($filtered);
|
||||
}
|
||||
|
||||
protected function handlePostRender(string $html): string
|
||||
{
|
||||
$themeResult = Theme::dispatch(ThemeEvents::PAGE_CONTENT_POST_RENDER, $html, $this->page);
|
||||
return is_string($themeResult) ? $themeResult : $html;
|
||||
}
|
||||
|
||||
protected function getContentCacheKey(string $html): string
|
||||
{
|
||||
$contentHash = md5($html);
|
||||
$contentId = $this->page->id;
|
||||
$contentTime = $this->page->updated_at->timestamp ?? time();
|
||||
$appVersion = AppVersion::get();
|
||||
$filterConfig = config('app.content_filtering') ?? '';
|
||||
return "page-content-cache::{$filterConfig}::{$appVersion}::{$contentId}::{$contentTime}::{$contentHash}";
|
||||
return $doc->getBodyInnerHtml();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,8 +8,6 @@ use BookStack\Entities\Queries\EntityQueries;
|
||||
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||
use BookStack\Permissions\Permission;
|
||||
use BookStack\Util\HtmlContentFilter;
|
||||
use BookStack\Util\HtmlContentFilterConfig;
|
||||
|
||||
class PageEditorData
|
||||
{
|
||||
@@ -49,7 +47,6 @@ class PageEditorData
|
||||
$isDraftRevision = false;
|
||||
$this->warnings = [];
|
||||
$editActivity = new PageEditActivity($page);
|
||||
$lastEditorId = $page->updated_by ?? user()->id;
|
||||
|
||||
if ($editActivity->hasActiveEditing()) {
|
||||
$this->warnings[] = $editActivity->activeEditingMessage();
|
||||
@@ -61,20 +58,11 @@ class PageEditorData
|
||||
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
|
||||
$isDraftRevision = true;
|
||||
$this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
|
||||
$lastEditorId = $userDraft->created_by;
|
||||
}
|
||||
|
||||
// Get editor type and handle changes
|
||||
$editorType = $this->getEditorType($page);
|
||||
$this->updateContentForEditor($page, $editorType);
|
||||
|
||||
// Filter HTML content if required
|
||||
if ($editorType->isHtmlBased() && !old('html') && $lastEditorId !== user()->id) {
|
||||
$filterConfig = HtmlContentFilterConfig::fromConfigString(config('app.content_filtering'));
|
||||
$filter = new HtmlContentFilter($filterConfig);
|
||||
$page->html = $filter->filterString($page->html);
|
||||
}
|
||||
|
||||
return [
|
||||
'page' => $page,
|
||||
'book' => $page->book,
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
|
||||
class ParentChanger
|
||||
{
|
||||
public function __construct(
|
||||
protected SlugGenerator $slugGenerator,
|
||||
protected ReferenceUpdater $referenceUpdater
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the parent book of a chapter or page.
|
||||
*/
|
||||
public function changeBook(BookChild $child, int $newBookId): void
|
||||
{
|
||||
$oldUrl = $child->getUrl();
|
||||
|
||||
$child->book_id = $newBookId;
|
||||
$child->unsetRelation('book');
|
||||
$this->slugGenerator->regenerateForEntity($child);
|
||||
$child->save();
|
||||
|
||||
if ($oldUrl !== $child->getUrl()) {
|
||||
$this->referenceUpdater->updateEntityReferences($child, $oldUrl);
|
||||
}
|
||||
|
||||
// Update all child pages if a chapter
|
||||
if ($child instanceof Chapter) {
|
||||
foreach ($child->pages()->withTrashed()->get() as $page) {
|
||||
$this->changeBook($page, $newBookId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,8 @@ class PermissionsUpdater
|
||||
*/
|
||||
public function updateFromPermissionsForm(Entity $entity, Request $request): void
|
||||
{
|
||||
$permissions = $request->input('permissions', null);
|
||||
$ownerId = $request->input('owned_by', null);
|
||||
$permissions = $request->get('permissions', null);
|
||||
$ownerId = $request->get('owned_by', null);
|
||||
|
||||
$entity->permissions()->delete();
|
||||
|
||||
@@ -47,7 +47,7 @@ class PermissionsUpdater
|
||||
{
|
||||
if (isset($data['role_permissions'])) {
|
||||
$entity->permissions()->where('role_id', '!=', 0)->delete();
|
||||
$rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions($data['role_permissions'], false);
|
||||
$rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions($data['role_permissions'] ?? [], false);
|
||||
$entity->permissions()->createMany($rolePermissionData);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,12 @@ namespace BookStack\Entities\Tools;
|
||||
use BookStack\App\Model;
|
||||
use BookStack\App\SluggableInterface;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Users\Models\User;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SlugGenerator
|
||||
{
|
||||
/**
|
||||
* Generate a fresh slug for the given item.
|
||||
* Generate a fresh slug for the given entity.
|
||||
* The slug will be generated so that it doesn't conflict within the same parent item.
|
||||
*/
|
||||
public function generate(SluggableInterface&Model $model, string $slugSource): string
|
||||
@@ -25,26 +23,6 @@ class SlugGenerator
|
||||
return $slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate the slug for the given entity.
|
||||
*/
|
||||
public function regenerateForEntity(Entity $entity): string
|
||||
{
|
||||
$entity->slug = $this->generate($entity, $entity->name);
|
||||
|
||||
return $entity->slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate the slug for a user.
|
||||
*/
|
||||
public function regenerateForUser(User $user): string
|
||||
{
|
||||
$user->slug = $this->generate($user, $user->name);
|
||||
|
||||
return $user->slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a name as a URL slug.
|
||||
*/
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\EntityTable;
|
||||
use BookStack\Entities\Models\SlugHistory as SlugHistoryModel;
|
||||
use BookStack\Permissions\PermissionApplicator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SlugHistory
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionApplicator $permissions,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the current slugs for the given entity.
|
||||
*/
|
||||
public function recordForEntity(Entity $entity): void
|
||||
{
|
||||
if (!$entity->id || !$entity->slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parentSlug = null;
|
||||
if ($entity instanceof BookChild) {
|
||||
$parentSlug = $entity->book()->first()?->slug;
|
||||
}
|
||||
|
||||
$latest = $this->getLatestEntryForEntity($entity);
|
||||
if ($latest && $latest->slug === $entity->slug && $latest->parent_slug === $parentSlug) {
|
||||
return;
|
||||
}
|
||||
|
||||
$info = [
|
||||
'sluggable_type' => $entity->getMorphClass(),
|
||||
'sluggable_id' => $entity->id,
|
||||
'slug' => $entity->slug,
|
||||
'parent_slug' => $parentSlug,
|
||||
];
|
||||
|
||||
$entry = new SlugHistoryModel();
|
||||
$entry->forceFill($info);
|
||||
$entry->save();
|
||||
|
||||
if ($entity instanceof Book) {
|
||||
$this->recordForBookChildren($entity);
|
||||
}
|
||||
}
|
||||
|
||||
protected function recordForBookChildren(Book $book): void
|
||||
{
|
||||
$query = EntityTable::query()
|
||||
->select(['type', 'id', 'slug', DB::raw("'{$book->slug}' as parent_slug"), DB::raw('now() as created_at'), DB::raw('now() as updated_at')])
|
||||
->where('book_id', '=', $book->id)
|
||||
->whereNotNull('book_id');
|
||||
|
||||
SlugHistoryModel::query()->insertUsing(
|
||||
['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'],
|
||||
$query
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the latest visible entry for an entity which uses the given slug(s) in the history.
|
||||
*/
|
||||
public function lookupEntityIdUsingSlugs(string $type, string $slug, string $parentSlug = ''): ?int
|
||||
{
|
||||
$query = SlugHistoryModel::query()
|
||||
->where('sluggable_type', '=', $type)
|
||||
->where('slug', '=', $slug);
|
||||
|
||||
if ($parentSlug) {
|
||||
$query->where('parent_slug', '=', $parentSlug);
|
||||
}
|
||||
|
||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'slug_history', 'sluggable_id', 'sluggable_type');
|
||||
|
||||
/** @var SlugHistoryModel|null $result */
|
||||
$result = $query->orderBy('created_at', 'desc')->first();
|
||||
|
||||
return $result?->sluggable_id;
|
||||
}
|
||||
|
||||
protected function getLatestEntryForEntity(Entity $entity): SlugHistoryModel|null
|
||||
{
|
||||
return SlugHistoryModel::query()
|
||||
->where('sluggable_type', '=', $entity->getMorphClass())
|
||||
->where('sluggable_id', '=', $entity->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -388,7 +388,7 @@ class TrashCan
|
||||
/**
|
||||
* Update entity relations to remove or update outstanding connections.
|
||||
*/
|
||||
protected function destroyCommonRelations(Entity $entity): void
|
||||
protected function destroyCommonRelations(Entity $entity)
|
||||
{
|
||||
Activity::removeEntity($entity);
|
||||
$entity->views()->delete();
|
||||
@@ -402,7 +402,6 @@ class TrashCan
|
||||
$entity->watches()->delete();
|
||||
$entity->referencesTo()->delete();
|
||||
$entity->referencesFrom()->delete();
|
||||
$entity->slugHistory()->delete();
|
||||
|
||||
if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
|
||||
$imageService = app()->make(ImageService::class);
|
||||
|
||||
@@ -11,7 +11,6 @@ use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Util\CspService;
|
||||
use BookStack\Util\HtmlDocument;
|
||||
use BookStack\Util\HtmlToPlainText;
|
||||
use DOMElement;
|
||||
use Exception;
|
||||
use Throwable;
|
||||
@@ -209,7 +208,7 @@ class ExportFormatter
|
||||
preg_match_all("/\<img.*?src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
|
||||
|
||||
// Replace image src with base64 encoded image strings
|
||||
if (count($imageTagsOutput[0]) > 0) {
|
||||
if (isset($imageTagsOutput[0]) && count($imageTagsOutput[0]) > 0) {
|
||||
foreach ($imageTagsOutput[0] as $index => $imgMatch) {
|
||||
$oldImgTagString = $imgMatch;
|
||||
$srcString = $imageTagsOutput[2][$index];
|
||||
@@ -226,7 +225,7 @@ class ExportFormatter
|
||||
preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $linksOutput);
|
||||
|
||||
// Update relative links to be absolute, with instance url
|
||||
if (count($linksOutput[0]) > 0) {
|
||||
if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
|
||||
foreach ($linksOutput[0] as $index => $linkMatch) {
|
||||
$oldLinkString = $linkMatch;
|
||||
$srcString = $linksOutput[2][$index];
|
||||
@@ -243,13 +242,24 @@ class ExportFormatter
|
||||
|
||||
/**
|
||||
* Converts the page contents into simple plain text.
|
||||
* We re-generate the plain text from HTML at this point, post-page-content rendering.
|
||||
* This method filters any bad looking content to provide a nice final output.
|
||||
*/
|
||||
public function pageToPlainText(Page $page, bool $pageRendered = false, bool $fromParent = false): string
|
||||
{
|
||||
$html = $pageRendered ? $page->html : (new PageContent($page))->render();
|
||||
$contentText = (new HtmlToPlainText())->convert($html);
|
||||
return $page->name . ($fromParent ? "\n" : "\n\n") . $contentText;
|
||||
// Add proceeding spaces before tags so spaces remain between
|
||||
// text within elements after stripping tags.
|
||||
$html = str_replace('<', " <", $html);
|
||||
$text = trim(strip_tags($html));
|
||||
// Replace multiple spaces with single spaces
|
||||
$text = preg_replace('/ {2,}/', ' ', $text);
|
||||
// Reduce multiple horrid whitespace characters.
|
||||
$text = preg_replace('/(\x0A|\xA0|\x0A|\r|\n){2,}/su', "\n\n", $text);
|
||||
$text = html_entity_decode($text);
|
||||
// Add title
|
||||
$text = $page->name . ($fromParent ? "\n" : "\n\n") . $text;
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -257,7 +267,7 @@ class ExportFormatter
|
||||
*/
|
||||
public function chapterToPlainText(Chapter $chapter): string
|
||||
{
|
||||
$text = $chapter->name . "\n" . $chapter->descriptionInfo()->getPlain();
|
||||
$text = $chapter->name . "\n" . $chapter->description;
|
||||
$text = trim($text) . "\n\n";
|
||||
|
||||
$parts = [];
|
||||
@@ -313,7 +323,7 @@ class ExportFormatter
|
||||
$text .= $description . "\n\n";
|
||||
}
|
||||
|
||||
foreach ($chapter->getVisiblePages() as $page) {
|
||||
foreach ($chapter->pages as $page) {
|
||||
$text .= $this->pageToMarkdown($page) . "\n\n";
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ namespace BookStack\Exports;
|
||||
|
||||
use BookStack\Exceptions\PdfExportException;
|
||||
use Dompdf\Dompdf;
|
||||
use FontLib\Font;
|
||||
use Illuminate\Support\Str;
|
||||
use Knp\Snappy\Pdf as SnappyPdf;
|
||||
use Symfony\Component\Process\Exception\ProcessTimedOutException;
|
||||
use Symfony\Component\Process\Process;
|
||||
@@ -62,65 +60,12 @@ class PdfGenerator
|
||||
$domPdf = new Dompdf($options);
|
||||
$domPdf->setBasePath(base_path('public'));
|
||||
|
||||
$fontMetrics = $domPdf->getFontMetrics();
|
||||
$userFontfamilies = $this->getUserDomPdfFontFamilies();
|
||||
foreach ($userFontfamilies as $fontFamily => $fonts) {
|
||||
try {
|
||||
$fontMetrics->setFontFamily($fontFamily, $fonts);
|
||||
} catch (\Exception $exception) {
|
||||
$expectedPath = storage_path('fonts/dompdf');
|
||||
throw new PdfExportException("Failed to create required font data in {$expectedPath}, Ensure all content in this location is writable by the web server");
|
||||
}
|
||||
}
|
||||
|
||||
$domPdf->loadHTML($this->convertEntities($html));
|
||||
$domPdf->render();
|
||||
|
||||
return (string) $domPdf->output();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, string>>
|
||||
*/
|
||||
protected function getUserDomPdfFontFamilies(): array
|
||||
{
|
||||
$fontStore = storage_path('fonts/dompdf');
|
||||
if (!is_dir($fontStore)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$fontFamilies = [];
|
||||
$fontFiles = glob($fontStore . DIRECTORY_SEPARATOR . '*.ttf');
|
||||
foreach ($fontFiles as $fontFile) {
|
||||
$fontFileName = basename($fontFile, '.ttf');
|
||||
$expectedUfm = $fontStore . DIRECTORY_SEPARATOR . $fontFileName . '.ufm';
|
||||
if (!file_exists($expectedUfm)) {
|
||||
$font = Font::load($fontFile);
|
||||
$font->parse();
|
||||
try {
|
||||
$font->saveAdobeFontMetrics($expectedUfm);
|
||||
} catch (\Exception $exception) {
|
||||
throw new PdfExportException("Failed to create required font data at $expectedUfm, Ensure this location is writable by the web server");
|
||||
}
|
||||
}
|
||||
|
||||
$nameParts = explode('-', $fontFileName);
|
||||
if (count($nameParts) === 1 || $nameParts[1] === 'Regular') {
|
||||
$nameParts[1] = 'Normal';
|
||||
}
|
||||
|
||||
$family = trim(strtolower(preg_replace('/([A-Z])/', ' $1', $nameParts[0])));
|
||||
$variation = Str::snake($nameParts[1]);
|
||||
if (!isset($fontFamilies[$family])) {
|
||||
$fontFamilies[$family] = [];
|
||||
}
|
||||
|
||||
$fontFamilies[$family][$variation] = $fontStore . DIRECTORY_SEPARATOR . $fontFileName;
|
||||
}
|
||||
|
||||
return $fontFamilies;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws PdfExportException
|
||||
*/
|
||||
|
||||
@@ -45,7 +45,7 @@ final class ZipExportAttachment extends ZipExportModel
|
||||
$rules = [
|
||||
'id' => ['nullable', 'int', $context->uniqueIdRule('attachment')],
|
||||
'name' => ['required', 'string', 'min:1'],
|
||||
'link' => ['required_without:file', 'nullable', 'string', 'max:2000', 'safe_url'],
|
||||
'link' => ['required_without:file', 'nullable', 'string'],
|
||||
'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()],
|
||||
];
|
||||
|
||||
|
||||
@@ -58,16 +58,6 @@ class ZipExportReader
|
||||
{
|
||||
$this->open();
|
||||
|
||||
$info = $this->zip->statName('data.json');
|
||||
if ($info === false) {
|
||||
throw new ZipExportException(trans('errors.import_zip_cant_decode_data'));
|
||||
}
|
||||
|
||||
$maxSize = max(intval(config()->get('app.upload_limit')), 1) * 1000000;
|
||||
if ($info['size'] > $maxSize) {
|
||||
throw new ZipExportException(trans('errors.import_zip_data_too_large'));
|
||||
}
|
||||
|
||||
// Validate json data exists, including metadata
|
||||
$jsonData = $this->zip->getFromName('data.json') ?: '';
|
||||
$importData = json_decode($jsonData, true);
|
||||
@@ -83,17 +73,6 @@ class ZipExportReader
|
||||
return $this->zip->statName("files/{$fileName}") !== false;
|
||||
}
|
||||
|
||||
public function fileWithinSizeLimit(string $fileName): bool
|
||||
{
|
||||
$fileInfo = $this->zip->statName("files/{$fileName}");
|
||||
if ($fileInfo === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxSize = max(intval(config()->get('app.upload_limit')), 1) * 1000000;
|
||||
return $fileInfo['size'] <= $maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return false|resource
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,7 @@ class ZipFileReferenceRule implements ValidationRule
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
@@ -22,13 +23,6 @@ class ZipFileReferenceRule implements ValidationRule
|
||||
$fail('validation.zip_file')->translate();
|
||||
}
|
||||
|
||||
if (!$this->context->zipReader->fileWithinSizeLimit($value)) {
|
||||
$fail('validation.zip_file_size')->translate([
|
||||
'attribute' => $value,
|
||||
'size' => config('app.upload_limit'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!empty($this->acceptedMimes)) {
|
||||
$fileMime = $this->context->zipReader->sniffFileMime($value);
|
||||
if (!in_array($fileMime, $this->acceptedMimes)) {
|
||||
|
||||
@@ -82,8 +82,10 @@ class ZipImportRunner
|
||||
$entity = $this->importBook($exportModel, $reader);
|
||||
} else if ($exportModel instanceof ZipExportChapter) {
|
||||
$entity = $this->importChapter($exportModel, $parent, $reader);
|
||||
} else {
|
||||
} else if ($exportModel instanceof ZipExportPage) {
|
||||
$entity = $this->importPage($exportModel, $parent, $reader);
|
||||
} else {
|
||||
throw new ZipImportException(['No importable data found in import data.']);
|
||||
}
|
||||
|
||||
$this->references->replaceReferences();
|
||||
@@ -130,7 +132,7 @@ class ZipImportRunner
|
||||
'name' => $exportBook->name,
|
||||
'description_html' => $exportBook->description_html ?? '',
|
||||
'image' => $exportBook->cover ? $this->zipFileToUploadedFile($exportBook->cover, $reader) : null,
|
||||
'tags' => $this->exportTagsToInputArray($exportBook->tags),
|
||||
'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []),
|
||||
]);
|
||||
|
||||
if ($book->coverInfo()->getImage()) {
|
||||
@@ -149,7 +151,7 @@ class ZipImportRunner
|
||||
foreach ($children as $child) {
|
||||
if ($child instanceof ZipExportChapter) {
|
||||
$this->importChapter($child, $book, $reader);
|
||||
} else {
|
||||
} else if ($child instanceof ZipExportPage) {
|
||||
$this->importPage($child, $book, $reader);
|
||||
}
|
||||
}
|
||||
@@ -164,7 +166,7 @@ class ZipImportRunner
|
||||
$chapter = $this->chapterRepo->create([
|
||||
'name' => $exportChapter->name,
|
||||
'description_html' => $exportChapter->description_html ?? '',
|
||||
'tags' => $this->exportTagsToInputArray($exportChapter->tags),
|
||||
'tags' => $this->exportTagsToInputArray($exportChapter->tags ?? []),
|
||||
], $parent);
|
||||
|
||||
$exportPages = $exportChapter->pages;
|
||||
@@ -197,7 +199,7 @@ class ZipImportRunner
|
||||
'name' => $exportPage->name,
|
||||
'markdown' => $exportPage->markdown ?? '',
|
||||
'html' => $exportPage->html ?? '',
|
||||
'tags' => $this->exportTagsToInputArray($exportPage->tags),
|
||||
'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []),
|
||||
]);
|
||||
|
||||
$this->references->addPage($page, $exportPage);
|
||||
@@ -263,12 +265,6 @@ class ZipImportRunner
|
||||
|
||||
protected function zipFileToUploadedFile(string $fileName, ZipExportReader $reader): UploadedFile
|
||||
{
|
||||
if (!$reader->fileWithinSizeLimit($fileName)) {
|
||||
throw new ZipImportException([
|
||||
"File $fileName exceeds app upload limit."
|
||||
]);
|
||||
}
|
||||
|
||||
$tempPath = tempnam(sys_get_temp_dir(), 'bszipextract');
|
||||
$fileStream = $reader->streamFile($fileName);
|
||||
$tempStream = fopen($tempPath, 'wb');
|
||||
@@ -300,7 +296,7 @@ class ZipImportRunner
|
||||
array_push($chapters, ...$exportModel->chapters);
|
||||
} else if ($exportModel instanceof ZipExportChapter) {
|
||||
$chapters[] = $exportModel;
|
||||
} else {
|
||||
} else if ($exportModel instanceof ZipExportPage) {
|
||||
$pages[] = $exportModel;
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,10 @@ class ZipReferenceParser
|
||||
$matches = [];
|
||||
preg_match_all($referenceRegex, $content, $matches);
|
||||
|
||||
if (count($matches) < 3) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
for ($i = 0; $i < count($matches[0]); $i++) {
|
||||
$referenceText = $matches[0][$i];
|
||||
$type = strtolower($matches[1][$i]);
|
||||
|
||||
@@ -20,14 +20,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 = [], array $filterableFields = []): JsonResponse
|
||||
protected function apiListingResponse(Builder $query, array $fields, array $modifiers = []): JsonResponse
|
||||
{
|
||||
$listing = new ListingResponseBuilder($query, request(), $fields);
|
||||
|
||||
if (count($filterableFields) > 0) {
|
||||
$listing->setFilterableFields($filterableFields);
|
||||
}
|
||||
|
||||
foreach ($modifiers as $modifier) {
|
||||
$listing->modifyResults($modifier);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ abstract class Controller extends BaseController
|
||||
*/
|
||||
protected function checkPermission(string|Permission $permission): void
|
||||
{
|
||||
if (!user()->can($permission)) {
|
||||
if (!user() || !user()->can($permission)) {
|
||||
$this->showPermissionError();
|
||||
}
|
||||
}
|
||||
@@ -167,26 +167,14 @@ abstract class Controller extends BaseController
|
||||
|
||||
/**
|
||||
* Redirect to the URL provided in the request as a '_return' parameter.
|
||||
* Will check that the parameter leads to a URL under the same origin as the application.
|
||||
* Will check that the parameter leads to a URL under the root path of the system.
|
||||
*/
|
||||
protected function redirectToRequest(Request $request): RedirectResponse
|
||||
{
|
||||
$basePath = url('/');
|
||||
$returnUrl = $request->input('_return') ?? $basePath;
|
||||
|
||||
// Only allow use of _return on requests where we expect CSRF to be active
|
||||
// to prevent it potentially being used as an open redirect
|
||||
$allowedMethods = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
||||
if (!in_array($request->getMethod(), $allowedMethods)) {
|
||||
return redirect($basePath);
|
||||
}
|
||||
|
||||
$intendedUrl = parse_url($returnUrl);
|
||||
$baseUrl = parse_url($basePath);
|
||||
$isSameOrigin = ($intendedUrl['host'] ?? '') === ($baseUrl['host'] ?? '')
|
||||
&& ($intendedUrl['scheme'] ?? '') === ($baseUrl['scheme'] ?? '')
|
||||
&& ($intendedUrl['port'] ?? 0) === ($baseUrl['port'] ?? 0);
|
||||
if (!$isSameOrigin) {
|
||||
if (!str_starts_with($returnUrl, $basePath)) {
|
||||
return redirect($basePath);
|
||||
}
|
||||
|
||||
|
||||
@@ -102,15 +102,12 @@ class DownloadResponseFactory
|
||||
protected function getHeaders(string $fileName, int $fileSize, string $mime = 'application/octet-stream'): array
|
||||
{
|
||||
$disposition = ($mime === 'application/octet-stream') ? 'attachment' : 'inline';
|
||||
|
||||
$downloadName = str_replace(['"', '/', '\\', '$'], '', $fileName);
|
||||
$downloadName = preg_replace('/[\x00-\x1F\x7F]/', '', $downloadName);
|
||||
$encodedDownloadName = rawurlencode($downloadName);
|
||||
$downloadName = str_replace('"', '', $fileName);
|
||||
|
||||
return [
|
||||
'Content-Type' => $mime,
|
||||
'Content-Length' => $fileSize,
|
||||
'Content-Disposition' => "{$disposition}; filename*=UTF-8''{$encodedDownloadName}",
|
||||
'Content-Disposition' => "{$disposition}; filename=\"{$downloadName}\"",
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class ApiAuthenticate
|
||||
public function handle(Request $request, Closure $next)
|
||||
{
|
||||
// Validate the token and it's users API access
|
||||
$this->ensureAuthorizedBySessionOrToken($request);
|
||||
$this->ensureAuthorizedBySessionOrToken();
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
@@ -28,28 +28,22 @@ class ApiAuthenticate
|
||||
*
|
||||
* @throws ApiAuthException
|
||||
*/
|
||||
protected function ensureAuthorizedBySessionOrToken(Request $request): void
|
||||
protected function ensureAuthorizedBySessionOrToken(): void
|
||||
{
|
||||
// Use the active user session already exists.
|
||||
// This is to make it easy to explore API endpoints via the UI.
|
||||
if (session()->isStarted()) {
|
||||
// Ensure the user has API access permission
|
||||
// Return if the user is already found to be signed in via session-based auth.
|
||||
// This is to make it easy to browser the API via browser after just logging into the system.
|
||||
if (!user()->isGuest() || session()->isStarted()) {
|
||||
if (!$this->sessionUserHasApiAccess()) {
|
||||
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
|
||||
}
|
||||
|
||||
// Only allow GET requests for cookie-based API usage
|
||||
if ($request->method() !== 'GET') {
|
||||
throw new ApiAuthException(trans('errors.api_cookie_auth_only_get'), 403);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Set our api guard to be the default for this request lifecycle.
|
||||
auth()->shouldUse('api');
|
||||
|
||||
// Validate the token and its users API access
|
||||
// Validate the token and it's users API access
|
||||
auth()->authenticate();
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user