mirror of
https://github.com/BookStackApp/BookStack.git
synced 2026-02-05 16:49:47 +03:00
Compare commits
285 Commits
user_permi
...
v22.02.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -42,7 +42,7 @@ APP_TIMEZONE=UTC
|
||||
# overrides can be made. Defaults to disabled.
|
||||
APP_THEME=false
|
||||
|
||||
# Trusted proxies
|
||||
# Trusted Proxies
|
||||
# Used to indicate trust of systems that proxy to the application so
|
||||
# certain header values (Such as "X-Forwarded-For") can be used from the
|
||||
# incoming proxy request to provide origin detail.
|
||||
@@ -58,13 +58,6 @@ DB_DATABASE=database_database
|
||||
DB_USERNAME=database_username
|
||||
DB_PASSWORD=database_user_password
|
||||
|
||||
# MySQL specific connection options
|
||||
# Path to Certificate Authority (CA) certificate file for your MySQL instance.
|
||||
# When this option is used host name identity verification will be performed
|
||||
# which checks the hostname, used by the client, against names within the
|
||||
# certificate itself (Common Name or Subject Alternative Name).
|
||||
MYSQL_ATTR_SSL_CA="/path/to/ca.pem"
|
||||
|
||||
# Mail system to use
|
||||
# Can be 'smtp' or 'sendmail'
|
||||
MAIL_DRIVER=smtp
|
||||
@@ -143,10 +136,6 @@ STORAGE_URL=false
|
||||
# Can be 'standard', 'ldap', 'saml2' or 'oidc'
|
||||
AUTH_METHOD=standard
|
||||
|
||||
# Automatically initiate login via external auth system if it's the only auth method.
|
||||
# Works with saml2 or oidc auth methods.
|
||||
AUTH_AUTO_INITIATE=false
|
||||
|
||||
# Social authentication configuration
|
||||
# All disabled by default.
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/third-party-auth/
|
||||
@@ -227,7 +216,6 @@ LDAP_DUMP_USER_DETAILS=false
|
||||
LDAP_USER_TO_GROUPS=false
|
||||
LDAP_GROUP_ATTRIBUTE="memberOf"
|
||||
LDAP_REMOVE_FROM_GROUPS=false
|
||||
LDAP_DUMP_USER_GROUPS=false
|
||||
|
||||
# SAML authentication configuration
|
||||
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
|
||||
@@ -263,11 +251,7 @@ OIDC_ISSUER_DISCOVER=false
|
||||
OIDC_PUBLIC_KEY=null
|
||||
OIDC_AUTH_ENDPOINT=null
|
||||
OIDC_TOKEN_ENDPOINT=null
|
||||
OIDC_ADDITIONAL_SCOPES=null
|
||||
OIDC_DUMP_USER_DETAILS=false
|
||||
OIDC_USER_TO_GROUPS=false
|
||||
OIDC_GROUPS_CLAIM=groups
|
||||
OIDC_REMOVE_FROM_GROUPS=false
|
||||
|
||||
# Disable default third-party services such as Gravatar and Draw.IO
|
||||
# Service-specific options will override this option
|
||||
@@ -282,7 +266,7 @@ AVATAR_URL=
|
||||
# Enable diagrams.net integration
|
||||
# Can simply be true/false to enable/disable the integration.
|
||||
# Alternatively, It can be URL to the diagrams.net instance you want to use.
|
||||
# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1&configure=1
|
||||
# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1
|
||||
DRAWIO=true
|
||||
|
||||
# Default item listing view
|
||||
@@ -299,7 +283,7 @@ APP_DEFAULT_DARK_MODE=false
|
||||
# Page revision limit
|
||||
# Number of page revisions to keep in the system before deleting old revisions.
|
||||
# If set to 'false' a limit will not be enforced.
|
||||
REVISION_LIMIT=100
|
||||
REVISION_LIMIT=50
|
||||
|
||||
# Recycle Bin Lifetime
|
||||
# The number of days that content will remain in the recycle bin before
|
||||
@@ -340,13 +324,6 @@ ALLOW_UNTRUSTED_SERVER_FETCHING=false
|
||||
# Setting this option will also auto-adjust cookies to be SameSite=None.
|
||||
ALLOWED_IFRAME_HOSTS=null
|
||||
|
||||
# A list of sources/hostnames that can be loaded within iframes within BookStack.
|
||||
# Space separated if multiple. BookStack host domain is auto-inferred.
|
||||
# Can be set to a lone "*" to allow all sources for iframe content (Not advised).
|
||||
# Defaults to a set of common services.
|
||||
# Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
|
||||
ALLOWED_IFRAME_SOURCES="https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com"
|
||||
|
||||
# The default and maximum item-counts for listing API requests.
|
||||
API_DEFAULT_ITEM_COUNT=100
|
||||
API_MAX_ITEM_COUNT=500
|
||||
@@ -361,11 +338,3 @@ API_REQUESTS_PER_MIN=180
|
||||
# user identifier (Username or email).
|
||||
LOG_FAILED_LOGIN_MESSAGE=false
|
||||
LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver
|
||||
|
||||
# Alter the precision of IP addresses stored by BookStack.
|
||||
# Should be a number between 0 and 4, where 4 retains the full IP address
|
||||
# and 0 completely hides the IP address. As an example, a value of 2 for the
|
||||
# IP address '146.191.42.4' would result in '146.191.x.x' being logged.
|
||||
# For the IPv6 address '2001:db8:85a3:8d3:1319:8a2e:370:7348' this would result as:
|
||||
# '2001:db8:85a3:8d3:x:x:x:x'
|
||||
IP_ADDRESS_PRECISION=4
|
||||
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1,4 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [ssddanbrown]
|
||||
ko_fi: ssddanbrown
|
||||
10
.github/ISSUE_TEMPLATE/config.yml
vendored
10
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,13 +1,9 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord Chat Support
|
||||
- name: Discord chat support
|
||||
url: https://discord.gg/ztkBqR2
|
||||
about: Realtime support & chat with the BookStack community and the team.
|
||||
about: Realtime support / chat with the community and the team.
|
||||
|
||||
- name: Debugging & Common Issues
|
||||
url: https://www.bookstackapp.com/docs/admin/debugging/
|
||||
about: Find details on how to debug issues and view common issues with their resolutions.
|
||||
|
||||
- name: Official Support Plans
|
||||
url: https://www.bookstackapp.com/support/
|
||||
about: View our official support plans that offer assured support for business.
|
||||
about: Find details on how to debug issues and view common issues with thier resolutions.
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/language_request.yml
vendored
4
.github/ISSUE_TEMPLATE/language_request.yml
vendored
@@ -1,5 +1,5 @@
|
||||
name: Language Request
|
||||
description: Request a new language to be added to Crowdin for you to translate
|
||||
description: Request a new language to be added to CrowdIn for you to translate
|
||||
labels: [":earth_africa: Translations"]
|
||||
assignees:
|
||||
- ssddanbrown
|
||||
@@ -23,7 +23,7 @@ body:
|
||||
This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack).
|
||||
Please don't use this template to request a new language that you are not prepared to provide translations for.
|
||||
options:
|
||||
- label: I confirm I'm offering to help translate for this new language via Crowdin.
|
||||
- label: I confirm I'm offering to help translate for this new language via CrowdIn.
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
|
||||
74
.github/translators.txt
vendored
74
.github/translators.txt
vendored
@@ -55,8 +55,6 @@ Name :: Languages
|
||||
@Baptistou :: French
|
||||
@arcoai :: Spanish
|
||||
@Jokuna :: Korean
|
||||
@smartshogu :: German; German Informal
|
||||
@samadha56 :: Persian
|
||||
cipi1965 :: Italian
|
||||
Mykola Ronik (Mantikor) :: Ukrainian
|
||||
furkanoyk :: Turkish
|
||||
@@ -138,7 +136,7 @@ Xiphoseer :: German
|
||||
MerlinSVK (merlinsvk) :: Slovak
|
||||
Kauê Sena (kaue.sena.ks) :: Portuguese, Brazilian
|
||||
MatthieuParis :: French
|
||||
Douradinho :: Portuguese, Brazilian; Portuguese
|
||||
Douradinho :: Portuguese, Brazilian
|
||||
Gaku Yaguchi (tama11) :: Japanese
|
||||
johnroyer :: Chinese Traditional
|
||||
jackaaa :: Chinese Traditional
|
||||
@@ -167,7 +165,7 @@ Francesco Franchina (ffranchina) :: Italian
|
||||
Aimrane Kds (aimrane.kds) :: Arabic
|
||||
whenwesober :: Indonesian
|
||||
Rem (remkovdhoef) :: Dutch
|
||||
syn7ax69 :: Bulgarian; Turkish; German
|
||||
syn7ax69 :: Bulgarian; Turkish
|
||||
Blaade :: French
|
||||
Behzad HosseinPoor (behzad.hp) :: Persian
|
||||
Ole Aldric (Swoy) :: Norwegian Bokmal
|
||||
@@ -176,7 +174,7 @@ Alexander Predl (Harveyhase68) :: German
|
||||
Rem (Rem9000) :: Dutch
|
||||
Michał Stelmach (stelmach-web) :: Polish
|
||||
arniom :: French
|
||||
REMOVED_USER :: ; Dutch; Turkish
|
||||
REMOVED_USER :: Turkish
|
||||
林祖年 (contagion) :: Chinese Traditional
|
||||
Siamak Guodarzi (siamakgoudarzi88) :: Persian
|
||||
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
|
||||
@@ -232,69 +230,3 @@ roncallyt :: Portuguese, Brazilian
|
||||
goegol :: Dutch
|
||||
msevgen :: Turkish
|
||||
Khroners :: French
|
||||
MASOUD HOSSEINY (masoudme) :: Persian
|
||||
Thomerson Roncally (roncallyt) :: Portuguese, Brazilian
|
||||
metaarch :: Bulgarian
|
||||
Xabi (xabikip) :: Basque
|
||||
pedromcsousa :: Portuguese
|
||||
Nir Louk (looknear) :: Hebrew
|
||||
Alex (qianmengnet) :: Chinese Simplified
|
||||
stothew :: German
|
||||
sgenc :: Turkish
|
||||
Shukrullo (vodiylik) :: Uzbek
|
||||
William W. (Nevnt) :: Chinese Traditional
|
||||
eamaro :: Portuguese
|
||||
Ypsilon-dev :: Arabic
|
||||
Hieu Vuong Trung (vuongtrunghieu) :: Vietnamese
|
||||
David Clubb (davidoclubb) :: Welsh
|
||||
welles freire (wellesximenes) :: Portuguese, Brazilian
|
||||
Magnus Jensen (MagnusHJensen) :: Danish
|
||||
Hesley Magno (hesleymagno) :: Portuguese, Brazilian
|
||||
Éric Gaspar (erga) :: French
|
||||
Fr3shlama :: German
|
||||
DSR :: Spanish, Argentina
|
||||
Andrii Bodnar (andrii-bodnar) :: Ukrainian
|
||||
Younes el Anjri (younesea28) :: Dutch
|
||||
Guclu Ozturk (gucluoz) :: Turkish
|
||||
Atmis :: French
|
||||
redjack666 :: Chinese Traditional
|
||||
Ashita007 :: Russian
|
||||
lihaorr :: Chinese Simplified
|
||||
Marcus Silber (marcus.silber82) :: German
|
||||
PellNet :: Croatian
|
||||
Winetradr :: German
|
||||
Sebastian Klaus (sebklaus) :: German
|
||||
Filip Antala (AntalaFilip) :: Slovak
|
||||
mcgong (GongMingCai) :: Chinese Simplified; Chinese Traditional
|
||||
Nanang Setia Budi (sefidananang) :: Indonesian
|
||||
Андрей Павлов (andrei.pavlov) :: Russian
|
||||
Alex Navarro (alex.n.navarro) :: Portuguese, Brazilian
|
||||
Ji-Hyeon Gim (PotatoGim) :: Korean
|
||||
Mihai Ochian (soulstorm19) :: Romanian
|
||||
HeartCore :: German Informal; German
|
||||
simon.pct :: French
|
||||
okaeiz :: Persian
|
||||
Naoto Ishikawa (na3shkw) :: Japanese
|
||||
sdhadi :: Persian
|
||||
DerLinkman (derlinkman) :: German; German Informal
|
||||
TurnArabic :: Arabic
|
||||
Martin Sebek (sebekmartin) :: Czech
|
||||
Kuchinashi Hoshikawa (kuchinashi) :: Chinese Simplified
|
||||
digilady :: Greek
|
||||
Linus (LinusOP) :: Swedish
|
||||
Felipe Cardoso (felipecardosoruff) :: Portuguese, Brazilian
|
||||
RandomUser0815 :: German
|
||||
Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
|
||||
구인회 (laskdjlaskdj12) :: Korean
|
||||
LiZerui (CNLiZerui) :: Chinese Traditional
|
||||
Fabrice Boyer (FabriceBoyer) :: French
|
||||
mikael (bitcanon) :: Swedish
|
||||
Matthias Mai (schnapsidee) :: German
|
||||
Ufuk Ayyıldız (ufukayyildiz) :: Turkish
|
||||
Jan Mitrof (jan.kachlik) :: Czech
|
||||
edwardsmirnov :: Russian
|
||||
Mr_OSS117 :: French
|
||||
shotu :: French
|
||||
Cesar_Lopez_Aguillon :: Spanish
|
||||
bdewoop :: German
|
||||
dina davoudi (dina.davoudi) :: Persian
|
||||
|
||||
19
.github/workflows/lint-php.yml
vendored
19
.github/workflows/lint-php.yml
vendored
@@ -1,19 +0,0 @@
|
||||
name: lint-php
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.1
|
||||
tools: phpcs
|
||||
|
||||
- name: Run formatting check
|
||||
run: composer lint
|
||||
@@ -1,34 +1,36 @@
|
||||
name: analyse-php
|
||||
name: phpstan
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.4']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.1
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: gd, mbstring, json, curl, xml, mysql, ldap
|
||||
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-8.1
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
|
||||
- name: Install composer dependencies
|
||||
run: composer install --prefer-dist --no-interaction --ansi
|
||||
|
||||
- name: Run static analysis check
|
||||
run: composer check-static
|
||||
- name: Run PHPStan
|
||||
run: php${{ matrix.php }} ./vendor/bin/phpstan analyse --memory-limit=2G
|
||||
@@ -1,14 +1,14 @@
|
||||
name: test-php
|
||||
name: phpunit
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.4', '8.0', '8.1', '8.2']
|
||||
php: ['7.4', '8.0', '8.1']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
@@ -21,14 +21,13 @@ jobs:
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Start Database
|
||||
run: |
|
||||
@@ -49,5 +48,5 @@ jobs:
|
||||
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
|
||||
php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
|
||||
|
||||
- name: Run PHP tests
|
||||
- name: phpunit
|
||||
run: php${{ matrix.php }} ./vendor/bin/phpunit
|
||||
9
.github/workflows/test-migrations.yml
vendored
9
.github/workflows/test-migrations.yml
vendored
@@ -5,10 +5,10 @@ on: [push, pull_request]
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref != 'refs/heads/l10n_development' }}
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.4', '8.0', '8.1', '8.2']
|
||||
php: ['7.4', '8.0', '8.1']
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
|
||||
@@ -21,14 +21,13 @@ jobs:
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
echo "::set-output name=dir::$(composer config cache-files-dir)"
|
||||
|
||||
- name: Cache composer packages
|
||||
uses: actions/cache@v3
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ matrix.php }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
|
||||
- name: Start MySQL
|
||||
run: |
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -5,10 +5,10 @@ Homestead.yaml
|
||||
.idea
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/public/dist
|
||||
/public/dist/*.map
|
||||
/public/plugins
|
||||
/public/css
|
||||
/public/js
|
||||
/public/css/*.map
|
||||
/public/js/*.map
|
||||
/public/bower
|
||||
/public/build/
|
||||
/storage/images
|
||||
|
||||
3
LICENSE
3
LICENSE
@@ -1,6 +1,7 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-2022, Dan Brown and the BookStack Project contributors.
|
||||
Copyright (c) 2015-present, Dan Brown and the BookStack Project contributors
|
||||
https://github.com/BookStackApp/BookStack/graphs/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
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ActivityLogger
|
||||
{
|
||||
protected $permissionService;
|
||||
|
||||
public function __construct(PermissionService $permissionService)
|
||||
{
|
||||
$this->permissionService = $permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a generic activity event to the database.
|
||||
*
|
||||
@@ -29,10 +35,8 @@ class ActivityLogger
|
||||
}
|
||||
|
||||
$activity->save();
|
||||
|
||||
$this->setNotification($type);
|
||||
$this->dispatchWebhooks($type, $detail);
|
||||
Theme::dispatch(ThemeEvents::ACTIVITY_LOGGED, $type, $detail);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,10 +44,12 @@ class ActivityLogger
|
||||
*/
|
||||
protected function newActivityForUser(string $type): Activity
|
||||
{
|
||||
$ip = request()->ip() ?? '';
|
||||
|
||||
return (new Activity())->forceFill([
|
||||
'type' => strtolower($type),
|
||||
'user_id' => user()->id,
|
||||
'ip' => IpFormatter::fromCurrentRequest()->format(),
|
||||
'ip' => config('app.env') === 'demo' ? '127.0.0.1' : $ip,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
@@ -13,13 +13,11 @@ use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class ActivityQueries
|
||||
{
|
||||
protected PermissionApplicator $permissions;
|
||||
protected $permissionService;
|
||||
|
||||
protected array $fieldsForLists = ['id', 'type', 'detail', 'activities.entity_type', 'activities.entity_id', 'user_id', 'created_at'];
|
||||
|
||||
public function __construct(PermissionApplicator $permissions)
|
||||
public function __construct(PermissionService $permissionService)
|
||||
{
|
||||
$this->permissions = $permissions;
|
||||
$this->permissionService = $permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,11 +25,9 @@ class ActivityQueries
|
||||
*/
|
||||
public function latest(int $count = 20, int $page = 0): array
|
||||
{
|
||||
$query = Activity::query()->select($this->fieldsForLists);
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery($query, 'activities', 'entity_id', 'entity_type')
|
||||
$activityList = $this->permissionService
|
||||
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->whereNotNull('activities.entity_id')
|
||||
->with(['user', 'entity'])
|
||||
->skip($count * $page)
|
||||
->take($count)
|
||||
@@ -82,12 +78,10 @@ class ActivityQueries
|
||||
*/
|
||||
public function userActivity(User $user, int $count = 20, int $page = 0): array
|
||||
{
|
||||
$query = Activity::query()->select($this->fieldsForLists);
|
||||
$activityList = $this->permissions
|
||||
->restrictEntityRelationQuery($query, 'activities', 'entity_id', 'entity_type')
|
||||
$activityList = $this->permissionService
|
||||
->filterRestrictedEntityRelations(Activity::query(), 'activities', 'entity_id', 'entity_type')
|
||||
->orderBy('created_at', 'desc')
|
||||
->where('user_id', '=', $user->id)
|
||||
->whereNotNull('activities.entity_id')
|
||||
->skip($count * $page)
|
||||
->take($count)
|
||||
->get();
|
||||
|
||||
@@ -16,22 +16,17 @@ class ActivityType
|
||||
const CHAPTER_MOVE = 'chapter_move';
|
||||
|
||||
const BOOK_CREATE = 'book_create';
|
||||
const BOOK_CREATE_FROM_CHAPTER = 'book_create_from_chapter';
|
||||
const BOOK_UPDATE = 'book_update';
|
||||
const BOOK_DELETE = 'book_delete';
|
||||
const BOOK_SORT = 'book_sort';
|
||||
|
||||
const BOOKSHELF_CREATE = 'bookshelf_create';
|
||||
const BOOKSHELF_CREATE_FROM_BOOK = 'bookshelf_create_from_book';
|
||||
const BOOKSHELF_UPDATE = 'bookshelf_update';
|
||||
const BOOKSHELF_DELETE = 'bookshelf_delete';
|
||||
|
||||
const COMMENTED_ON = 'commented_on';
|
||||
const PERMISSIONS_UPDATE = 'permissions_update';
|
||||
|
||||
const REVISION_RESTORE = 'revision_restore';
|
||||
const REVISION_DELETE = 'revision_delete';
|
||||
|
||||
const SETTINGS_UPDATE = 'settings_update';
|
||||
const MAINTENANCE_ACTION_RUN = 'maintenance_action_run';
|
||||
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Model;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -21,16 +24,31 @@ class DispatchWebhookJob implements ShouldQueue
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
protected Webhook $webhook;
|
||||
protected string $event;
|
||||
protected User $initiator;
|
||||
protected int $initiatedTime;
|
||||
/**
|
||||
* @var Webhook
|
||||
*/
|
||||
protected $webhook;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $event;
|
||||
|
||||
/**
|
||||
* @var string|Loggable
|
||||
*/
|
||||
protected $detail;
|
||||
|
||||
/**
|
||||
* @var User
|
||||
*/
|
||||
protected $initiator;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*/
|
||||
protected $initiatedTime;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
@@ -52,8 +70,8 @@ class DispatchWebhookJob implements ShouldQueue
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime);
|
||||
$webhookData = $themeResponse ?? WebhookFormatter::getDefault($this->event, $this->webhook, $this->detail, $this->initiator, $this->initiatedTime)->format();
|
||||
$themeResponse = Theme::dispatch(ThemeEvents::WEBHOOK_CALL_BEFORE, $this->event, $this->webhook, $this->detail);
|
||||
$webhookData = $themeResponse ?? $this->buildWebhookData();
|
||||
$lastError = null;
|
||||
|
||||
try {
|
||||
@@ -79,4 +97,36 @@ class DispatchWebhookJob implements ShouldQueue
|
||||
|
||||
$this->webhook->save();
|
||||
}
|
||||
|
||||
protected function buildWebhookData(): array
|
||||
{
|
||||
$textParts = [
|
||||
$this->initiator->name,
|
||||
trans('activities.' . $this->event),
|
||||
];
|
||||
|
||||
if ($this->detail instanceof Entity) {
|
||||
$textParts[] = '"' . $this->detail->name . '"';
|
||||
}
|
||||
|
||||
$data = [
|
||||
'event' => $this->event,
|
||||
'text' => implode(' ', $textParts),
|
||||
'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
|
||||
'triggered_by' => $this->initiator->attributesToArray(),
|
||||
'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
|
||||
'webhook_id' => $this->webhook->id,
|
||||
'webhook_name' => $this->webhook->name,
|
||||
];
|
||||
|
||||
if (method_exists($this->detail, 'getUrl')) {
|
||||
$data['url'] = $this->detail->getUrl();
|
||||
}
|
||||
|
||||
if ($this->detail instanceof Model) {
|
||||
$data['related_item'] = $this->detail->attributesToArray();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
class IpFormatter
|
||||
{
|
||||
protected string $ip;
|
||||
protected int $precision;
|
||||
|
||||
public function __construct(string $ip, int $precision)
|
||||
{
|
||||
$this->ip = trim($ip);
|
||||
$this->precision = max(0, min($precision, 4));
|
||||
}
|
||||
|
||||
public function format(): string
|
||||
{
|
||||
if (empty($this->ip) || $this->precision === 4) {
|
||||
return $this->ip;
|
||||
}
|
||||
|
||||
return $this->isIpv6() ? $this->maskIpv6() : $this->maskIpv4();
|
||||
}
|
||||
|
||||
protected function maskIpv4(): string
|
||||
{
|
||||
$exploded = $this->explodeAndExpandIp('.', 4);
|
||||
$maskGroupCount = min(4 - $this->precision, count($exploded));
|
||||
|
||||
for ($i = 0; $i < $maskGroupCount; $i++) {
|
||||
$exploded[3 - $i] = 'x';
|
||||
}
|
||||
|
||||
return implode('.', $exploded);
|
||||
}
|
||||
|
||||
protected function maskIpv6(): string
|
||||
{
|
||||
$exploded = $this->explodeAndExpandIp(':', 8);
|
||||
$maskGroupCount = min(8 - ($this->precision * 2), count($exploded));
|
||||
|
||||
for ($i = 0; $i < $maskGroupCount; $i++) {
|
||||
$exploded[7 - $i] = 'x';
|
||||
}
|
||||
|
||||
return implode(':', $exploded);
|
||||
}
|
||||
|
||||
protected function isIpv6(): bool
|
||||
{
|
||||
return strpos($this->ip, ':') !== false;
|
||||
}
|
||||
|
||||
protected function explodeAndExpandIp(string $separator, int $targetLength): array
|
||||
{
|
||||
$exploded = explode($separator, $this->ip);
|
||||
|
||||
while (count($exploded) < $targetLength) {
|
||||
$emptyIndex = array_search('', $exploded) ?: count($exploded) - 1;
|
||||
array_splice($exploded, $emptyIndex, 0, '0');
|
||||
}
|
||||
|
||||
$emptyIndex = array_search('', $exploded);
|
||||
if ($emptyIndex !== false) {
|
||||
$exploded[$emptyIndex] = '0';
|
||||
}
|
||||
|
||||
return $exploded;
|
||||
}
|
||||
|
||||
public static function fromCurrentRequest(): self
|
||||
{
|
||||
$ip = request()->ip() ?? '';
|
||||
|
||||
if (config('app.env') === 'demo') {
|
||||
$ip = '127.0.0.1';
|
||||
}
|
||||
|
||||
return new self($ip, config('app.ip_address_precision'));
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions\Queries;
|
||||
|
||||
use BookStack\Actions\Webhook;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Get all the webhooks in the system in a paginated format.
|
||||
*/
|
||||
class WebhooksAllPaginatedAndSorted
|
||||
{
|
||||
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
|
||||
{
|
||||
$query = Webhook::query()->select(['*'])
|
||||
->withCount(['trackedEvents'])
|
||||
->orderBy($listOptions->getSort(), $listOptions->getOrder());
|
||||
|
||||
if ($listOptions->getSearch()) {
|
||||
$term = '%' . $listOptions->getSearch() . '%';
|
||||
$query->where(function ($query) use ($term) {
|
||||
$query->where('name', 'like', $term)
|
||||
->orWhere('endpoint', 'like', $term);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->paginate($count);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
class TagClassGenerator
|
||||
{
|
||||
protected array $tags;
|
||||
|
||||
/**
|
||||
* @param Tag[] $tags
|
||||
*/
|
||||
public function __construct(array $tags)
|
||||
{
|
||||
$this->tags = $tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function generate(): array
|
||||
{
|
||||
$classes = [];
|
||||
|
||||
foreach ($this->tags as $tag) {
|
||||
$name = $this->normalizeTagClassString($tag->name);
|
||||
$value = $this->normalizeTagClassString($tag->value);
|
||||
$classes[] = 'tag-name-' . $name;
|
||||
if ($value) {
|
||||
$classes[] = 'tag-value-' . $value;
|
||||
$classes[] = 'tag-pair-' . $name . '-' . $value;
|
||||
}
|
||||
}
|
||||
|
||||
return array_unique($classes);
|
||||
}
|
||||
|
||||
public function generateAsString(): string
|
||||
{
|
||||
return implode(' ', $this->generate());
|
||||
}
|
||||
|
||||
protected function normalizeTagClassString(string $value): string
|
||||
{
|
||||
$value = str_replace(' ', '', strtolower($value));
|
||||
$value = str_replace('-', '', strtolower($value));
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -2,45 +2,38 @@
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TagRepo
|
||||
{
|
||||
protected PermissionApplicator $permissions;
|
||||
protected $tag;
|
||||
protected $permissionService;
|
||||
|
||||
public function __construct(PermissionApplicator $permissions)
|
||||
public function __construct(PermissionService $ps)
|
||||
{
|
||||
$this->permissions = $permissions;
|
||||
$this->permissionService = $ps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a query against all tags in the system.
|
||||
*/
|
||||
public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder
|
||||
public function queryWithTotals(string $searchTerm, string $nameFilter): Builder
|
||||
{
|
||||
$searchTerm = $listOptions->getSearch();
|
||||
$sort = $listOptions->getSort();
|
||||
if ($sort === 'name' && $nameFilter) {
|
||||
$sort = 'value';
|
||||
}
|
||||
|
||||
$entityTypeCol = DB::getTablePrefix() . 'tags.entity_type';
|
||||
$query = Tag::query()
|
||||
->select([
|
||||
'name',
|
||||
($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
|
||||
DB::raw('COUNT(id) as usages'),
|
||||
DB::raw("SUM(IF({$entityTypeCol} = 'page', 1, 0)) as page_count"),
|
||||
DB::raw("SUM(IF({$entityTypeCol} = 'chapter', 1, 0)) as chapter_count"),
|
||||
DB::raw("SUM(IF({$entityTypeCol} = 'book', 1, 0)) as book_count"),
|
||||
DB::raw("SUM(IF({$entityTypeCol} = 'bookshelf', 1, 0)) as shelf_count"),
|
||||
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Page\', 1, 0)) as page_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Chapter\', 1, 0)) as chapter_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'BookStack\\\\Book\', 1, 0)) as book_count'),
|
||||
DB::raw('SUM(IF(entity_type = \'BookStack\\\\BookShelf\', 1, 0)) as shelf_count'),
|
||||
])
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
->orderBy($nameFilter ? 'value' : 'name');
|
||||
|
||||
if ($nameFilter) {
|
||||
$query->where('name', '=', $nameFilter);
|
||||
@@ -58,28 +51,28 @@ class TagRepo
|
||||
});
|
||||
}
|
||||
|
||||
return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
return $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag name suggestions from scanning existing tag names.
|
||||
* If no search term is given the 50 most popular tag names are provided.
|
||||
*/
|
||||
public function getNameSuggestions(string $searchTerm): Collection
|
||||
public function getNameSuggestions(?string $searchTerm): Collection
|
||||
{
|
||||
$query = Tag::query()
|
||||
->select('*', DB::raw('count(*) as count'))
|
||||
->groupBy('name');
|
||||
|
||||
if ($searchTerm) {
|
||||
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'asc');
|
||||
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
|
||||
} else {
|
||||
$query = $query->orderBy('count', 'desc')->take(50);
|
||||
}
|
||||
|
||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
|
||||
|
||||
return $query->pluck('name');
|
||||
return $query->get(['name'])->pluck('name');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,7 +80,7 @@ class TagRepo
|
||||
* If no search is given the 50 most popular values are provided.
|
||||
* Passing a tagName will only find values for a tags with a particular name.
|
||||
*/
|
||||
public function getValueSuggestions(string $searchTerm, string $tagName): Collection
|
||||
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
|
||||
{
|
||||
$query = Tag::query()
|
||||
->select('*', DB::raw('count(*) as count'))
|
||||
@@ -103,9 +96,9 @@ class TagRepo
|
||||
$query = $query->where('name', '=', $tagName);
|
||||
}
|
||||
|
||||
$query = $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type');
|
||||
$query = $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
|
||||
|
||||
return $query->pluck('value');
|
||||
return $query->get(['value'])->pluck('value');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,10 +22,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
*/
|
||||
class Webhook extends Model implements Loggable
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'endpoint', 'timeout'];
|
||||
|
||||
use HasFactory;
|
||||
|
||||
protected $casts = [
|
||||
'last_called_at' => 'datetime',
|
||||
'last_errored_at' => 'datetime',
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Actions;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class WebhookFormatter
|
||||
{
|
||||
protected Webhook $webhook;
|
||||
protected string $event;
|
||||
protected User $initiator;
|
||||
protected int $initiatedTime;
|
||||
|
||||
/**
|
||||
* @var string|Loggable
|
||||
*/
|
||||
protected $detail;
|
||||
|
||||
/**
|
||||
* @var array{condition: callable(string, Model):bool, format: callable(Model):void}[]
|
||||
*/
|
||||
protected $modelFormatters = [];
|
||||
|
||||
public function __construct(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime)
|
||||
{
|
||||
$this->webhook = $webhook;
|
||||
$this->event = $event;
|
||||
$this->initiator = $initiator;
|
||||
$this->initiatedTime = $initiatedTime;
|
||||
$this->detail = is_object($detail) ? clone $detail : $detail;
|
||||
}
|
||||
|
||||
public function format(): array
|
||||
{
|
||||
$data = [
|
||||
'event' => $this->event,
|
||||
'text' => $this->formatText(),
|
||||
'triggered_at' => Carbon::createFromTimestampUTC($this->initiatedTime)->toISOString(),
|
||||
'triggered_by' => $this->initiator->attributesToArray(),
|
||||
'triggered_by_profile_url' => $this->initiator->getProfileUrl(),
|
||||
'webhook_id' => $this->webhook->id,
|
||||
'webhook_name' => $this->webhook->name,
|
||||
];
|
||||
|
||||
if (method_exists($this->detail, 'getUrl')) {
|
||||
$data['url'] = $this->detail->getUrl();
|
||||
}
|
||||
|
||||
if ($this->detail instanceof Model) {
|
||||
$data['related_item'] = $this->formatModel();
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callable(string, Model):bool $condition
|
||||
* @param callable(Model):void $format
|
||||
*/
|
||||
public function addModelFormatter(callable $condition, callable $format): void
|
||||
{
|
||||
$this->modelFormatters[] = [
|
||||
'condition' => $condition,
|
||||
'format' => $format,
|
||||
];
|
||||
}
|
||||
|
||||
public function addDefaultModelFormatters(): void
|
||||
{
|
||||
// Load entity owner, creator, updater details
|
||||
$this->addModelFormatter(
|
||||
fn ($event, $model) => ($model instanceof Entity),
|
||||
fn ($model) => $model->load(['ownedBy', 'createdBy', 'updatedBy'])
|
||||
);
|
||||
|
||||
// Load revision detail for page update and create events
|
||||
$this->addModelFormatter(
|
||||
fn ($event, $model) => ($model instanceof Page && ($event === ActivityType::PAGE_CREATE || $event === ActivityType::PAGE_UPDATE)),
|
||||
fn ($model) => $model->load('currentRevision')
|
||||
);
|
||||
}
|
||||
|
||||
protected function formatModel(): array
|
||||
{
|
||||
/** @var Model $model */
|
||||
$model = $this->detail;
|
||||
$model->unsetRelations();
|
||||
|
||||
foreach ($this->modelFormatters as $formatter) {
|
||||
if ($formatter['condition']($this->event, $model)) {
|
||||
$formatter['format']($model);
|
||||
}
|
||||
}
|
||||
|
||||
return $model->toArray();
|
||||
}
|
||||
|
||||
protected function formatText(): string
|
||||
{
|
||||
$textParts = [
|
||||
$this->initiator->name,
|
||||
trans('activities.' . $this->event),
|
||||
];
|
||||
|
||||
if ($this->detail instanceof Entity) {
|
||||
$textParts[] = '"' . $this->detail->name . '"';
|
||||
}
|
||||
|
||||
return implode(' ', $textParts);
|
||||
}
|
||||
|
||||
public static function getDefault(string $event, Webhook $webhook, $detail, User $initiator, int $initiatedTime): self
|
||||
{
|
||||
$instance = new self($event, $webhook, $detail, $initiator, $initiatedTime);
|
||||
$instance->addDefaultModelFormatters();
|
||||
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
*/
|
||||
class WebhookTrackedEvent extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['event'];
|
||||
|
||||
use HasFactory;
|
||||
}
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
|
||||
class ApiEntityListFormatter
|
||||
{
|
||||
/**
|
||||
* The list to be formatted.
|
||||
* @var Entity[]
|
||||
*/
|
||||
protected $list = [];
|
||||
|
||||
/**
|
||||
* The fields to show in the formatted data.
|
||||
* Can be a plain string array item for a direct model field (If existing on model).
|
||||
* If the key is a string, with a callable value, the return value of the callable
|
||||
* will be used for the resultant value. A null return value will omit the property.
|
||||
* @var array<string|int, string|callable>
|
||||
*/
|
||||
protected $fields = [
|
||||
'id', 'name', 'slug', 'book_id', 'chapter_id',
|
||||
'draft', 'template', 'created_at', 'updated_at',
|
||||
];
|
||||
|
||||
public function __construct(array $list)
|
||||
{
|
||||
$this->list = $list;
|
||||
|
||||
// Default dynamic fields
|
||||
$this->withField('url', fn(Entity $entity) => $entity->getUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a field to be used in the formatter, with the property using the given
|
||||
* name and value being the return type of the given callback.
|
||||
*/
|
||||
public function withField(string $property, callable $callback): self
|
||||
{
|
||||
$this->fields[$property] = $callback;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the 'type' property in the response reflecting the entity type.
|
||||
* EG: page, chapter, bookshelf, book
|
||||
* To be included in results with non-pre-determined types.
|
||||
*/
|
||||
public function withType(): self
|
||||
{
|
||||
$this->withField('type', fn(Entity $entity) => $entity->getType());
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Include tags in the formatted data.
|
||||
*/
|
||||
public function withTags(): self
|
||||
{
|
||||
$this->withField('tags', fn(Entity $entity) => $entity->tags);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the data and return an array of formatted content.
|
||||
* @return array[]
|
||||
*/
|
||||
public function format(): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($this->list as $item) {
|
||||
$results[] = $this->formatSingle($item);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single entity item to a plain array.
|
||||
*/
|
||||
protected function formatSingle(Entity $entity): array
|
||||
{
|
||||
$result = [];
|
||||
$values = (clone $entity)->toArray();
|
||||
|
||||
foreach ($this->fields as $field => $callback) {
|
||||
if (is_string($callback)) {
|
||||
$field = $callback;
|
||||
if (!isset($values[$field])) {
|
||||
continue;
|
||||
}
|
||||
$value = $values[$field];
|
||||
} else {
|
||||
$value = $callback($entity);
|
||||
if (is_null($value)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$result[$field] = $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -2,31 +2,24 @@
|
||||
|
||||
namespace BookStack\Api;
|
||||
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ListingResponseBuilder
|
||||
{
|
||||
protected Builder $query;
|
||||
protected Request $request;
|
||||
|
||||
/**
|
||||
* @var string[]
|
||||
*/
|
||||
protected array $fields;
|
||||
protected $query;
|
||||
protected $request;
|
||||
protected $fields;
|
||||
|
||||
/**
|
||||
* @var array<callable>
|
||||
*/
|
||||
protected array $resultModifiers = [];
|
||||
protected $resultModifiers = [];
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected array $filterOperators = [
|
||||
protected $filterOperators = [
|
||||
'eq' => '=',
|
||||
'ne' => '!=',
|
||||
'gt' => '>',
|
||||
@@ -70,9 +63,9 @@ class ListingResponseBuilder
|
||||
/**
|
||||
* Add a callback to modify each element of the results.
|
||||
*
|
||||
* @param (callable(Model): void) $modifier
|
||||
* @param (callable(Model)) $modifier
|
||||
*/
|
||||
public function modifyResults(callable $modifier): void
|
||||
public function modifyResults($modifier): void
|
||||
{
|
||||
$this->resultModifiers[] = $modifier;
|
||||
}
|
||||
|
||||
@@ -28,8 +28,10 @@ class GroupSyncService
|
||||
*/
|
||||
protected function externalIdMatchesGroupNames(string $externalId, array $groupNames): bool
|
||||
{
|
||||
foreach ($this->parseRoleExternalAuthId($externalId) as $externalAuthId) {
|
||||
if (in_array($externalAuthId, $groupNames)) {
|
||||
$externalAuthIds = explode(',', strtolower($externalId));
|
||||
|
||||
foreach ($externalAuthIds as $externalAuthId) {
|
||||
if (in_array(trim($externalAuthId), $groupNames)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -37,18 +39,6 @@ class GroupSyncService
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function parseRoleExternalAuthId(string $externalId): array
|
||||
{
|
||||
$inputIds = preg_split('/(?<!\\\),/', strtolower($externalId));
|
||||
$cleanIds = [];
|
||||
|
||||
foreach ($inputIds as $inputId) {
|
||||
$cleanIds[] = str_replace('\,', ',', trim($inputId));
|
||||
}
|
||||
|
||||
return $cleanIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match an array of group names to BookStack system roles.
|
||||
* Formats group names to be lower-case and hyphenated.
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace BookStack\Auth\Access\Guards;
|
||||
use BookStack\Auth\Access\LdapService;
|
||||
use BookStack\Auth\Access\RegistrationService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Exceptions\LdapException;
|
||||
use BookStack\Exceptions\LoginAttemptEmailNeededException;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
@@ -16,7 +15,7 @@ use Illuminate\Support\Str;
|
||||
|
||||
class LdapSessionGuard extends ExternalBaseSessionGuard
|
||||
{
|
||||
protected LdapService $ldapService;
|
||||
protected $ldapService;
|
||||
|
||||
/**
|
||||
* LdapSessionGuard constructor.
|
||||
@@ -60,9 +59,8 @@ class LdapSessionGuard extends ExternalBaseSessionGuard
|
||||
* @param array $credentials
|
||||
* @param bool $remember
|
||||
*
|
||||
* @throws LdapException*@throws \BookStack\Exceptions\JsonDebugException
|
||||
* @throws LoginAttemptException
|
||||
* @throws JsonDebugException
|
||||
* @throws LdapException
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
|
||||
@@ -15,17 +15,12 @@ use Illuminate\Support\Facades\Log;
|
||||
*/
|
||||
class LdapService
|
||||
{
|
||||
protected Ldap $ldap;
|
||||
protected GroupSyncService $groupSyncService;
|
||||
protected UserAvatars $userAvatars;
|
||||
|
||||
/**
|
||||
* @var resource
|
||||
*/
|
||||
protected $ldap;
|
||||
protected $groupSyncService;
|
||||
protected $ldapConnection;
|
||||
|
||||
protected array $config;
|
||||
protected bool $enabled;
|
||||
protected $userAvatars;
|
||||
protected $config;
|
||||
protected $enabled;
|
||||
|
||||
/**
|
||||
* LdapService constructor.
|
||||
@@ -105,7 +100,7 @@ class LdapService
|
||||
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
|
||||
'dn' => $user['dn'],
|
||||
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
|
||||
'avatar' => $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
|
||||
'avatar'=> $thumbnailAttr ? $this->getUserResponseProperty($user, $thumbnailAttr, null) : null,
|
||||
];
|
||||
|
||||
if ($this->config['dump_user_details']) {
|
||||
@@ -279,7 +274,6 @@ class LdapService
|
||||
* Get the groups a user is a part of on ldap.
|
||||
*
|
||||
* @throws LdapException
|
||||
* @throws JsonDebugException
|
||||
*/
|
||||
public function getUserGroups(string $userName): array
|
||||
{
|
||||
@@ -291,17 +285,8 @@ class LdapService
|
||||
}
|
||||
|
||||
$userGroups = $this->groupFilter($user);
|
||||
$allGroups = $this->getGroupsRecursive($userGroups, []);
|
||||
|
||||
if ($this->config['dump_user_groups']) {
|
||||
throw new JsonDebugException([
|
||||
'details_from_ldap' => $user,
|
||||
'parsed_direct_user_groups' => $userGroups,
|
||||
'parsed_recursive_user_groups' => $allGroups,
|
||||
]);
|
||||
}
|
||||
|
||||
return $allGroups;
|
||||
return $this->getGroupsRecursive($userGroups, []);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -384,7 +369,6 @@ class LdapService
|
||||
* Sync the LDAP groups to the user roles for the current user.
|
||||
*
|
||||
* @throws LdapException
|
||||
* @throws JsonDebugException
|
||||
*/
|
||||
public function syncGroups(User $user, string $username)
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace BookStack\Auth\Access;
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Access\Mfa\MfaSession;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\LoginAttemptException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Facades\Theme;
|
||||
@@ -150,7 +149,6 @@ class LoginService
|
||||
* May interrupt the flow if extra authentication requirements are imposed.
|
||||
*
|
||||
* @throws StoppedAuthenticationException
|
||||
* @throws LoginAttemptException
|
||||
*/
|
||||
public function attempt(array $credentials, string $method, bool $remember = false): bool
|
||||
{
|
||||
|
||||
@@ -67,10 +67,11 @@ class OidcJwtSigningKey
|
||||
throw new OidcInvalidKeyException("Only RS256 keys are currently supported. Found key using {$alg}");
|
||||
}
|
||||
|
||||
// 'use' is optional for a JWK but we assume 'sig' where no value exists since that's what
|
||||
// the OIDC discovery spec infers since 'sig' MUST be set if encryption keys come into play.
|
||||
$use = $jwk['use'] ?? 'sig';
|
||||
if ($use !== 'sig') {
|
||||
if (empty($jwk['use'])) {
|
||||
throw new OidcInvalidKeyException('A "use" parameter on the provided key is expected');
|
||||
}
|
||||
|
||||
if ($jwk['use'] !== 'sig') {
|
||||
throw new OidcInvalidKeyException("Only signature keys are currently supported. Found key for use {$jwk['use']}");
|
||||
}
|
||||
|
||||
|
||||
@@ -30,11 +30,6 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
*/
|
||||
protected $tokenEndpoint;
|
||||
|
||||
/**
|
||||
* Scopes to use for the OIDC authorization call.
|
||||
*/
|
||||
protected array $scopes = ['openid', 'profile', 'email'];
|
||||
|
||||
/**
|
||||
* Returns the base URL for authorizing a client.
|
||||
*/
|
||||
@@ -59,15 +54,6 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an additional scope to this provider upon the default.
|
||||
*/
|
||||
public function addScope(string $scope): void
|
||||
{
|
||||
$this->scopes[] = $scope;
|
||||
$this->scopes = array_unique($this->scopes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default scopes used by this provider.
|
||||
*
|
||||
@@ -76,7 +62,7 @@ class OidcOAuthProvider extends AbstractProvider
|
||||
*/
|
||||
protected function getDefaultScopes(): array
|
||||
{
|
||||
return $this->scopes;
|
||||
return ['openid', 'profile', 'email'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,17 +15,40 @@ use Psr\Http\Client\ClientInterface;
|
||||
*/
|
||||
class OidcProviderSettings
|
||||
{
|
||||
public string $issuer;
|
||||
public string $clientId;
|
||||
public string $clientSecret;
|
||||
public ?string $redirectUri;
|
||||
public ?string $authorizationEndpoint;
|
||||
public ?string $tokenEndpoint;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $issuer;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $clientId;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $clientSecret;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $redirectUri;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $authorizationEndpoint;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
public $tokenEndpoint;
|
||||
|
||||
/**
|
||||
* @var string[]|array[]
|
||||
*/
|
||||
public ?array $keys = [];
|
||||
public $keys = [];
|
||||
|
||||
public function __construct(array $settings)
|
||||
{
|
||||
@@ -141,10 +164,9 @@ class OidcProviderSettings
|
||||
protected function filterKeys(array $keys): array
|
||||
{
|
||||
return array_filter($keys, function (array $key) {
|
||||
$alg = $key['alg'] ?? 'RS256';
|
||||
$use = $key['use'] ?? 'sig';
|
||||
$alg = $key['alg'] ?? null;
|
||||
|
||||
return $key['kty'] === 'RSA' && $use === 'sig' && $alg === 'RS256';
|
||||
return $key['kty'] === 'RSA' && $key['use'] === 'sig' && (is_null($alg) || $alg === 'RS256');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,18 +2,20 @@
|
||||
|
||||
namespace BookStack\Auth\Access\Oidc;
|
||||
|
||||
use BookStack\Auth\Access\GroupSyncService;
|
||||
use function auth;
|
||||
use BookStack\Auth\Access\LoginService;
|
||||
use BookStack\Auth\Access\RegistrationService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Exceptions\JsonDebugException;
|
||||
use BookStack\Exceptions\StoppedAuthenticationException;
|
||||
use BookStack\Exceptions\UserRegistrationException;
|
||||
use Illuminate\Support\Arr;
|
||||
use function config;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
|
||||
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
|
||||
use Psr\Http\Client\ClientInterface as HttpClient;
|
||||
use function trans;
|
||||
use function url;
|
||||
|
||||
/**
|
||||
* Class OpenIdConnectService
|
||||
@@ -24,21 +26,15 @@ class OidcService
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
protected HttpClient $httpClient;
|
||||
protected GroupSyncService $groupService;
|
||||
|
||||
/**
|
||||
* OpenIdService constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService,
|
||||
HttpClient $httpClient,
|
||||
GroupSyncService $groupService
|
||||
) {
|
||||
public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient)
|
||||
{
|
||||
$this->registrationService = $registrationService;
|
||||
$this->loginService = $loginService;
|
||||
$this->httpClient = $httpClient;
|
||||
$this->groupService = $groupService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,6 +48,7 @@ class OidcService
|
||||
{
|
||||
$settings = $this->getProviderSettings();
|
||||
$provider = $this->getProvider($settings);
|
||||
|
||||
return [
|
||||
'url' => $provider->getAuthorizationUrl(),
|
||||
'state' => $provider->getState(),
|
||||
@@ -120,31 +117,10 @@ class OidcService
|
||||
*/
|
||||
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
|
||||
{
|
||||
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [
|
||||
return new OidcOAuthProvider($settings->arrayForProvider(), [
|
||||
'httpClient' => $this->httpClient,
|
||||
'optionProvider' => new HttpBasicAuthOptionProvider(),
|
||||
]);
|
||||
|
||||
foreach ($this->getAdditionalScopes() as $scope) {
|
||||
$provider->addScope($scope);
|
||||
}
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get any user-defined addition/custom scopes to apply to the authentication request.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
protected function getAdditionalScopes(): array
|
||||
{
|
||||
$scopeConfig = $this->config()['additional_scopes'] ?: '';
|
||||
|
||||
$scopeArr = explode(',', $scopeConfig);
|
||||
$scopeArr = array_map(fn (string $scope) => trim($scope), $scopeArr);
|
||||
|
||||
return array_filter($scopeArr);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,32 +145,10 @@ class OidcService
|
||||
return implode(' ', $displayName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the assigned groups from the id token.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
protected function getUserGroups(OidcIdToken $token): array
|
||||
{
|
||||
$groupsAttr = $this->config()['groups_claim'];
|
||||
if (empty($groupsAttr)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groupsList = Arr::get($token->getAllClaims(), $groupsAttr);
|
||||
if (!is_array($groupsList)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter($groupsList, function ($val) {
|
||||
return is_string($val);
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the details of a user from an ID token.
|
||||
*
|
||||
* @return array{name: string, email: string, external_id: string, groups: string[]}
|
||||
* @return array{name: string, email: string, external_id: string}
|
||||
*/
|
||||
protected function getUserDetails(OidcIdToken $token): array
|
||||
{
|
||||
@@ -204,7 +158,6 @@ class OidcService
|
||||
'external_id' => $id,
|
||||
'email' => $token->getClaim('email'),
|
||||
'name' => $this->getUserDisplayName($token, $id),
|
||||
'groups' => $this->getUserGroups($token),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -256,12 +209,6 @@ class OidcService
|
||||
throw new OidcException($exception->getMessage());
|
||||
}
|
||||
|
||||
if ($this->shouldSyncGroups()) {
|
||||
$groups = $userDetails['groups'];
|
||||
$detachExisting = $this->config()['remove_from_groups'];
|
||||
$this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
|
||||
}
|
||||
|
||||
$this->loginService->login($user, 'oidc');
|
||||
|
||||
return $user;
|
||||
@@ -274,12 +221,4 @@ class OidcService
|
||||
{
|
||||
return config('oidc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if groups should be synced.
|
||||
*/
|
||||
protected function shouldSyncGroups(): bool
|
||||
{
|
||||
return $this->config()['user_to_groups'] !== false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,14 @@ use OneLogin\Saml2\ValidationError;
|
||||
*/
|
||||
class Saml2Service
|
||||
{
|
||||
protected array $config;
|
||||
protected RegistrationService $registrationService;
|
||||
protected LoginService $loginService;
|
||||
protected GroupSyncService $groupSyncService;
|
||||
protected $config;
|
||||
protected $registrationService;
|
||||
protected $loginService;
|
||||
protected $groupSyncService;
|
||||
|
||||
/**
|
||||
* Saml2Service constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
RegistrationService $registrationService,
|
||||
LoginService $loginService,
|
||||
@@ -106,10 +109,9 @@ class Saml2Service
|
||||
$errors = $toolkit->getErrors();
|
||||
|
||||
if (!empty($errors)) {
|
||||
$reason = $toolkit->getLastErrorReason();
|
||||
$message = 'Invalid ACS Response; Errors: ' . implode(', ', $errors);
|
||||
$message .= $reason ? "; Reason: {$reason}" : '';
|
||||
throw new Error($message);
|
||||
throw new Error(
|
||||
'Invalid ACS Response: ' . implode(', ', $errors)
|
||||
);
|
||||
}
|
||||
|
||||
if (!$toolkit->isAuthenticated()) {
|
||||
@@ -166,7 +168,7 @@ class Saml2Service
|
||||
*/
|
||||
public function metadata(): string
|
||||
{
|
||||
$toolKit = $this->getToolkit(true);
|
||||
$toolKit = $this->getToolkit();
|
||||
$settings = $toolKit->getSettings();
|
||||
$metadata = $settings->getSPMetadata();
|
||||
$errors = $settings->validateMetadata($metadata);
|
||||
@@ -187,7 +189,7 @@ class Saml2Service
|
||||
* @throws Error
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function getToolkit(bool $spOnly = false): Auth
|
||||
protected function getToolkit(): Auth
|
||||
{
|
||||
$settings = $this->config['onelogin'];
|
||||
$overrides = $this->config['onelogin_overrides'] ?? [];
|
||||
@@ -197,14 +199,14 @@ class Saml2Service
|
||||
}
|
||||
|
||||
$metaDataSettings = [];
|
||||
if (!$spOnly && $this->config['autoload_from_metadata']) {
|
||||
if ($this->config['autoload_from_metadata']) {
|
||||
$metaDataSettings = IdPMetadataParser::parseRemoteXML($settings['idp']['entityId']);
|
||||
}
|
||||
|
||||
$spSettings = $this->loadOneloginServiceProviderDetails();
|
||||
$settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides);
|
||||
|
||||
return new Auth($settings, $spOnly);
|
||||
return new Auth($settings);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Model;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property ?int $role_id
|
||||
* @property ?int $user_id
|
||||
* @property string $entity_type
|
||||
* @property int $entity_id
|
||||
* @property bool $view
|
||||
*/
|
||||
class CollapsedPermission extends Model
|
||||
{
|
||||
protected $table = 'entity_permissions_collapsed';
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Collapsed permissions act as a "flattened" view of entity-level permissions in the system
|
||||
* so inheritance does not have to managed as part of permission querying.
|
||||
*/
|
||||
class CollapsedPermissionBuilder
|
||||
{
|
||||
/**
|
||||
* Re-generate all collapsed permissions from scratch.
|
||||
*/
|
||||
public function rebuildForAll()
|
||||
{
|
||||
DB::table('entity_permissions_collapsed')->truncate();
|
||||
|
||||
// Chunk through all books
|
||||
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) {
|
||||
$this->buildForBooks($books, false);
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->withTrashed()
|
||||
->select(['id'])
|
||||
->chunk(50, function (EloquentCollection $shelves) {
|
||||
$this->generateCollapsedPermissions($shelves->all());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the collapsed permissions for a particular entity.
|
||||
*/
|
||||
public function rebuildForEntity(Entity $entity)
|
||||
{
|
||||
$entities = [$entity];
|
||||
if ($entity instanceof Book) {
|
||||
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
|
||||
$this->buildForBooks($books, true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BookChild $entity */
|
||||
if ($entity->book) {
|
||||
$entities[] = $entity->book;
|
||||
}
|
||||
|
||||
if ($entity instanceof Page && $entity->chapter_id) {
|
||||
$entities[] = $entity->chapter;
|
||||
}
|
||||
|
||||
if ($entity instanceof Chapter) {
|
||||
foreach ($entity->pages as $page) {
|
||||
$entities[] = $page;
|
||||
}
|
||||
}
|
||||
|
||||
$this->buildForEntities($entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a query for fetching a book with its children.
|
||||
*/
|
||||
protected function bookFetchQuery(): Builder
|
||||
{
|
||||
return Book::query()->withTrashed()
|
||||
->select(['id'])->with([
|
||||
'chapters' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'book_id']);
|
||||
},
|
||||
'pages' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'book_id', 'chapter_id']);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build collapsed permissions for the given books.
|
||||
*/
|
||||
protected function buildForBooks(EloquentCollection $books, bool $deleteOld)
|
||||
{
|
||||
$entities = clone $books;
|
||||
|
||||
/** @var Book $book */
|
||||
foreach ($books->all() as $book) {
|
||||
foreach ($book->getRelation('chapters') as $chapter) {
|
||||
$entities->push($chapter);
|
||||
}
|
||||
foreach ($book->getRelation('pages') as $page) {
|
||||
$entities->push($page);
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteOld) {
|
||||
$this->deleteForEntities($entities->all());
|
||||
}
|
||||
|
||||
$this->generateCollapsedPermissions($entities->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the collapsed permissions for a collection of entities.
|
||||
*/
|
||||
protected function buildForEntities(array $entities)
|
||||
{
|
||||
$this->deleteForEntities($entities);
|
||||
$this->generateCollapsedPermissions($entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the stored collapsed permissions for a list of entities.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
*/
|
||||
protected function deleteForEntities(array $entities)
|
||||
{
|
||||
$simpleEntities = $this->entitiesToSimpleEntities($entities);
|
||||
$idsByType = $this->entitiesToTypeIdMap($simpleEntities);
|
||||
|
||||
DB::transaction(function () use ($idsByType) {
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
foreach (array_chunk($ids, 1000) as $idChunk) {
|
||||
DB::table('entity_permissions_collapsed')
|
||||
->where('entity_type', '=', $type)
|
||||
->whereIn('entity_id', $idChunk)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given list of entities into "SimpleEntityData" representations
|
||||
* for faster usage and property access.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
*
|
||||
* @return SimpleEntityData[]
|
||||
*/
|
||||
protected function entitiesToSimpleEntities(array $entities): array
|
||||
{
|
||||
$simpleEntities = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$attrs = $entity->getAttributes();
|
||||
$simple = new SimpleEntityData();
|
||||
$simple->id = $attrs['id'];
|
||||
$simple->type = $entity->getMorphClass();
|
||||
$simple->book_id = $attrs['book_id'] ?? null;
|
||||
$simple->chapter_id = $attrs['chapter_id'] ?? null;
|
||||
$simpleEntities[] = $simple;
|
||||
}
|
||||
|
||||
return $simpleEntities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create & Save collapsed entity permissions.
|
||||
*
|
||||
* @param Entity[] $originalEntities
|
||||
*/
|
||||
protected function generateCollapsedPermissions(array $originalEntities)
|
||||
{
|
||||
$entities = $this->entitiesToSimpleEntities($originalEntities);
|
||||
$collapsedPermData = [];
|
||||
|
||||
// Fetch related entity permissions
|
||||
$permissions = $this->getEntityPermissionsForEntities($entities);
|
||||
|
||||
// Create a mapping of explicit entity permissions
|
||||
$permissionMap = new EntityPermissionMap($permissions);
|
||||
|
||||
// Create Joint Permission Data
|
||||
foreach ($entities as $entity) {
|
||||
array_push($collapsedPermData, ...$this->createCollapsedPermissionData($entity, $permissionMap));
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($collapsedPermData) {
|
||||
foreach (array_chunk($collapsedPermData, 1000) as $dataChunk) {
|
||||
DB::table('entity_permissions_collapsed')->insert($dataChunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create collapsed permission data for the given entity using the given permission map.
|
||||
*/
|
||||
protected function createCollapsedPermissionData(SimpleEntityData $entity, EntityPermissionMap $permissionMap): array
|
||||
{
|
||||
$chain = [
|
||||
$entity->type . ':' . $entity->id,
|
||||
$entity->chapter_id ? ('chapter:' . $entity->chapter_id) : null,
|
||||
$entity->book_id ? ('book:' . $entity->book_id) : null,
|
||||
];
|
||||
|
||||
$permissionData = [];
|
||||
$overridesApplied = [];
|
||||
|
||||
foreach ($chain as $entityTypeId) {
|
||||
if ($entityTypeId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$permissions = $permissionMap->getForEntity($entityTypeId);
|
||||
foreach ($permissions as $permission) {
|
||||
$related = $permission->getAssignedType() . ':' . $permission->getAssignedTypeId();
|
||||
if (!isset($overridesApplied[$related])) {
|
||||
$permissionData[] = [
|
||||
'role_id' => $permission->role_id,
|
||||
'user_id' => $permission->user_id,
|
||||
'view' => $permission->view,
|
||||
'entity_type' => $entity->type,
|
||||
'entity_id' => $entity->id,
|
||||
];
|
||||
$overridesApplied[$related] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $permissionData;
|
||||
}
|
||||
|
||||
/**
|
||||
* From the given entity list, provide back a mapping of entity types to
|
||||
* the ids of that given type. The type used is the DB morph class.
|
||||
*
|
||||
* @param SimpleEntityData[] $entities
|
||||
*
|
||||
* @return array<string, int[]>
|
||||
*/
|
||||
protected function entitiesToTypeIdMap(array $entities): array
|
||||
{
|
||||
$idsByType = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
if (!isset($idsByType[$entity->type])) {
|
||||
$idsByType[$entity->type] = [];
|
||||
}
|
||||
|
||||
$idsByType[$entity->type][] = $entity->id;
|
||||
}
|
||||
|
||||
return $idsByType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permissions for all the given entities.
|
||||
*
|
||||
* @param SimpleEntityData[] $entities
|
||||
*
|
||||
* @return EntityPermission[]
|
||||
*/
|
||||
protected function getEntityPermissionsForEntities(array $entities): array
|
||||
{
|
||||
$idsByType = $this->entitiesToTypeIdMap($entities);
|
||||
$permissionFetch = EntityPermission::query()
|
||||
->where(function (Builder $query) use ($idsByType) {
|
||||
foreach ($idsByType as $type => $ids) {
|
||||
$query->orWhere(function (Builder $query) use ($type, $ids) {
|
||||
$query->where('entity_type', '=', $type)->whereIn('entity_id', $ids);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return $permissionFetch->get()->all();
|
||||
}
|
||||
}
|
||||
@@ -2,68 +2,20 @@
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $role_id
|
||||
* @property int $user_id
|
||||
* @property int $entity_id
|
||||
* @property string $entity_type
|
||||
* @property boolean $view
|
||||
* @property boolean $create
|
||||
* @property boolean $update
|
||||
* @property boolean $delete
|
||||
*/
|
||||
class EntityPermission extends Model
|
||||
{
|
||||
public const PERMISSIONS = ['view', 'create', 'update', 'delete'];
|
||||
|
||||
protected $fillable = ['role_id', 'user_id', 'view', 'create', 'update', 'delete'];
|
||||
protected $fillable = ['role_id', 'action'];
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* Get the role assigned to this entity permission.
|
||||
* Get all this restriction's attached entity.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
|
||||
*/
|
||||
public function role(): BelongsTo
|
||||
public function restrictable()
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user assigned to this entity permission.
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of entity permission this is.
|
||||
* Will be one of: user, role, fallback
|
||||
*/
|
||||
public function getAssignedType(): string
|
||||
{
|
||||
if ($this->user_id) {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
if ($this->role_id) {
|
||||
return 'role';
|
||||
}
|
||||
|
||||
return 'fallback';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ID for the assigned type of permission.
|
||||
* (Role/User ID). Defaults to 0 for fallback.
|
||||
*/
|
||||
public function getAssignedTypeId(): int
|
||||
{
|
||||
return $this->user_id ?? $this->role_id ?? 0;
|
||||
return $this->morphTo('restrictable');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
class EntityPermissionMap
|
||||
{
|
||||
protected array $map = [];
|
||||
|
||||
/**
|
||||
* @param EntityPermission[] $permissions
|
||||
*/
|
||||
public function __construct(array $permissions = [])
|
||||
{
|
||||
foreach ($permissions as $entityPermission) {
|
||||
$this->addPermission($entityPermission);
|
||||
}
|
||||
}
|
||||
|
||||
protected function addPermission(EntityPermission $permission)
|
||||
{
|
||||
$entityCombinedId = $permission->entity_type . ':' . $permission->entity_id;
|
||||
|
||||
if (!isset($this->map[$entityCombinedId])) {
|
||||
$this->map[$entityCombinedId] = [];
|
||||
}
|
||||
|
||||
$this->map[$entityCombinedId][] = $permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return EntityPermission[]
|
||||
*/
|
||||
public function getForEntity(string $typeIdString): array
|
||||
{
|
||||
return $this->map[$typeIdString] ?? [];
|
||||
}
|
||||
}
|
||||
31
app/Auth/Permissions/JointPermission.php
Normal file
31
app/Auth/Permissions/JointPermission.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
|
||||
class JointPermission extends Model
|
||||
{
|
||||
protected $primaryKey = null;
|
||||
public $timestamps = false;
|
||||
|
||||
/**
|
||||
* Get the role that this points to.
|
||||
*/
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Role::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity this points to.
|
||||
*/
|
||||
public function entity(): MorphOne
|
||||
{
|
||||
return $this->morphOne(Entity::class, 'entity');
|
||||
}
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Model;
|
||||
use BookStack\Traits\HasCreatorAndUpdater;
|
||||
use BookStack\Traits\HasOwner;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class PermissionApplicator
|
||||
{
|
||||
/**
|
||||
* Checks if an entity has a restriction set upon it.
|
||||
*
|
||||
* @param HasCreatorAndUpdater|HasOwner $ownable
|
||||
*/
|
||||
public function checkOwnableUserAccess(Model $ownable, string $permission): bool
|
||||
{
|
||||
$explodedPermission = explode('-', $permission);
|
||||
$action = $explodedPermission[1] ?? $explodedPermission[0];
|
||||
$fullPermission = count($explodedPermission) > 1 ? $permission : $ownable->getMorphClass() . '-' . $permission;
|
||||
|
||||
$user = $this->currentUser();
|
||||
$userRoleIds = $this->getCurrentUserRoleIds();
|
||||
|
||||
$allRolePermission = $user->can($fullPermission . '-all');
|
||||
$ownRolePermission = $user->can($fullPermission . '-own');
|
||||
$nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
|
||||
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
|
||||
$ownableFieldVal = $ownable->getAttribute($ownerField);
|
||||
|
||||
if (is_null($ownableFieldVal)) {
|
||||
throw new InvalidArgumentException("{$ownerField} field used but has not been loaded");
|
||||
}
|
||||
|
||||
$isOwner = $user->id === $ownableFieldVal;
|
||||
$hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission);
|
||||
|
||||
// Handle non entity specific jointPermissions
|
||||
if (in_array($explodedPermission[0], $nonJointPermissions)) {
|
||||
return $hasRolePermission;
|
||||
}
|
||||
|
||||
$hasApplicableEntityPermissions = $this->hasEntityPermission($ownable, $userRoleIds, $user->id, $action);
|
||||
|
||||
return is_null($hasApplicableEntityPermissions) ? $hasRolePermission : $hasApplicableEntityPermissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are permissions that are applicable for the given entity item, action and roles.
|
||||
* Returns null when no entity permissions are in force.
|
||||
*/
|
||||
protected function hasEntityPermission(Entity $entity, array $userRoleIds, int $userId, string $action): ?bool
|
||||
{
|
||||
$this->ensureValidEntityAction($action);
|
||||
|
||||
$adminRoleId = Role::getSystemRole('admin')->id;
|
||||
if (in_array($adminRoleId, $userRoleIds)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The array order here is very important due to the fact we walk up the chain
|
||||
// in the flattening loop below. Earlier items in the chain have higher priority.
|
||||
$typeIdList = [$entity->getMorphClass() . ':' . $entity->id];
|
||||
if ($entity instanceof Page && $entity->chapter_id) {
|
||||
$typeIdList[] = 'chapter:' . $entity->chapter_id;
|
||||
}
|
||||
|
||||
if ($entity instanceof Page || $entity instanceof Chapter) {
|
||||
$typeIdList[] = 'book:' . $entity->book_id;
|
||||
}
|
||||
|
||||
$relevantPermissions = EntityPermission::query()
|
||||
->where(function (Builder $query) use ($typeIdList) {
|
||||
foreach ($typeIdList as $typeId) {
|
||||
$query->orWhere(function (Builder $query) use ($typeId) {
|
||||
[$type, $id] = explode(':', $typeId);
|
||||
$query->where('entity_type', '=', $type)
|
||||
->where('entity_id', '=', $id);
|
||||
});
|
||||
}
|
||||
})->where(function (Builder $query) use ($userRoleIds, $userId) {
|
||||
$query->whereIn('role_id', $userRoleIds)
|
||||
->orWhere('user_id', '=', $userId)
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->whereNull(['role_id', 'user_id']);
|
||||
});
|
||||
})->get(['entity_id', 'entity_type', 'role_id', 'user_id', $action])
|
||||
->all();
|
||||
|
||||
$permissionMap = new EntityPermissionMap($relevantPermissions);
|
||||
$permitsByType = ['user' => [], 'fallback' => [], 'role' => []];
|
||||
|
||||
// Collapse and simplify permission structure
|
||||
foreach ($typeIdList as $typeId) {
|
||||
$permissions = $permissionMap->getForEntity($typeId);
|
||||
foreach ($permissions as $permission) {
|
||||
$related = $permission->getAssignedType();
|
||||
$relatedId = $permission->getAssignedTypeId();
|
||||
if (!isset($permitsByType[$related][$relatedId])) {
|
||||
$permitsByType[$related][$relatedId] = $permission->$action;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return user-level permission if exists
|
||||
if (count($permitsByType['user']) > 0) {
|
||||
return boolval(array_values($permitsByType['user'])[0]);
|
||||
}
|
||||
|
||||
// Return grant or reject from role-level if exists
|
||||
if (count($permitsByType['role']) > 0) {
|
||||
return boolval(max($permitsByType['role']));
|
||||
}
|
||||
|
||||
// Return fallback permission if exists
|
||||
if (count($permitsByType['fallback']) > 0) {
|
||||
return boolval($permitsByType['fallback'][0]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user has the given permission for any items in the system.
|
||||
* Can be passed an entity instance to filter on a specific type.
|
||||
*/
|
||||
public function checkUserHasEntityPermissionOnAny(string $action, string $entityClass = ''): bool
|
||||
{
|
||||
$this->ensureValidEntityAction($action);
|
||||
|
||||
$permissionQuery = EntityPermission::query()
|
||||
->where($action, '=', true)
|
||||
->where(function (Builder $query) {
|
||||
$query->whereIn('role_id', $this->getCurrentUserRoleIds())
|
||||
->orWhere('user_id', '=', $this->currentUser()->id);
|
||||
});
|
||||
|
||||
if (!empty($entityClass)) {
|
||||
/** @var Entity $entityInstance */
|
||||
$entityInstance = app()->make($entityClass);
|
||||
$permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
|
||||
}
|
||||
|
||||
$hasPermission = $permissionQuery->count() > 0;
|
||||
|
||||
return $hasPermission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit the given entity query so that the query will only
|
||||
* return items that the user has view permission for.
|
||||
*/
|
||||
public function restrictEntityQuery(Builder $query, string $morphClass): Builder
|
||||
{
|
||||
$this->applyPermissionsToQuery($query, $query->getModel()->getTable(), $morphClass, 'id', '');
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyPermissionsToQuery($query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn): void
|
||||
{
|
||||
if ($this->currentUser()->hasSystemRole('admin')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->applyFallbackJoin($query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
$this->applyRoleJoin($query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
$this->applyUserJoin($query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
$this->applyPermissionWhereFilter($query, $queryTable, $entityTypeLimiter, $entityTypeColumn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the where condition to a permission restricting query, to limit based upon the values of the joined
|
||||
* permission data. Query must have joins pre-applied.
|
||||
* Either entityTypeLimiter or entityTypeColumn should be supplied, with the other empty.
|
||||
* Both should not be applied since that would conflict upon intent.
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyPermissionWhereFilter($query, string $queryTable, string $entityTypeLimiter, string $entityTypeColumn)
|
||||
{
|
||||
$abilities = ['all' => [], 'own' => []];
|
||||
$types = $entityTypeLimiter ? [$entityTypeLimiter] : ['page', 'chapter', 'bookshelf', 'book'];
|
||||
$fullEntityTypeColumn = $queryTable . '.' . $entityTypeColumn;
|
||||
foreach ($types as $type) {
|
||||
$abilities['all'][$type] = userCan($type . '-view-all');
|
||||
$abilities['own'][$type] = userCan($type . '-view-own');
|
||||
}
|
||||
|
||||
$abilities['all'] = array_filter($abilities['all']);
|
||||
$abilities['own'] = array_filter($abilities['own']);
|
||||
|
||||
$query->where(function (Builder $query) use ($abilities, $fullEntityTypeColumn, $entityTypeColumn) {
|
||||
$query->where('perms_user', '=', 1)
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->whereNull('perms_user')->where('perms_role', '=', 1);
|
||||
})->orWhere(function (Builder $query) {
|
||||
$query->whereNull(['perms_user', 'perms_role'])
|
||||
->where('perms_fallback', '=', 1);
|
||||
});
|
||||
|
||||
if (count($abilities['all']) > 0) {
|
||||
$query->orWhere(function (Builder $query) use ($abilities, $fullEntityTypeColumn, $entityTypeColumn) {
|
||||
$query->whereNull(['perms_user', 'perms_role', 'perms_fallback']);
|
||||
if ($entityTypeColumn) {
|
||||
$query->whereIn($fullEntityTypeColumn, array_keys($abilities['all']));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (count($abilities['own']) > 0) {
|
||||
$query->orWhere(function (Builder $query) use ($abilities, $fullEntityTypeColumn, $entityTypeColumn) {
|
||||
$query->whereNull(['perms_user', 'perms_role', 'perms_fallback'])
|
||||
->where('owned_by', '=', $this->currentUser()->id);
|
||||
if ($entityTypeColumn) {
|
||||
$query->whereIn($fullEntityTypeColumn, array_keys($abilities['all']));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyPermissionJoin(callable $joinCallable, string $subAlias, $query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$joinCondition = $this->getJoinCondition($queryTable, $subAlias, $entityIdColumn, $entityTypeColumn);
|
||||
|
||||
$query->joinSub(function (QueryBuilder $joinQuery) use ($joinCallable, $entityTypeLimiter) {
|
||||
$joinQuery->select(['entity_id', 'entity_type'])->from('entity_permissions_collapsed')
|
||||
->groupBy('entity_id', 'entity_type');
|
||||
$joinCallable($joinQuery);
|
||||
|
||||
if ($entityTypeLimiter) {
|
||||
$joinQuery->where('entity_type', '=', $entityTypeLimiter);
|
||||
}
|
||||
}, $subAlias, $joinCondition, null, null, 'left');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyUserJoin($query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$this->applyPermissionJoin(function (QueryBuilder $joinQuery) {
|
||||
$joinQuery->selectRaw('max(view) as perms_user')
|
||||
->where('user_id', '=', $this->currentUser()->id);
|
||||
}, 'p_u', $query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyRoleJoin($query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$this->applyPermissionJoin(function (QueryBuilder $joinQuery) {
|
||||
$joinQuery->selectRaw('max(view) as perms_role')
|
||||
->whereIn('role_id', $this->getCurrentUserRoleIds());
|
||||
}, 'p_r', $query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
protected function applyFallbackJoin($query, string $queryTable, string $entityTypeLimiter, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$this->applyPermissionJoin(function (QueryBuilder $joinQuery) {
|
||||
$joinQuery->selectRaw('max(view) as perms_fallback')
|
||||
->whereNull(['role_id', 'user_id']);
|
||||
}, 'p_f', $query, $queryTable, $entityTypeLimiter, $entityIdColumn, $entityTypeColumn);
|
||||
}
|
||||
|
||||
protected function getJoinCondition(string $queryTable, string $joinTableName, string $entityIdColumn, string $entityTypeColumn): callable
|
||||
{
|
||||
return function (JoinClause $join) use ($queryTable, $joinTableName, $entityIdColumn, $entityTypeColumn) {
|
||||
$join->on($queryTable . '.' . $entityIdColumn, '=', $joinTableName . '.entity_id');
|
||||
if ($entityTypeColumn) {
|
||||
$join->on($queryTable . '.' . $entityTypeColumn, '=', $joinTableName . '.entity_type');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the given page query to ensure draft items are not visible
|
||||
* unless created by the given user.
|
||||
*/
|
||||
public function restrictDraftsOnPageQuery(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $query) {
|
||||
$query->where('draft', '=', false)
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->where('draft', '=', true)
|
||||
->where('owned_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter items that have entities set as a polymorphic relation.
|
||||
* For simplicity, this will not return results attached to draft pages.
|
||||
* Draft pages should never really have related items though.
|
||||
*
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
public function restrictEntityRelationQuery($query, string $tableName, string $entityIdColumn, string $entityTypeColumn)
|
||||
{
|
||||
$query->leftJoinSub(function (QueryBuilder $query) {
|
||||
$query->select(['id as entity_id', DB::raw("'page' as entity_type"), 'owned_by', 'deleted_at', 'draft'])->from('pages');
|
||||
$tablesByType = ['page' => 'pages', 'book' => 'books', 'chapter' => 'chapters', 'bookshelf' => 'bookshelves'];
|
||||
foreach ($tablesByType as $type => $table) {
|
||||
$query->unionAll(function (QueryBuilder $query) use ($type, $table) {
|
||||
$query->select(['id as entity_id', DB::raw("'{$type}' as entity_type"), 'owned_by', 'deleted_at', DB::raw('0 as draft')])->from($table);
|
||||
});
|
||||
}
|
||||
}, 'entities', function (JoinClause $join) use ($tableName, $entityIdColumn, $entityTypeColumn) {
|
||||
$join->on($tableName . '.' . $entityIdColumn, '=', 'entities.entity_id')
|
||||
->on($tableName . '.' . $entityTypeColumn, '=', 'entities.entity_type');
|
||||
});
|
||||
|
||||
$this->applyPermissionsToQuery($query, $tableName, '', $entityIdColumn, $entityTypeColumn);
|
||||
// TODO - Test page draft access (Might allow drafts which should not be seen)
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add conditions to a query for a model that's a relation of a page, so only the model results
|
||||
* on visible pages are returned by the query.
|
||||
* Is effectively the same as "restrictEntityRelationQuery" but takes into account page drafts
|
||||
* while not expecting a polymorphic relation, Just a simpler one-page-to-many-relations set-up.
|
||||
*/
|
||||
public function restrictPageRelationQuery(Builder $query, string $tableName, string $pageIdColumn): Builder
|
||||
{
|
||||
$morphClass = (new Page())->getMorphClass();
|
||||
|
||||
$this->applyPermissionsToQuery($query, $tableName, $morphClass, $pageIdColumn, '');
|
||||
// TODO - Draft display
|
||||
// TODO - Likely need owned_by entity join workaround as used above
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user.
|
||||
*/
|
||||
protected function currentUser(): User
|
||||
{
|
||||
return user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles for the current logged-in user.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
protected function getCurrentUserRoleIds(): array
|
||||
{
|
||||
if (auth()->guest()) {
|
||||
return [Role::getSystemRole('public')->id];
|
||||
}
|
||||
|
||||
return $this->currentUser()->roles->pluck('id')->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the given action is a valid and expected entity action.
|
||||
* Throws an exception if invalid otherwise does nothing.
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
protected function ensureValidEntityAction(string $action): void
|
||||
{
|
||||
if (!in_array($action, EntityPermission::PERMISSIONS)) {
|
||||
throw new InvalidArgumentException('Action should be a simple entity permission action, not a role permission');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
|
||||
class PermissionFormData
|
||||
{
|
||||
protected Entity $entity;
|
||||
|
||||
public function __construct(Entity $entity)
|
||||
{
|
||||
$this->entity = $entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the permissions with assigned roles.
|
||||
*/
|
||||
public function permissionsWithRoles(): array
|
||||
{
|
||||
return $this->entity->permissions()
|
||||
->with('role')
|
||||
->whereNotNull('role_id')
|
||||
->get()
|
||||
->sortBy('role.display_name')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the permissions with assigned users.
|
||||
*/
|
||||
public function permissionsWithUsers(): array
|
||||
{
|
||||
return $this->entity->permissions()
|
||||
->with('user')
|
||||
->whereNotNull('user_id')
|
||||
->get()
|
||||
->sortBy('user.name')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles that don't yet have specific permissions for the
|
||||
* entity we're managing permissions for.
|
||||
*/
|
||||
public function rolesNotAssigned(): array
|
||||
{
|
||||
$assigned = $this->entity->permissions()->whereNotNull('role_id')->pluck('role_id');
|
||||
return Role::query()
|
||||
->where('system_name', '!=', 'admin')
|
||||
->whereNotIn('id', $assigned)
|
||||
->orderBy('display_name', 'asc')
|
||||
->get()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permission for the "Everyone Else" option.
|
||||
*/
|
||||
public function everyoneElseEntityPermission(): EntityPermission
|
||||
{
|
||||
/** @var ?EntityPermission $permission */
|
||||
$permission = $this->entity->permissions()
|
||||
->whereNull(['role_id', 'user_id'])
|
||||
->first();
|
||||
return $permission ?? (new EntityPermission());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the "Everyone else" option is inheriting default role system permissions.
|
||||
* Is determined by any system entity_permission existing for the current entity.
|
||||
*/
|
||||
public function everyoneElseInheriting(): bool
|
||||
{
|
||||
return !$this->entity->permissions()
|
||||
->whereNull(['role_id', 'user_id'])
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
719
app/Auth/Permissions/PermissionService.php
Normal file
719
app/Auth/Permissions/PermissionService.php
Normal file
@@ -0,0 +1,719 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Model;
|
||||
use BookStack\Traits\HasCreatorAndUpdater;
|
||||
use BookStack\Traits\HasOwner;
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||
use Throwable;
|
||||
|
||||
class PermissionService
|
||||
{
|
||||
/**
|
||||
* @var ?array
|
||||
*/
|
||||
protected $userRoles = null;
|
||||
|
||||
/**
|
||||
* @var ?User
|
||||
*/
|
||||
protected $currentUserModel = null;
|
||||
|
||||
/**
|
||||
* @var Connection
|
||||
*/
|
||||
protected $db;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $entityCache;
|
||||
|
||||
/**
|
||||
* PermissionService constructor.
|
||||
*/
|
||||
public function __construct(Connection $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the database connection.
|
||||
*/
|
||||
public function setConnection(Connection $connection)
|
||||
{
|
||||
$this->db = $connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the local entity cache and ensure it's empty.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
*/
|
||||
protected function readyEntityCache(array $entities = [])
|
||||
{
|
||||
$this->entityCache = [];
|
||||
|
||||
foreach ($entities as $entity) {
|
||||
$class = get_class($entity);
|
||||
if (!isset($this->entityCache[$class])) {
|
||||
$this->entityCache[$class] = collect();
|
||||
}
|
||||
$this->entityCache[$class]->put($entity->id, $entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a book via ID, Checks local cache.
|
||||
*/
|
||||
protected function getBook(int $bookId): ?Book
|
||||
{
|
||||
if (isset($this->entityCache[Book::class]) && $this->entityCache[Book::class]->has($bookId)) {
|
||||
return $this->entityCache[Book::class]->get($bookId);
|
||||
}
|
||||
|
||||
return Book::query()->withTrashed()->find($bookId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a chapter via ID, Checks local cache.
|
||||
*/
|
||||
protected function getChapter(int $chapterId): ?Chapter
|
||||
{
|
||||
if (isset($this->entityCache[Chapter::class]) && $this->entityCache[Chapter::class]->has($chapterId)) {
|
||||
return $this->entityCache[Chapter::class]->get($chapterId);
|
||||
}
|
||||
|
||||
return Chapter::query()
|
||||
->withTrashed()
|
||||
->find($chapterId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles for the current logged in user.
|
||||
*/
|
||||
protected function getCurrentUserRoles(): array
|
||||
{
|
||||
if (!is_null($this->userRoles)) {
|
||||
return $this->userRoles;
|
||||
}
|
||||
|
||||
if (auth()->guest()) {
|
||||
$this->userRoles = [Role::getSystemRole('public')->id];
|
||||
} else {
|
||||
$this->userRoles = $this->currentUser()->roles->pluck('id')->values()->all();
|
||||
}
|
||||
|
||||
return $this->userRoles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-generate all entity permission from scratch.
|
||||
*/
|
||||
public function buildJointPermissions()
|
||||
{
|
||||
JointPermission::query()->truncate();
|
||||
$this->readyEntityCache();
|
||||
|
||||
// Get all roles (Should be the most limited dimension)
|
||||
$roles = Role::query()->with('permissions')->get()->all();
|
||||
|
||||
// Chunk through all books
|
||||
$this->bookFetchQuery()->chunk(5, function (EloquentCollection $books) use ($roles) {
|
||||
$this->buildJointPermissionsForBooks($books, $roles);
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->withTrashed()->select(['id', 'restricted', 'owned_by'])
|
||||
->chunk(50, function (EloquentCollection $shelves) use ($roles) {
|
||||
$this->buildJointPermissionsForShelves($shelves, $roles);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a query for fetching a book with it's children.
|
||||
*/
|
||||
protected function bookFetchQuery(): Builder
|
||||
{
|
||||
return Book::query()->withTrashed()
|
||||
->select(['id', 'restricted', 'owned_by'])->with([
|
||||
'chapters' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id']);
|
||||
},
|
||||
'pages' => function ($query) {
|
||||
$query->withTrashed()->select(['id', 'restricted', 'owned_by', 'book_id', 'chapter_id']);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build joint permissions for the given shelf and role combinations.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
protected function buildJointPermissionsForShelves(EloquentCollection $shelves, array $roles, bool $deleteOld = false)
|
||||
{
|
||||
if ($deleteOld) {
|
||||
$this->deleteManyJointPermissionsForEntities($shelves->all());
|
||||
}
|
||||
$this->createManyJointPermissions($shelves->all(), $roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build joint permissions for the given book and role combinations.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false)
|
||||
{
|
||||
$entities = clone $books;
|
||||
|
||||
/** @var Book $book */
|
||||
foreach ($books->all() as $book) {
|
||||
foreach ($book->getRelation('chapters') as $chapter) {
|
||||
$entities->push($chapter);
|
||||
}
|
||||
foreach ($book->getRelation('pages') as $page) {
|
||||
$entities->push($page);
|
||||
}
|
||||
}
|
||||
|
||||
if ($deleteOld) {
|
||||
$this->deleteManyJointPermissionsForEntities($entities->all());
|
||||
}
|
||||
$this->createManyJointPermissions($entities->all(), $roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entity jointPermissions for a particular entity.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function buildJointPermissionsForEntity(Entity $entity)
|
||||
{
|
||||
$entities = [$entity];
|
||||
if ($entity instanceof Book) {
|
||||
$books = $this->bookFetchQuery()->where('id', '=', $entity->id)->get();
|
||||
$this->buildJointPermissionsForBooks($books, Role::query()->get()->all(), true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BookChild $entity */
|
||||
if ($entity->book) {
|
||||
$entities[] = $entity->book;
|
||||
}
|
||||
|
||||
if ($entity instanceof Page && $entity->chapter_id) {
|
||||
$entities[] = $entity->chapter;
|
||||
}
|
||||
|
||||
if ($entity instanceof Chapter) {
|
||||
foreach ($entity->pages as $page) {
|
||||
$entities[] = $page;
|
||||
}
|
||||
}
|
||||
|
||||
$this->buildJointPermissionsForEntities($entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entity jointPermissions for a collection of entities.
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function buildJointPermissionsForEntities(array $entities)
|
||||
{
|
||||
$roles = Role::query()->get()->values()->all();
|
||||
$this->deleteManyJointPermissionsForEntities($entities);
|
||||
$this->createManyJointPermissions($entities, $roles);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the entity jointPermissions for a particular role.
|
||||
*/
|
||||
public function buildJointPermissionForRole(Role $role)
|
||||
{
|
||||
$roles = [$role];
|
||||
$this->deleteManyJointPermissionsForRoles($roles);
|
||||
|
||||
// Chunk through all books
|
||||
$this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
|
||||
$this->buildJointPermissionsForBooks($books, $roles);
|
||||
});
|
||||
|
||||
// Chunk through all bookshelves
|
||||
Bookshelf::query()->select(['id', 'restricted', 'owned_by'])
|
||||
->chunk(50, function ($shelves) use ($roles) {
|
||||
$this->buildJointPermissionsForShelves($shelves, $roles);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the entity jointPermissions attached to a particular role.
|
||||
*/
|
||||
public function deleteJointPermissionsForRole(Role $role)
|
||||
{
|
||||
$this->deleteManyJointPermissionsForRoles([$role]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all of the entity jointPermissions for a list of entities.
|
||||
*
|
||||
* @param Role[] $roles
|
||||
*/
|
||||
protected function deleteManyJointPermissionsForRoles($roles)
|
||||
{
|
||||
$roleIds = array_map(function ($role) {
|
||||
return $role->id;
|
||||
}, $roles);
|
||||
JointPermission::query()->whereIn('role_id', $roleIds)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the entity jointPermissions for a particular entity.
|
||||
*
|
||||
* @param Entity $entity
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function deleteJointPermissionsForEntity(Entity $entity)
|
||||
{
|
||||
$this->deleteManyJointPermissionsForEntities([$entity]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all of the entity jointPermissions for a list of entities.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
protected function deleteManyJointPermissionsForEntities(array $entities)
|
||||
{
|
||||
if (count($entities) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->transaction(function () use ($entities) {
|
||||
foreach (array_chunk($entities, 1000) as $entityChunk) {
|
||||
$query = $this->db->table('joint_permissions');
|
||||
foreach ($entityChunk as $entity) {
|
||||
$query->orWhere(function (QueryBuilder $query) use ($entity) {
|
||||
$query->where('entity_id', '=', $entity->id)
|
||||
->where('entity_type', '=', $entity->getMorphClass());
|
||||
});
|
||||
}
|
||||
$query->delete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create & Save entity jointPermissions for many entities and roles.
|
||||
*
|
||||
* @param Entity[] $entities
|
||||
* @param Role[] $roles
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
protected function createManyJointPermissions(array $entities, array $roles)
|
||||
{
|
||||
$this->readyEntityCache($entities);
|
||||
$jointPermissions = [];
|
||||
|
||||
// Fetch Entity Permissions and create a mapping of entity restricted statuses
|
||||
$entityRestrictedMap = [];
|
||||
$permissionFetch = EntityPermission::query();
|
||||
foreach ($entities as $entity) {
|
||||
$entityRestrictedMap[$entity->getMorphClass() . ':' . $entity->id] = boolval($entity->getRawAttribute('restricted'));
|
||||
$permissionFetch->orWhere(function ($query) use ($entity) {
|
||||
$query->where('restrictable_id', '=', $entity->id)->where('restrictable_type', '=', $entity->getMorphClass());
|
||||
});
|
||||
}
|
||||
$permissions = $permissionFetch->get();
|
||||
|
||||
// Create a mapping of explicit entity permissions
|
||||
$permissionMap = [];
|
||||
foreach ($permissions as $permission) {
|
||||
$key = $permission->restrictable_type . ':' . $permission->restrictable_id . ':' . $permission->role_id . ':' . $permission->action;
|
||||
$isRestricted = $entityRestrictedMap[$permission->restrictable_type . ':' . $permission->restrictable_id];
|
||||
$permissionMap[$key] = $isRestricted;
|
||||
}
|
||||
|
||||
// Create a mapping of role permissions
|
||||
$rolePermissionMap = [];
|
||||
foreach ($roles as $role) {
|
||||
foreach ($role->permissions as $permission) {
|
||||
$rolePermissionMap[$role->getRawAttribute('id') . ':' . $permission->getRawAttribute('name')] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Create Joint Permission Data
|
||||
foreach ($entities as $entity) {
|
||||
foreach ($roles as $role) {
|
||||
foreach ($this->getActions($entity) as $action) {
|
||||
$jointPermissions[] = $this->createJointPermissionData($entity, $role, $action, $permissionMap, $rolePermissionMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->transaction(function () use ($jointPermissions) {
|
||||
foreach (array_chunk($jointPermissions, 1000) as $jointPermissionChunk) {
|
||||
$this->db->table('joint_permissions')->insert($jointPermissionChunk);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the actions related to an entity.
|
||||
*/
|
||||
protected function getActions(Entity $entity): array
|
||||
{
|
||||
$baseActions = ['view', 'update', 'delete'];
|
||||
if ($entity instanceof Chapter || $entity instanceof Book) {
|
||||
$baseActions[] = 'page-create';
|
||||
}
|
||||
if ($entity instanceof Book) {
|
||||
$baseActions[] = 'chapter-create';
|
||||
}
|
||||
|
||||
return $baseActions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create entity permission data for an entity and role
|
||||
* for a particular action.
|
||||
*/
|
||||
protected function createJointPermissionData(Entity $entity, Role $role, string $action, array $permissionMap, array $rolePermissionMap): array
|
||||
{
|
||||
$permissionPrefix = (strpos($action, '-') === false ? ($entity->getType() . '-') : '') . $action;
|
||||
$roleHasPermission = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-all']);
|
||||
$roleHasPermissionOwn = isset($rolePermissionMap[$role->getRawAttribute('id') . ':' . $permissionPrefix . '-own']);
|
||||
$explodedAction = explode('-', $action);
|
||||
$restrictionAction = end($explodedAction);
|
||||
|
||||
if ($role->system_name === 'admin') {
|
||||
return $this->createJointPermissionDataArray($entity, $role, $action, true, true);
|
||||
}
|
||||
|
||||
if ($entity->restricted) {
|
||||
$hasAccess = $this->mapHasActiveRestriction($permissionMap, $entity, $role, $restrictionAction);
|
||||
|
||||
return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
|
||||
}
|
||||
|
||||
if ($entity instanceof Book || $entity instanceof Bookshelf) {
|
||||
return $this->createJointPermissionDataArray($entity, $role, $action, $roleHasPermission, $roleHasPermissionOwn);
|
||||
}
|
||||
|
||||
// For chapters and pages, Check if explicit permissions are set on the Book.
|
||||
$book = $this->getBook($entity->book_id);
|
||||
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $book, $role, $restrictionAction);
|
||||
$hasPermissiveAccessToParents = !$book->restricted;
|
||||
|
||||
// For pages with a chapter, Check if explicit permissions are set on the Chapter
|
||||
if ($entity instanceof Page && intval($entity->chapter_id) !== 0) {
|
||||
$chapter = $this->getChapter($entity->chapter_id);
|
||||
$hasPermissiveAccessToParents = $hasPermissiveAccessToParents && !$chapter->restricted;
|
||||
if ($chapter->restricted) {
|
||||
$hasExplicitAccessToParents = $this->mapHasActiveRestriction($permissionMap, $chapter, $role, $restrictionAction);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->createJointPermissionDataArray(
|
||||
$entity,
|
||||
$role,
|
||||
$action,
|
||||
($hasExplicitAccessToParents || ($roleHasPermission && $hasPermissiveAccessToParents)),
|
||||
($hasExplicitAccessToParents || ($roleHasPermissionOwn && $hasPermissiveAccessToParents))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for an active restriction in an entity map.
|
||||
*/
|
||||
protected function mapHasActiveRestriction(array $entityMap, Entity $entity, Role $role, string $action): bool
|
||||
{
|
||||
$key = $entity->getMorphClass() . ':' . $entity->getRawAttribute('id') . ':' . $role->getRawAttribute('id') . ':' . $action;
|
||||
|
||||
return $entityMap[$key] ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an array of data with the information of an entity jointPermissions.
|
||||
* Used to build data for bulk insertion.
|
||||
*/
|
||||
protected function createJointPermissionDataArray(Entity $entity, Role $role, string $action, bool $permissionAll, bool $permissionOwn): array
|
||||
{
|
||||
return [
|
||||
'role_id' => $role->getRawAttribute('id'),
|
||||
'entity_id' => $entity->getRawAttribute('id'),
|
||||
'entity_type' => $entity->getMorphClass(),
|
||||
'action' => $action,
|
||||
'has_permission' => $permissionAll,
|
||||
'has_permission_own' => $permissionOwn,
|
||||
'owned_by' => $entity->getRawAttribute('owned_by'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an entity has a restriction set upon it.
|
||||
*
|
||||
* @param HasCreatorAndUpdater|HasOwner $ownable
|
||||
*/
|
||||
public function checkOwnableUserAccess(Model $ownable, string $permission): bool
|
||||
{
|
||||
$explodedPermission = explode('-', $permission);
|
||||
|
||||
$baseQuery = $ownable->newQuery()->where('id', '=', $ownable->id);
|
||||
$action = end($explodedPermission);
|
||||
$user = $this->currentUser();
|
||||
|
||||
$nonJointPermissions = ['restrictions', 'image', 'attachment', 'comment'];
|
||||
|
||||
// Handle non entity specific jointPermissions
|
||||
if (in_array($explodedPermission[0], $nonJointPermissions)) {
|
||||
$allPermission = $user && $user->can($permission . '-all');
|
||||
$ownPermission = $user && $user->can($permission . '-own');
|
||||
$ownerField = ($ownable instanceof Entity) ? 'owned_by' : 'created_by';
|
||||
$isOwner = $user && $user->id === $ownable->$ownerField;
|
||||
|
||||
return $allPermission || ($isOwner && $ownPermission);
|
||||
}
|
||||
|
||||
// Handle abnormal create jointPermissions
|
||||
if ($action === 'create') {
|
||||
$action = $permission;
|
||||
}
|
||||
|
||||
$hasAccess = $this->entityRestrictionQuery($baseQuery, $action)->count() > 0;
|
||||
$this->clean();
|
||||
|
||||
return $hasAccess;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user has the given permission for any items in the system.
|
||||
* Can be passed an entity instance to filter on a specific type.
|
||||
*/
|
||||
public function checkUserHasPermissionOnAnything(string $permission, ?string $entityClass = null): bool
|
||||
{
|
||||
$userRoleIds = $this->currentUser()->roles()->select('id')->pluck('id')->toArray();
|
||||
$userId = $this->currentUser()->id;
|
||||
|
||||
$permissionQuery = JointPermission::query()
|
||||
->where('action', '=', $permission)
|
||||
->whereIn('role_id', $userRoleIds)
|
||||
->where(function (Builder $query) use ($userId) {
|
||||
$this->addJointHasPermissionCheck($query, $userId);
|
||||
});
|
||||
|
||||
if (!is_null($entityClass)) {
|
||||
$entityInstance = app($entityClass);
|
||||
$permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass());
|
||||
}
|
||||
|
||||
$hasPermission = $permissionQuery->count() > 0;
|
||||
$this->clean();
|
||||
|
||||
return $hasPermission;
|
||||
}
|
||||
|
||||
/**
|
||||
* The general query filter to remove all entities
|
||||
* that the current user does not have access to.
|
||||
*/
|
||||
protected function entityRestrictionQuery(Builder $query, string $action): Builder
|
||||
{
|
||||
$q = $query->where(function ($parentQuery) use ($action) {
|
||||
$parentQuery->whereHas('jointPermissions', function ($permissionQuery) use ($action) {
|
||||
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
|
||||
->where('action', '=', $action)
|
||||
->where(function (Builder $query) {
|
||||
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$this->clean();
|
||||
|
||||
return $q;
|
||||
}
|
||||
|
||||
/**
|
||||
* Limited the given entity query so that the query will only
|
||||
* return items that the user has permission for the given ability.
|
||||
*/
|
||||
public function restrictEntityQuery(Builder $query, string $ability = 'view'): Builder
|
||||
{
|
||||
$this->clean();
|
||||
|
||||
return $query->where(function (Builder $parentQuery) use ($ability) {
|
||||
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) use ($ability) {
|
||||
$permissionQuery->whereIn('role_id', $this->getCurrentUserRoles())
|
||||
->where('action', '=', $ability)
|
||||
->where(function (Builder $query) {
|
||||
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend the given page query to ensure draft items are not visible
|
||||
* unless created by the given user.
|
||||
*/
|
||||
public function enforceDraftVisibilityOnQuery(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $query) {
|
||||
$query->where('draft', '=', false)
|
||||
->orWhere(function (Builder $query) {
|
||||
$query->where('draft', '=', true)
|
||||
->where('owned_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add restrictions for a generic entity.
|
||||
*/
|
||||
public function enforceEntityRestrictions(Entity $entity, Builder $query, string $action = 'view'): Builder
|
||||
{
|
||||
if ($entity instanceof Page) {
|
||||
// Prevent drafts being visible to others.
|
||||
$this->enforceDraftVisibilityOnQuery($query);
|
||||
}
|
||||
|
||||
return $this->entityRestrictionQuery($query, $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter items that have entities set as a polymorphic relation.
|
||||
* For simplicity, this will not return results attached to draft pages.
|
||||
* Draft pages should never really have related items though.
|
||||
*
|
||||
* @param Builder|QueryBuilder $query
|
||||
*/
|
||||
public function filterRestrictedEntityRelations($query, string $tableName, string $entityIdColumn, string $entityTypeColumn, string $action = 'view')
|
||||
{
|
||||
$tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
|
||||
$pageMorphClass = (new Page())->getMorphClass();
|
||||
|
||||
$q = $query->whereExists(function ($permissionQuery) use (&$tableDetails, $action) {
|
||||
/** @var Builder $permissionQuery */
|
||||
$permissionQuery->select(['role_id'])->from('joint_permissions')
|
||||
->whereColumn('joint_permissions.entity_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
|
||||
->whereColumn('joint_permissions.entity_type', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'])
|
||||
->where('joint_permissions.action', '=', $action)
|
||||
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
|
||||
->where(function (QueryBuilder $query) {
|
||||
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
|
||||
});
|
||||
})->where(function ($query) use ($tableDetails, $pageMorphClass) {
|
||||
/** @var Builder $query */
|
||||
$query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass)
|
||||
->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) {
|
||||
$query->select('id')->from('pages')
|
||||
->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn'])
|
||||
->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass)
|
||||
->where('pages.draft', '=', false);
|
||||
});
|
||||
});
|
||||
|
||||
$this->clean();
|
||||
|
||||
return $q;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add conditions to a query to filter the selection to related entities
|
||||
* where view permissions are granted.
|
||||
*/
|
||||
public function filterRelatedEntity(string $entityClass, Builder $query, string $tableName, string $entityIdColumn): Builder
|
||||
{
|
||||
$fullEntityIdColumn = $tableName . '.' . $entityIdColumn;
|
||||
$instance = new $entityClass();
|
||||
$morphClass = $instance->getMorphClass();
|
||||
|
||||
$existsQuery = function ($permissionQuery) use ($fullEntityIdColumn, $morphClass) {
|
||||
/** @var Builder $permissionQuery */
|
||||
$permissionQuery->select('joint_permissions.role_id')->from('joint_permissions')
|
||||
->whereColumn('joint_permissions.entity_id', '=', $fullEntityIdColumn)
|
||||
->where('joint_permissions.entity_type', '=', $morphClass)
|
||||
->where('joint_permissions.action', '=', 'view')
|
||||
->whereIn('joint_permissions.role_id', $this->getCurrentUserRoles())
|
||||
->where(function (QueryBuilder $query) {
|
||||
$this->addJointHasPermissionCheck($query, $this->currentUser()->id);
|
||||
});
|
||||
};
|
||||
|
||||
$q = $query->where(function ($query) use ($existsQuery, $fullEntityIdColumn) {
|
||||
$query->whereExists($existsQuery)
|
||||
->orWhere($fullEntityIdColumn, '=', 0);
|
||||
});
|
||||
|
||||
if ($instance instanceof Page) {
|
||||
// Prevent visibility of non-owned draft pages
|
||||
$q->whereExists(function (QueryBuilder $query) use ($fullEntityIdColumn) {
|
||||
$query->select('id')->from('pages')
|
||||
->whereColumn('pages.id', '=', $fullEntityIdColumn)
|
||||
->where(function (QueryBuilder $query) {
|
||||
$query->where('pages.draft', '=', false)
|
||||
->orWhere('pages.owned_by', '=', $this->currentUser()->id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
$this->clean();
|
||||
|
||||
return $q;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the query for checking the given user id has permission
|
||||
* within the join_permissions table.
|
||||
*
|
||||
* @param QueryBuilder|Builder $query
|
||||
*/
|
||||
protected function addJointHasPermissionCheck($query, int $userIdToCheck)
|
||||
{
|
||||
$query->where('joint_permissions.has_permission', '=', true)->orWhere(function ($query) use ($userIdToCheck) {
|
||||
$query->where('joint_permissions.has_permission_own', '=', true)
|
||||
->where('joint_permissions.owned_by', '=', $userIdToCheck);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user.
|
||||
*/
|
||||
private function currentUser(): User
|
||||
{
|
||||
if (is_null($this->currentUserModel)) {
|
||||
$this->currentUserModel = user();
|
||||
}
|
||||
|
||||
return $this->currentUserModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean the cached user elements.
|
||||
*/
|
||||
private function clean(): void
|
||||
{
|
||||
$this->currentUserModel = null;
|
||||
$this->userRoles = null;
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,20 @@ use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class PermissionsRepo
|
||||
{
|
||||
protected CollapsedPermissionBuilder $permissionBuilder;
|
||||
protected array $systemRoles = ['admin', 'public'];
|
||||
protected $permission;
|
||||
protected $role;
|
||||
protected $permissionService;
|
||||
|
||||
protected $systemRoles = ['admin', 'public'];
|
||||
|
||||
/**
|
||||
* PermissionsRepo constructor.
|
||||
*/
|
||||
public function __construct(CollapsedPermissionBuilder $permissionBuilder)
|
||||
public function __construct(RolePermission $permission, Role $role, PermissionService $permissionService)
|
||||
{
|
||||
$this->permissionBuilder = $permissionBuilder;
|
||||
$this->permission = $permission;
|
||||
$this->role = $role;
|
||||
$this->permissionService = $permissionService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,7 +32,7 @@ class PermissionsRepo
|
||||
*/
|
||||
public function getAllRoles(): Collection
|
||||
{
|
||||
return Role::query()->get();
|
||||
return $this->role->all();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,7 +40,7 @@ class PermissionsRepo
|
||||
*/
|
||||
public function getAllRolesExcept(Role $role): Collection
|
||||
{
|
||||
return Role::query()->where('id', '!=', $role->id)->get();
|
||||
return $this->role->where('id', '!=', $role->id)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,7 +48,7 @@ class PermissionsRepo
|
||||
*/
|
||||
public function getRoleById($id): Role
|
||||
{
|
||||
return Role::query()->findOrFail($id);
|
||||
return $this->role->newQuery()->findOrFail($id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,13 +56,13 @@ class PermissionsRepo
|
||||
*/
|
||||
public function saveNewRole(array $roleData): Role
|
||||
{
|
||||
$role = new Role($roleData);
|
||||
$role = $this->role->newInstance($roleData);
|
||||
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
|
||||
$role->save();
|
||||
|
||||
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
||||
$this->assignRolePermissions($role, $permissions);
|
||||
|
||||
$this->permissionService->buildJointPermissionForRole($role);
|
||||
Activity::add(ActivityType::ROLE_CREATE, $role);
|
||||
|
||||
return $role;
|
||||
@@ -69,7 +74,8 @@ class PermissionsRepo
|
||||
*/
|
||||
public function updateRole($roleId, array $roleData)
|
||||
{
|
||||
$role = $this->getRoleById($roleId);
|
||||
/** @var Role $role */
|
||||
$role = $this->role->newQuery()->findOrFail($roleId);
|
||||
|
||||
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
|
||||
if ($role->system_name === 'admin') {
|
||||
@@ -87,12 +93,12 @@ class PermissionsRepo
|
||||
$role->fill($roleData);
|
||||
$role->mfa_enforced = ($roleData['mfa_enforced'] ?? 'false') === 'true';
|
||||
$role->save();
|
||||
|
||||
$this->permissionService->buildJointPermissionForRole($role);
|
||||
Activity::add(ActivityType::ROLE_UPDATE, $role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a list of permission names to a role.
|
||||
* Assign an list of permission names to an role.
|
||||
*/
|
||||
protected function assignRolePermissions(Role $role, array $permissionNameArray = [])
|
||||
{
|
||||
@@ -100,7 +106,7 @@ class PermissionsRepo
|
||||
$permissionNameArray = array_values($permissionNameArray);
|
||||
|
||||
if ($permissionNameArray) {
|
||||
$permissions = RolePermission::query()
|
||||
$permissions = $this->permission->newQuery()
|
||||
->whereIn('name', $permissionNameArray)
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
@@ -120,7 +126,8 @@ class PermissionsRepo
|
||||
*/
|
||||
public function deleteRole($roleId, $migrateRoleId)
|
||||
{
|
||||
$role = $this->getRoleById($roleId);
|
||||
/** @var Role $role */
|
||||
$role = $this->role->newQuery()->findOrFail($roleId);
|
||||
|
||||
// Prevent deleting admin role or default registration role.
|
||||
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
|
||||
@@ -130,15 +137,14 @@ class PermissionsRepo
|
||||
}
|
||||
|
||||
if ($migrateRoleId) {
|
||||
$newRole = Role::query()->find($migrateRoleId);
|
||||
$newRole = $this->role->newQuery()->find($migrateRoleId);
|
||||
if ($newRole) {
|
||||
$users = $role->users()->pluck('id')->toArray();
|
||||
$newRole->users()->sync($users);
|
||||
}
|
||||
}
|
||||
|
||||
$role->entityPermissions()->delete();
|
||||
$role->collapsedPermissions()->delete();
|
||||
$this->permissionService->deleteJointPermissionsForRole($role);
|
||||
Activity::add(ActivityType::ROLE_DELETE, $role);
|
||||
$role->delete();
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Permissions;
|
||||
|
||||
class SimpleEntityData
|
||||
{
|
||||
public int $id;
|
||||
public string $type;
|
||||
public ?int $book_id;
|
||||
public ?int $chapter_id;
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace BookStack\Auth\Queries;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
@@ -12,23 +11,23 @@ use Illuminate\Pagination\LengthAwarePaginator;
|
||||
* user is assumed to be trusted. (Admin users).
|
||||
* Email search can be abused to extract email addresses.
|
||||
*/
|
||||
class UsersAllPaginatedAndSorted
|
||||
class AllUsersPaginatedAndSorted
|
||||
{
|
||||
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
|
||||
/**
|
||||
* @param array{sort: string, order: string, search: string} $sortData
|
||||
*/
|
||||
public function run(int $count, array $sortData): LengthAwarePaginator
|
||||
{
|
||||
$sort = $listOptions->getSort();
|
||||
if ($sort === 'created_at') {
|
||||
$sort = 'users.created_at';
|
||||
}
|
||||
$sort = $sortData['sort'];
|
||||
|
||||
$query = User::query()->select(['*'])
|
||||
->scopes(['withLastActivityAt'])
|
||||
->with(['roles', 'avatar'])
|
||||
->withCount('mfaValues')
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
->orderBy($sort, $sortData['order']);
|
||||
|
||||
if ($listOptions->getSearch()) {
|
||||
$term = '%' . $listOptions->getSearch() . '%';
|
||||
if ($sortData['search']) {
|
||||
$term = '%' . $sortData['search'] . '%';
|
||||
$query->where(function ($query) use ($term) {
|
||||
$query->where('name', 'like', $term)
|
||||
->orWhere('email', 'like', $term);
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Auth\Queries;
|
||||
|
||||
use BookStack\Auth\Role;
|
||||
use BookStack\Util\SimpleListOptions;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* Get all the roles in the system in a paginated format.
|
||||
*/
|
||||
class RolesAllPaginatedAndSorted
|
||||
{
|
||||
public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
|
||||
{
|
||||
$sort = $listOptions->getSort();
|
||||
if ($sort === 'created_at') {
|
||||
$sort = 'users.created_at';
|
||||
}
|
||||
|
||||
$query = Role::query()->select(['*'])
|
||||
->withCount(['users', 'permissions'])
|
||||
->orderBy($sort, $listOptions->getOrder());
|
||||
|
||||
if ($listOptions->getSearch()) {
|
||||
$term = '%' . $listOptions->getSearch() . '%';
|
||||
$query->where(function ($query) use ($term) {
|
||||
$query->where('display_name', 'like', $term)
|
||||
->orWhere('description', 'like', $term);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->paginate($count);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
namespace BookStack\Auth;
|
||||
|
||||
use BookStack\Auth\Permissions\CollapsedPermission;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Auth\Permissions\RolePermission;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Model;
|
||||
@@ -39,6 +38,14 @@ class Role extends Model implements Loggable
|
||||
return $this->belongsToMany(User::class)->orderBy('name', 'asc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all related JointPermissions.
|
||||
*/
|
||||
public function jointPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(JointPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* The RolePermissions that belong to the role.
|
||||
*/
|
||||
@@ -47,22 +54,6 @@ class Role extends Model implements Loggable
|
||||
return $this->belongsToMany(RolePermission::class, 'permission_role', 'role_id', 'permission_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permissions assigned to this role.
|
||||
*/
|
||||
public function entityPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(EntityPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all related entity collapsed permissions.
|
||||
*/
|
||||
public function collapsedPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CollapsedPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this role has a permission.
|
||||
*/
|
||||
@@ -110,6 +101,25 @@ class Role extends Model implements Loggable
|
||||
return static::query()->where('system_name', '=', $systemName)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all visible roles.
|
||||
*/
|
||||
public static function visible(): Collection
|
||||
{
|
||||
return static::query()->where('hidden', '=', false)->orderBy('name')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the roles that can be restricted.
|
||||
*/
|
||||
public static function restrictable(): Collection
|
||||
{
|
||||
return static::query()
|
||||
->where('system_name', '!=', 'admin')
|
||||
->orderBy('display_name', 'asc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
|
||||
@@ -5,8 +5,6 @@ namespace BookStack\Auth;
|
||||
use BookStack\Actions\Favourite;
|
||||
use BookStack\Api\ApiToken;
|
||||
use BookStack\Auth\Access\Mfa\MfaValue;
|
||||
use BookStack\Auth\Permissions\CollapsedPermission;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Interfaces\Sluggable;
|
||||
@@ -82,11 +80,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
*/
|
||||
protected ?Collection $permissions;
|
||||
|
||||
/**
|
||||
* This holds the user's avatar URL when loaded to prevent re-calculating within the same request.
|
||||
*/
|
||||
protected string $avatarUrl = '';
|
||||
|
||||
/**
|
||||
* This holds the default user when loaded.
|
||||
*
|
||||
@@ -170,7 +163,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all permissions belonging to the current user.
|
||||
* Get all permissions belonging to a the current user.
|
||||
*/
|
||||
protected function permissions(): Collection
|
||||
{
|
||||
@@ -240,18 +233,12 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (!empty($this->avatarUrl)) {
|
||||
return $this->avatarUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
$avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default;
|
||||
} catch (Exception $err) {
|
||||
$avatar = $default;
|
||||
}
|
||||
|
||||
$this->avatarUrl = $avatar;
|
||||
|
||||
return $avatar;
|
||||
}
|
||||
|
||||
@@ -300,22 +287,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
|
||||
}, 'activities', 'users.id', '=', 'activities.user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity permissions assigned to this specific user.
|
||||
*/
|
||||
public function entityPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(EntityPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all related entity collapsed permissions.
|
||||
*/
|
||||
public function collapsedPermissions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CollapsedPermission::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the url for editing this user.
|
||||
*/
|
||||
|
||||
@@ -10,7 +10,6 @@ use BookStack\Exceptions\UserUpdateException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\Uploads\UserAvatars;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -62,7 +61,7 @@ class UserRepo
|
||||
$user = new User();
|
||||
$user->name = $data['name'];
|
||||
$user->email = $data['email'];
|
||||
$user->password = Hash::make(empty($data['password']) ? Str::random(32) : $data['password']);
|
||||
$user->password = bcrypt(empty($data['password']) ? Str::random(32) : $data['password']);
|
||||
$user->email_confirmed = $emailConfirmed;
|
||||
$user->external_auth_id = $data['external_auth_id'] ?? '';
|
||||
|
||||
@@ -127,7 +126,7 @@ class UserRepo
|
||||
}
|
||||
|
||||
if (!empty($data['password'])) {
|
||||
$user->password = Hash::make($data['password']);
|
||||
$user->password = bcrypt($data['password']);
|
||||
}
|
||||
|
||||
if (!empty($data['language'])) {
|
||||
@@ -153,16 +152,11 @@ class UserRepo
|
||||
$user->apiTokens()->delete();
|
||||
$user->favourites()->delete();
|
||||
$user->mfaValues()->delete();
|
||||
$user->collapsedPermissions()->delete();
|
||||
$user->entityPermissions()->delete();
|
||||
$user->delete();
|
||||
|
||||
// Delete user profile images
|
||||
$this->userAvatar->destroyAllForUser($user);
|
||||
|
||||
// Delete related activities
|
||||
setting()->deleteUserSettings($user->id);
|
||||
|
||||
if (!empty($newOwnerId)) {
|
||||
$newOwner = User::query()->find($newOwnerId);
|
||||
if (!is_null($newOwner)) {
|
||||
|
||||
@@ -22,7 +22,7 @@ return [
|
||||
// The number of revisions to keep in the database.
|
||||
// Once this limit is reached older revisions will be deleted.
|
||||
// If set to false then a limit will not be enforced.
|
||||
'revision_limit' => env('REVISION_LIMIT', 100),
|
||||
'revision_limit' => env('REVISION_LIMIT', 50),
|
||||
|
||||
// The number of days that content will remain in the recycle bin before
|
||||
// being considered for auto-removal. It is not a guarantee that content will
|
||||
@@ -57,17 +57,6 @@ return [
|
||||
// Space separated if multiple. BookStack host domain is auto-inferred.
|
||||
'iframe_hosts' => env('ALLOWED_IFRAME_HOSTS', null),
|
||||
|
||||
// A list of sources/hostnames that can be loaded within iframes within BookStack.
|
||||
// Space separated if multiple. BookStack host domain is auto-inferred.
|
||||
// Can be set to a lone "*" to allow all sources for iframe content (Not advised).
|
||||
// Defaults to a set of common services.
|
||||
// Current host and source for the "DRAWIO" setting will be auto-appended to the sources configured.
|
||||
'iframe_sources' => env('ALLOWED_IFRAME_SOURCES', 'https://*.draw.io https://*.youtube.com https://*.youtube-nocookie.com https://*.vimeo.com'),
|
||||
|
||||
// Alter the precision of IP addresses stored by BookStack.
|
||||
// Integer value between 0 (IP hidden) to 4 (Full IP usage)
|
||||
'ip_address_precision' => env('IP_ADDRESS_PRECISION', 4),
|
||||
|
||||
// Application timezone for back-end date functions.
|
||||
'timezone' => env('APP_TIMEZONE', 'UTC'),
|
||||
|
||||
@@ -75,7 +64,7 @@ return [
|
||||
'locale' => env('APP_LANG', 'en'),
|
||||
|
||||
// Locales available
|
||||
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'el', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ka', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
|
||||
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'et', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW'],
|
||||
|
||||
// Application Fallback Locale
|
||||
'fallback_locale' => 'en',
|
||||
@@ -114,8 +103,6 @@ return [
|
||||
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
|
||||
Illuminate\Hashing\HashServiceProvider::class,
|
||||
Illuminate\Mail\MailServiceProvider::class,
|
||||
Illuminate\Notifications\NotificationServiceProvider::class,
|
||||
Illuminate\Pagination\PaginationServiceProvider::class,
|
||||
Illuminate\Pipeline\PipelineServiceProvider::class,
|
||||
Illuminate\Queue\QueueServiceProvider::class,
|
||||
Illuminate\Redis\RedisServiceProvider::class,
|
||||
@@ -123,22 +110,27 @@ return [
|
||||
Illuminate\Session\SessionServiceProvider::class,
|
||||
Illuminate\Validation\ValidationServiceProvider::class,
|
||||
Illuminate\View\ViewServiceProvider::class,
|
||||
Illuminate\Notifications\NotificationServiceProvider::class,
|
||||
SocialiteProviders\Manager\ServiceProvider::class,
|
||||
|
||||
// Third party service providers
|
||||
Intervention\Image\ImageServiceProvider::class,
|
||||
Barryvdh\DomPDF\ServiceProvider::class,
|
||||
Barryvdh\Snappy\ServiceProvider::class,
|
||||
Intervention\Image\ImageServiceProvider::class,
|
||||
SocialiteProviders\Manager\ServiceProvider::class,
|
||||
|
||||
// BookStack replacement service providers (Extends Laravel)
|
||||
BookStack\Providers\PaginationServiceProvider::class,
|
||||
BookStack\Providers\TranslationServiceProvider::class,
|
||||
|
||||
// BookStack custom service providers
|
||||
BookStack\Providers\ThemeServiceProvider::class,
|
||||
BookStack\Providers\AppServiceProvider::class,
|
||||
BookStack\Providers\AuthServiceProvider::class,
|
||||
BookStack\Providers\AppServiceProvider::class,
|
||||
BookStack\Providers\BroadcastServiceProvider::class,
|
||||
BookStack\Providers\EventServiceProvider::class,
|
||||
BookStack\Providers\RouteServiceProvider::class,
|
||||
BookStack\Providers\TranslationServiceProvider::class,
|
||||
BookStack\Providers\ValidationRuleServiceProvider::class,
|
||||
BookStack\Providers\ViewTweaksServiceProvider::class,
|
||||
BookStack\Providers\CustomFacadeProvider::class,
|
||||
BookStack\Providers\CustomValidationServiceProvider::class,
|
||||
],
|
||||
|
||||
/*
|
||||
@@ -198,9 +190,12 @@ return [
|
||||
|
||||
// Third Party
|
||||
'ImageTool' => Intervention\Image\Facades\Image::class,
|
||||
'DomPDF' => Barryvdh\DomPDF\Facade::class,
|
||||
'SnappyPDF' => Barryvdh\Snappy\Facades\SnappyPdf::class,
|
||||
|
||||
// Custom BookStack
|
||||
'Activity' => BookStack\Facades\Activity::class,
|
||||
'Permissions' => BookStack\Facades\Permissions::class,
|
||||
'Theme' => BookStack\Facades\Theme::class,
|
||||
],
|
||||
|
||||
|
||||
@@ -13,10 +13,6 @@ return [
|
||||
// Options: standard, ldap, saml2, oidc
|
||||
'method' => env('AUTH_METHOD', 'standard'),
|
||||
|
||||
// Automatically initiate login via external auth system if it's the sole auth method.
|
||||
// Works with saml2 or oidc auth methods.
|
||||
'auto_initiate' => env('AUTH_AUTO_INITIATE', false),
|
||||
|
||||
// Authentication Defaults
|
||||
// This option controls the default authentication "guard" and password
|
||||
// reset options for your application.
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* Configuration should be altered via the `.env` file or environment variables.
|
||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||
*/
|
||||
|
||||
$dompdfPaperSizeMap = [
|
||||
'a4' => 'a4',
|
||||
'letter' => 'letter',
|
||||
@@ -16,8 +15,8 @@ $dompdfPaperSizeMap = [
|
||||
return [
|
||||
|
||||
'show_warnings' => false, // Throw an Exception on warnings from dompdf
|
||||
|
||||
'options' => [
|
||||
'orientation' => 'portrait',
|
||||
'defines' => [
|
||||
/**
|
||||
* The location of the DOMPDF font directory.
|
||||
*
|
||||
@@ -78,25 +77,15 @@ return [
|
||||
'chroot' => realpath(public_path()),
|
||||
|
||||
/**
|
||||
* Protocol whitelist.
|
||||
* Whether to use Unicode fonts or not.
|
||||
*
|
||||
* Protocols and PHP wrappers allowed in URIs, and the validation rules
|
||||
* that determine if a resouce may be loaded. Full support is not guaranteed
|
||||
* for the protocols/wrappers specified
|
||||
* by this array.
|
||||
* When set to true the PDF backend must be set to "CPDF" and fonts must be
|
||||
* loaded via load_font.php.
|
||||
*
|
||||
* @var array
|
||||
* When enabled, dompdf can support all Unicode glyphs. Any glyphs used in a
|
||||
* document must be present in your fonts, however.
|
||||
*/
|
||||
'allowed_protocols' => [
|
||||
'file://' => ['rules' => []],
|
||||
'http://' => ['rules' => []],
|
||||
'https://' => ['rules' => []],
|
||||
],
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
'log_output_file' => null,
|
||||
'unicode_enabled' => true,
|
||||
|
||||
/**
|
||||
* Whether to enable font subsetting or not.
|
||||
@@ -167,15 +156,6 @@ return [
|
||||
*/
|
||||
'default_paper_size' => $dompdfPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'a4',
|
||||
|
||||
/**
|
||||
* The default paper orientation.
|
||||
*
|
||||
* The orientation of the page (portrait or landscape).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
'default_paper_orientation' => 'portrait',
|
||||
|
||||
/**
|
||||
* The default font family.
|
||||
*
|
||||
@@ -278,13 +258,10 @@ return [
|
||||
'enable_css_float' => true,
|
||||
|
||||
/**
|
||||
* Use the HTML5 Lib parser.
|
||||
*
|
||||
* @deprecated This feature is now always on in dompdf 2.x
|
||||
*
|
||||
* @var bool
|
||||
* Use the more-than-experimental HTML5 Lib parser.
|
||||
*/
|
||||
'enable_html5_parser' => true,
|
||||
'enable_html5parser' => true,
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -32,16 +32,4 @@ return [
|
||||
// OAuth2 endpoints.
|
||||
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
|
||||
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
|
||||
|
||||
// Add extra scopes, upon those required, to the OIDC authentication request
|
||||
// Multiple values can be provided comma seperated.
|
||||
'additional_scopes' => env('OIDC_ADDITIONAL_SCOPES', null),
|
||||
|
||||
// Group sync options
|
||||
// Enable syncing, upon login, of OIDC groups to BookStack roles
|
||||
'user_to_groups' => env('OIDC_USER_TO_GROUPS', false),
|
||||
// Attribute, within a OIDC ID token, to find group names within
|
||||
'groups_claim' => env('OIDC_GROUPS_CLAIM', 'groups'),
|
||||
// When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups.
|
||||
'remove_from_groups' => env('OIDC_REMOVE_FROM_GROUPS', false),
|
||||
];
|
||||
|
||||
@@ -119,7 +119,6 @@ return [
|
||||
'ldap' => [
|
||||
'server' => env('LDAP_SERVER', false),
|
||||
'dump_user_details' => env('LDAP_DUMP_USER_DETAILS', false),
|
||||
'dump_user_groups' => env('LDAP_DUMP_USER_GROUPS', false),
|
||||
'dn' => env('LDAP_DN', false),
|
||||
'pass' => env('LDAP_PASS', false),
|
||||
'base_dn' => env('LDAP_BASE_DN', false),
|
||||
|
||||
@@ -72,7 +72,7 @@ return [
|
||||
// to the server if the browser has a HTTPS connection. This will keep
|
||||
// the cookie from being sent to you if it can not be done securely.
|
||||
'secure' => env('SESSION_SECURE_COOKIE', null)
|
||||
?? Str::startsWith(env('APP_URL', ''), 'https:'),
|
||||
?? Str::startsWith(env('APP_URL'), 'https:'),
|
||||
|
||||
// HTTP Access Only
|
||||
// Setting this value to true will prevent JavaScript from accessing the
|
||||
|
||||
@@ -26,8 +26,6 @@ return [
|
||||
|
||||
// User-level default settings
|
||||
'user' => [
|
||||
'ui-shortcuts' => '{}',
|
||||
'ui-shortcuts-enabled' => false,
|
||||
'dark-mode-enabled' => env('APP_DEFAULT_DARK_MODE', false),
|
||||
'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
|
||||
'bookshelf_view_type' => env('APP_VIEWS_BOOKSHELF', 'grid'),
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* Configuration should be altered via the `.env` file or environment variables.
|
||||
* Do not edit this file unless you're happy to maintain any changes yourself.
|
||||
*/
|
||||
|
||||
$snappyPaperSizeMap = [
|
||||
'a4' => 'A4',
|
||||
'letter' => 'Letter',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Tools\PermissionsUpdater;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CopyShelfPermissions extends Command
|
||||
@@ -25,16 +25,19 @@ class CopyShelfPermissions extends Command
|
||||
*/
|
||||
protected $description = 'Copy shelf permissions to all child books';
|
||||
|
||||
protected PermissionsUpdater $permissionsUpdater;
|
||||
/**
|
||||
* @var BookshelfRepo
|
||||
*/
|
||||
protected $bookshelfRepo;
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(PermissionsUpdater $permissionsUpdater)
|
||||
public function __construct(BookshelfRepo $repo)
|
||||
{
|
||||
$this->permissionsUpdater = $permissionsUpdater;
|
||||
$this->bookshelfRepo = $repo;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@@ -66,18 +69,18 @@ class CopyShelfPermissions extends Command
|
||||
return;
|
||||
}
|
||||
|
||||
$shelves = Bookshelf::query()->get(['id']);
|
||||
$shelves = Bookshelf::query()->get(['id', 'restricted']);
|
||||
}
|
||||
|
||||
if ($shelfSlug) {
|
||||
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id']);
|
||||
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id', 'restricted']);
|
||||
if ($shelves->count() === 0) {
|
||||
$this->info('No shelves found with the given slug.');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($shelves as $shelf) {
|
||||
$this->permissionsUpdater->updateBookPermissionsFromShelf($shelf, false);
|
||||
$this->bookshelfRepo->copyDownPermissions($shelf, false);
|
||||
$this->info('Copied permissions for shelf [' . $shelf->id . ']');
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace BookStack\Console\Commands;
|
||||
use BookStack\Actions\Comment;
|
||||
use BookStack\Actions\CommentRepo;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegenerateCommentContent extends Command
|
||||
{
|
||||
@@ -44,9 +43,9 @@ class RegenerateCommentContent extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$connection = DB::getDefaultConnection();
|
||||
$connection = \DB::getDefaultConnection();
|
||||
if ($this->option('database') !== null) {
|
||||
DB::setDefaultConnection($this->option('database'));
|
||||
\DB::setDefaultConnection($this->option('database'));
|
||||
}
|
||||
|
||||
Comment::query()->chunk(100, function ($comments) {
|
||||
@@ -56,9 +55,7 @@ class RegenerateCommentContent extends Command
|
||||
}
|
||||
});
|
||||
|
||||
DB::setDefaultConnection($connection);
|
||||
\DB::setDefaultConnection($connection);
|
||||
$this->comment('Comment HTML content has been regenerated');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegeneratePermissions extends Command
|
||||
{
|
||||
@@ -22,14 +21,19 @@ class RegeneratePermissions extends Command
|
||||
*/
|
||||
protected $description = 'Regenerate all system permissions';
|
||||
|
||||
protected CollapsedPermissionBuilder $permissionBuilder;
|
||||
/**
|
||||
* The service to handle the permission system.
|
||||
*
|
||||
* @var PermissionService
|
||||
*/
|
||||
protected $permissionService;
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*/
|
||||
public function __construct(CollapsedPermissionBuilder $permissionBuilder)
|
||||
public function __construct(PermissionService $permissionService)
|
||||
{
|
||||
$this->permissionBuilder = $permissionBuilder;
|
||||
$this->permissionService = $permissionService;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
@@ -40,17 +44,15 @@ class RegeneratePermissions extends Command
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$connection = DB::getDefaultConnection();
|
||||
|
||||
if ($this->option('database')) {
|
||||
DB::setDefaultConnection($this->option('database'));
|
||||
$connection = \DB::getDefaultConnection();
|
||||
if ($this->option('database') !== null) {
|
||||
\DB::setDefaultConnection($this->option('database'));
|
||||
$this->permissionService->setConnection(\DB::connection($this->option('database')));
|
||||
}
|
||||
|
||||
$this->permissionBuilder->rebuildForAll();
|
||||
$this->permissionService->buildJointPermissions();
|
||||
|
||||
DB::setDefaultConnection($connection);
|
||||
\DB::setDefaultConnection($connection);
|
||||
$this->comment('Permissions regenerated');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\References\ReferenceStore;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class RegenerateReferences extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'bookstack:regenerate-references {--database= : The database connection to use.}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Regenerate all the cross-item model reference index';
|
||||
|
||||
protected ReferenceStore $references;
|
||||
|
||||
/**
|
||||
* Create a new command instance.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function __construct(ReferenceStore $references)
|
||||
{
|
||||
$this->references = $references;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$connection = DB::getDefaultConnection();
|
||||
|
||||
if ($this->option('database')) {
|
||||
DB::setDefaultConnection($this->option('database'));
|
||||
}
|
||||
|
||||
$this->references->updateForAllPages();
|
||||
|
||||
DB::setDefaultConnection($connection);
|
||||
|
||||
$this->comment('References have been regenerated');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace BookStack\Console\Commands;
|
||||
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Search\SearchIndex;
|
||||
use BookStack\Entities\Tools\SearchIndex;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ use Illuminate\Support\Collection;
|
||||
* @property \Illuminate\Database\Eloquent\Collection $chapters
|
||||
* @property \Illuminate\Database\Eloquent\Collection $pages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $directPages
|
||||
* @property \Illuminate\Database\Eloquent\Collection $shelves
|
||||
*/
|
||||
class Book extends Entity implements HasCoverImage
|
||||
{
|
||||
@@ -28,7 +27,7 @@ class Book extends Entity implements HasCoverImage
|
||||
public $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description'];
|
||||
protected $hidden = ['pivot', 'image_id', 'deleted_at'];
|
||||
protected $hidden = ['restricted', 'pivot', 'image_id', 'deleted_at'];
|
||||
|
||||
/**
|
||||
* Get the url for this book.
|
||||
@@ -120,13 +119,4 @@ class Book extends Entity implements HasCoverImage
|
||||
|
||||
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible book by its slug.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlug(string $slug): self
|
||||
{
|
||||
return static::visible()->where('slug', '=', $slug)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
@@ -58,16 +57,11 @@ abstract class BookChild extends Entity
|
||||
*/
|
||||
public function changeBook(int $newBookId): Entity
|
||||
{
|
||||
$oldUrl = $this->getUrl();
|
||||
$this->book_id = $newBookId;
|
||||
$this->refreshSlug();
|
||||
$this->save();
|
||||
$this->refresh();
|
||||
|
||||
if ($oldUrl !== $this->getUrl()) {
|
||||
app()->make(ReferenceUpdater::class)->updateEntityPageReferences($this, $oldUrl);
|
||||
}
|
||||
|
||||
// Update all child pages if a chapter
|
||||
if ($this instanceof Chapter) {
|
||||
foreach ($this->pages()->withTrashed()->get() as $page) {
|
||||
|
||||
@@ -17,7 +17,7 @@ class Bookshelf extends Entity implements HasCoverImage
|
||||
|
||||
protected $fillable = ['name', 'description', 'image_id'];
|
||||
|
||||
protected $hidden = ['image_id', 'deleted_at'];
|
||||
protected $hidden = ['restricted', 'image_id', 'deleted_at'];
|
||||
|
||||
/**
|
||||
* Get the books in this shelf.
|
||||
@@ -86,11 +86,15 @@ class Bookshelf extends Entity implements HasCoverImage
|
||||
*/
|
||||
public function coverImageTypeKey(): string
|
||||
{
|
||||
return 'cover_bookshelf';
|
||||
return 'cover_shelf';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this shelf contains the given book.
|
||||
*
|
||||
* @param Book $book
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function contains(Book $book): bool
|
||||
{
|
||||
@@ -99,6 +103,8 @@ class Bookshelf extends Entity implements HasCoverImage
|
||||
|
||||
/**
|
||||
* Add a book to the end of this shelf.
|
||||
*
|
||||
* @param Book $book
|
||||
*/
|
||||
public function appendBook(Book $book)
|
||||
{
|
||||
@@ -109,13 +115,4 @@ class Bookshelf extends Entity implements HasCoverImage
|
||||
$maxOrder = $this->books()->max('order');
|
||||
$this->books()->attach($book->id, ['order' => $maxOrder + 1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible shelf by its slug.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlug(string $slug): self
|
||||
{
|
||||
return static::visible()->where('slug', '=', $slug)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class Chapter extends BookChild
|
||||
public $searchFactor = 1.2;
|
||||
|
||||
protected $fillable = ['name', 'description', 'priority'];
|
||||
protected $hidden = ['pivot', 'deleted_at'];
|
||||
protected $hidden = ['restricted', 'pivot', 'deleted_at'];
|
||||
|
||||
/**
|
||||
* Get the pages that this chapter contains.
|
||||
@@ -58,13 +58,4 @@ class Chapter extends BookChild
|
||||
->orderBy('priority', 'asc')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible chapter by its book and page slugs.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlugs(string $bookSlug, string $chapterSlug): self
|
||||
{
|
||||
return static::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,16 +10,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $deleted_by
|
||||
* @property string $deletable_type
|
||||
* @property int $deletable_id
|
||||
* @property Deletable $deletable
|
||||
*/
|
||||
class Deletion extends Model implements Loggable
|
||||
{
|
||||
protected $hidden = [];
|
||||
|
||||
/**
|
||||
* Get the related deletable record.
|
||||
*/
|
||||
|
||||
@@ -7,20 +7,17 @@ use BookStack\Actions\Comment;
|
||||
use BookStack\Actions\Favourite;
|
||||
use BookStack\Actions\Tag;
|
||||
use BookStack\Actions\View;
|
||||
use BookStack\Auth\Permissions\CollapsedPermission;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\Permissions\CollapsedPermissionBuilder;
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Auth\Permissions\JointPermission;
|
||||
use BookStack\Entities\Tools\SearchIndex;
|
||||
use BookStack\Entities\Tools\SlugGenerator;
|
||||
use BookStack\Facades\Permissions;
|
||||
use BookStack\Interfaces\Deletable;
|
||||
use BookStack\Interfaces\Favouritable;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Interfaces\Sluggable;
|
||||
use BookStack\Interfaces\Viewable;
|
||||
use BookStack\Model;
|
||||
use BookStack\References\Reference;
|
||||
use BookStack\Search\SearchIndex;
|
||||
use BookStack\Search\SearchTerm;
|
||||
use BookStack\Traits\HasCreatorAndUpdater;
|
||||
use BookStack\Traits\HasOwner;
|
||||
use Carbon\Carbon;
|
||||
@@ -42,9 +39,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
* @property Carbon $deleted_at
|
||||
* @property int $created_by
|
||||
* @property int $updated_by
|
||||
* @property bool $restricted
|
||||
* @property Collection $tags
|
||||
*
|
||||
* @method static Entity|Builder visible()
|
||||
* @method static Entity|Builder hasPermission(string $permission)
|
||||
* @method static Builder withLastView()
|
||||
* @method static Builder withViewCount()
|
||||
*/
|
||||
@@ -69,7 +68,15 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
*/
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
return app()->make(PermissionApplicator::class)->restrictEntityQuery($query, $this->getMorphClass());
|
||||
return $this->scopeHasPermission($query, 'view');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope the query to those entities that the current user has the given permission for.
|
||||
*/
|
||||
public function scopeHasPermission(Builder $query, string $permission)
|
||||
{
|
||||
return Permissions::restrictEntityQuery($query, $permission);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,23 +182,24 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
*/
|
||||
public function permissions(): MorphMany
|
||||
{
|
||||
return $this->morphMany(EntityPermission::class, 'entity');
|
||||
return $this->morphMany(EntityPermission::class, 'restrictable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this entity has a specific restriction set against it.
|
||||
*/
|
||||
public function hasPermissions(): bool
|
||||
public function hasRestriction(int $role_id, string $action): bool
|
||||
{
|
||||
return $this->permissions()->count() > 0;
|
||||
return $this->permissions()->where('role_id', '=', $role_id)
|
||||
->where('action', '=', $action)->count() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the entity collapsed permissions this is connected to.
|
||||
* Get the entity jointPermissions this is connected to.
|
||||
*/
|
||||
public function collapsedPermissions(): MorphMany
|
||||
public function jointPermissions(): MorphMany
|
||||
{
|
||||
return $this->morphMany(CollapsedPermission::class, 'entity');
|
||||
return $this->morphMany(JointPermission::class, 'entity');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,22 +210,6 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
return $this->morphMany(Deletion::class, 'deletable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the references pointing from this entity to other items.
|
||||
*/
|
||||
public function referencesFrom(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Reference::class, 'from');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the references pointing to this entity from other items.
|
||||
*/
|
||||
public function referencesTo(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Reference::class, 'to');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this instance or class is a certain type of entity.
|
||||
* Examples of $type are 'page', 'book', 'chapter'.
|
||||
@@ -292,7 +284,8 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
*/
|
||||
public function rebuildPermissions()
|
||||
{
|
||||
app()->make(CollapsedPermissionBuilder::class)->rebuildForEntity(clone $this);
|
||||
/** @noinspection PhpUnhandledExceptionInspection */
|
||||
Permissions::buildJointPermissionsForEntity(clone $this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -300,7 +293,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
*/
|
||||
public function indexForSearch()
|
||||
{
|
||||
app()->make(SearchIndex::class)->indexEntity(clone $this);
|
||||
app(SearchIndex::class)->indexEntity(clone $this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,7 +301,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
|
||||
*/
|
||||
public function refreshSlug(): string
|
||||
{
|
||||
$this->slug = app()->make(SlugGenerator::class)->generate($this);
|
||||
$this->slug = app(SlugGenerator::class)->generate($this);
|
||||
|
||||
return $this->slug;
|
||||
}
|
||||
|
||||
@@ -2,31 +2,27 @@
|
||||
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Facades\Permissions;
|
||||
use BookStack\Uploads\Attachment;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
/**
|
||||
* Class Page.
|
||||
*
|
||||
* @property int $chapter_id
|
||||
* @property string $html
|
||||
* @property string $markdown
|
||||
* @property string $text
|
||||
* @property bool $template
|
||||
* @property bool $draft
|
||||
* @property int $revision_count
|
||||
* @property string $editor
|
||||
* @property Chapter $chapter
|
||||
* @property Collection $attachments
|
||||
* @property Collection $revisions
|
||||
* @property PageRevision $currentRevision
|
||||
* @property int $chapter_id
|
||||
* @property string $html
|
||||
* @property string $markdown
|
||||
* @property string $text
|
||||
* @property bool $template
|
||||
* @property bool $draft
|
||||
* @property int $revision_count
|
||||
* @property Chapter $chapter
|
||||
* @property Collection $attachments
|
||||
*/
|
||||
class Page extends BookChild
|
||||
{
|
||||
@@ -39,7 +35,7 @@ class Page extends BookChild
|
||||
|
||||
public $textField = 'text';
|
||||
|
||||
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];
|
||||
protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot', 'deleted_at'];
|
||||
|
||||
protected $casts = [
|
||||
'draft' => 'boolean',
|
||||
@@ -51,7 +47,7 @@ class Page extends BookChild
|
||||
*/
|
||||
public function scopeVisible(Builder $query): Builder
|
||||
{
|
||||
$query = app()->make(PermissionApplicator::class)->restrictDraftsOnPageQuery($query);
|
||||
$query = Permissions::enforceDraftVisibilityOnQuery($query);
|
||||
|
||||
return parent::scopeVisible($query);
|
||||
}
|
||||
@@ -86,17 +82,6 @@ class Page extends BookChild
|
||||
->orderBy('id', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current revision for the page if existing.
|
||||
*/
|
||||
public function currentRevision(): HasOne
|
||||
{
|
||||
return $this->hasOne(PageRevision::class)
|
||||
->where('type', '=', 'version')
|
||||
->orderBy('created_at', 'desc')
|
||||
->orderBy('id', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all revision instances assigned to this page.
|
||||
* Includes all types of revisions.
|
||||
@@ -132,6 +117,16 @@ class Page extends BookChild
|
||||
return url('/' . implode('/', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current revision for the page if existing.
|
||||
*
|
||||
* @return PageRevision|null
|
||||
*/
|
||||
public function getCurrentRevision()
|
||||
{
|
||||
return $this->revisions()->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get this page for JSON display.
|
||||
*/
|
||||
@@ -143,13 +138,4 @@ class Page extends BookChild
|
||||
|
||||
return $refreshed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a visible page by its book and page slugs.
|
||||
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
|
||||
*/
|
||||
public static function getBySlugs(string $bookSlug, string $pageSlug): self
|
||||
{
|
||||
return static::visible()->whereSlugs($bookSlug, $pageSlug)->firstOrFail();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Interfaces\Loggable;
|
||||
use BookStack\Model;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -11,9 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
/**
|
||||
* Class PageRevision.
|
||||
*
|
||||
* @property mixed $id
|
||||
* @property int $page_id
|
||||
* @property string $name
|
||||
* @property string $slug
|
||||
* @property string $book_slug
|
||||
* @property int $created_by
|
||||
@@ -23,15 +20,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* @property string $summary
|
||||
* @property string $markdown
|
||||
* @property string $html
|
||||
* @property string $text
|
||||
* @property int $revision_number
|
||||
* @property Page $page
|
||||
* @property-read ?User $createdBy
|
||||
*/
|
||||
class PageRevision extends Model implements Loggable
|
||||
class PageRevision extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'text', 'summary'];
|
||||
protected $hidden = ['html', 'markdown', 'text'];
|
||||
protected $fillable = ['name', 'html', 'text', 'markdown', 'summary'];
|
||||
|
||||
/**
|
||||
* Get the user that created the page revision.
|
||||
@@ -84,9 +79,4 @@ class PageRevision extends Model implements Loggable
|
||||
{
|
||||
return $type === 'revision';
|
||||
}
|
||||
|
||||
public function logDescriptor(): string
|
||||
{
|
||||
return "Revision #{$this->revision_number} (ID: {$this->id}) for page ID {$this->page_id}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search;
|
||||
namespace BookStack\Entities\Models;
|
||||
|
||||
use BookStack\Model;
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
namespace BookStack\Entities\Queries;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
|
||||
abstract class EntityQuery
|
||||
{
|
||||
protected function permissionService(): PermissionApplicator
|
||||
protected function permissionService(): PermissionService
|
||||
{
|
||||
return app()->make(PermissionApplicator::class);
|
||||
return app()->make(PermissionService::class);
|
||||
}
|
||||
|
||||
protected function entityProvider(): EntityProvider
|
||||
|
||||
@@ -7,10 +7,10 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Popular extends EntityQuery
|
||||
{
|
||||
public function run(int $count, int $page, array $filterModels = null)
|
||||
public function run(int $count, int $page, array $filterModels = null, string $action = 'view')
|
||||
{
|
||||
$query = $this->permissionService()
|
||||
->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type')
|
||||
->filterRestrictedEntityRelations(View::query(), 'views', 'viewable_id', 'viewable_type', $action)
|
||||
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
|
||||
->groupBy('viewable_id', 'viewable_type')
|
||||
->orderBy('view_count', 'desc');
|
||||
|
||||
@@ -14,11 +14,12 @@ class RecentlyViewed extends EntityQuery
|
||||
return collect();
|
||||
}
|
||||
|
||||
$query = $this->permissionService()->restrictEntityRelationQuery(
|
||||
$query = $this->permissionService()->filterRestrictedEntityRelations(
|
||||
View::query(),
|
||||
'views',
|
||||
'viewable_id',
|
||||
'viewable_type'
|
||||
'viewable_type',
|
||||
'view'
|
||||
)
|
||||
->orderBy('views.updated_at', 'desc')
|
||||
->where('user_id', '=', user()->id);
|
||||
|
||||
@@ -15,7 +15,7 @@ class TopFavourites extends EntityQuery
|
||||
}
|
||||
|
||||
$query = $this->permissionService()
|
||||
->restrictEntityRelationQuery(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type')
|
||||
->filterRestrictedEntityRelations(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type', 'view')
|
||||
->select('favourites.*')
|
||||
->leftJoin('views', function (JoinClause $join) {
|
||||
$join->on('favourites.favouritable_id', '=', 'views.viewable_id');
|
||||
|
||||
@@ -6,21 +6,18 @@ use BookStack\Actions\TagRepo;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\HasCoverImage;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use BookStack\Uploads\ImageRepo;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class BaseRepo
|
||||
{
|
||||
protected TagRepo $tagRepo;
|
||||
protected ImageRepo $imageRepo;
|
||||
protected ReferenceUpdater $referenceUpdater;
|
||||
protected $tagRepo;
|
||||
protected $imageRepo;
|
||||
|
||||
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo, ReferenceUpdater $referenceUpdater)
|
||||
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
|
||||
{
|
||||
$this->tagRepo = $tagRepo;
|
||||
$this->imageRepo = $imageRepo;
|
||||
$this->referenceUpdater = $referenceUpdater;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +38,6 @@ class BaseRepo
|
||||
$this->tagRepo->saveTagsToEntity($entity, $input['tags']);
|
||||
}
|
||||
|
||||
$entity->refresh();
|
||||
$entity->rebuildPermissions();
|
||||
$entity->indexForSearch();
|
||||
}
|
||||
@@ -51,12 +47,10 @@ class BaseRepo
|
||||
*/
|
||||
public function update(Entity $entity, array $input)
|
||||
{
|
||||
$oldUrl = $entity->getUrl();
|
||||
|
||||
$entity->fill($input);
|
||||
$entity->updated_by = user()->id;
|
||||
|
||||
if ($entity->isDirty('name') || empty($entity->slug)) {
|
||||
if ($entity->isDirty('name')) {
|
||||
$entity->refreshSlug();
|
||||
}
|
||||
|
||||
@@ -64,15 +58,10 @@ class BaseRepo
|
||||
|
||||
if (isset($input['tags'])) {
|
||||
$this->tagRepo->saveTagsToEntity($entity, $input['tags']);
|
||||
$entity->touch();
|
||||
}
|
||||
|
||||
$entity->rebuildPermissions();
|
||||
$entity->indexForSearch();
|
||||
|
||||
if ($oldUrl !== $entity->getUrl()) {
|
||||
$this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,15 +75,14 @@ class BaseRepo
|
||||
public function updateCoverImage($entity, ?UploadedFile $coverImage, bool $removeImage = false)
|
||||
{
|
||||
if ($coverImage) {
|
||||
$imageType = $entity->coverImageTypeKey();
|
||||
$this->imageRepo->destroyImage($entity->cover()->first());
|
||||
$image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true);
|
||||
$this->imageRepo->destroyImage($entity->cover);
|
||||
$image = $this->imageRepo->saveNew($coverImage, 'cover_book', $entity->id, 512, 512, true);
|
||||
$entity->cover()->associate($image);
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
if ($removeImage) {
|
||||
$this->imageRepo->destroyImage($entity->cover()->first());
|
||||
$this->imageRepo->destroyImage($entity->cover);
|
||||
$entity->image_id = 0;
|
||||
$entity->save();
|
||||
}
|
||||
|
||||
@@ -91,7 +91,6 @@ class BookRepo
|
||||
{
|
||||
$book = new Book();
|
||||
$this->baseRepo->create($book, $input);
|
||||
$this->baseRepo->updateCoverImage($book, $input['image'] ?? null);
|
||||
Activity::add(ActivityType::BOOK_CREATE, $book);
|
||||
|
||||
return $book;
|
||||
@@ -103,11 +102,6 @@ class BookRepo
|
||||
public function update(Book $book, array $input): Book
|
||||
{
|
||||
$this->baseRepo->update($book, $input);
|
||||
|
||||
if (array_key_exists('image', $input)) {
|
||||
$this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null);
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::BOOK_UPDATE, $book);
|
||||
|
||||
return $book;
|
||||
|
||||
@@ -6,10 +6,12 @@ use BookStack\Actions\ActivityType;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Facades\Activity;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class BookshelfRepo
|
||||
@@ -87,7 +89,6 @@ class BookshelfRepo
|
||||
{
|
||||
$shelf = new Bookshelf();
|
||||
$this->baseRepo->create($shelf, $input);
|
||||
$this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null);
|
||||
$this->updateBooks($shelf, $bookIds);
|
||||
Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf);
|
||||
|
||||
@@ -105,17 +106,14 @@ class BookshelfRepo
|
||||
$this->updateBooks($shelf, $bookIds);
|
||||
}
|
||||
|
||||
if (array_key_exists('image', $input)) {
|
||||
$this->baseRepo->updateCoverImage($shelf, $input['image'], $input['image'] === null);
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::BOOKSHELF_UPDATE, $shelf);
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update which books are assigned to this shelf by syncing the given book ids.
|
||||
* Update which books are assigned to this shelf by
|
||||
* syncing the given book ids.
|
||||
* Function ensures the books are visible to the current user and existing.
|
||||
*/
|
||||
protected function updateBooks(Bookshelf $shelf, array $bookIds)
|
||||
@@ -134,6 +132,42 @@ class BookshelfRepo
|
||||
$shelf->books()->sync($syncData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the given shelf cover image, or clear it.
|
||||
*
|
||||
* @throws ImageUploadException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function updateCoverImage(Bookshelf $shelf, ?UploadedFile $coverImage, bool $removeImage = false)
|
||||
{
|
||||
$this->baseRepo->updateCoverImage($shelf, $coverImage, $removeImage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy down the permissions of the given shelf to all child books.
|
||||
*/
|
||||
public function copyDownPermissions(Bookshelf $shelf, $checkUserPermissions = true): int
|
||||
{
|
||||
$shelfPermissions = $shelf->permissions()->get(['role_id', 'action'])->toArray();
|
||||
$shelfBooks = $shelf->books()->get(['id', 'restricted']);
|
||||
$updatedBookCount = 0;
|
||||
|
||||
/** @var Book $book */
|
||||
foreach ($shelfBooks as $book) {
|
||||
if ($checkUserPermissions && !userCan('restrictions-manage', $book)) {
|
||||
continue;
|
||||
}
|
||||
$book->permissions()->delete();
|
||||
$book->restricted = $shelf->restricted;
|
||||
$book->permissions()->createMany($shelfPermissions);
|
||||
$book->save();
|
||||
$book->rebuildPermissions();
|
||||
$updatedBookCount++;
|
||||
}
|
||||
|
||||
return $updatedBookCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a bookshelf from the system.
|
||||
*
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Repos;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Entities\Models\Deletion;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Facades\Activity;
|
||||
|
||||
class DeletionRepo
|
||||
{
|
||||
private TrashCan $trashCan;
|
||||
|
||||
public function __construct(TrashCan $trashCan)
|
||||
{
|
||||
$this->trashCan = $trashCan;
|
||||
}
|
||||
|
||||
public function restore(int $id): int
|
||||
{
|
||||
/** @var Deletion $deletion */
|
||||
$deletion = Deletion::query()->findOrFail($id);
|
||||
Activity::add(ActivityType::RECYCLE_BIN_RESTORE, $deletion);
|
||||
|
||||
return $this->trashCan->restoreFromDeletion($deletion);
|
||||
}
|
||||
|
||||
public function destroy(int $id): int
|
||||
{
|
||||
/** @var Deletion $deletion */
|
||||
$deletion = Deletion::query()->findOrFail($id);
|
||||
Activity::add(ActivityType::RECYCLE_BIN_DESTROY, $deletion);
|
||||
|
||||
return $this->trashCan->destroyFromDeletion($deletion);
|
||||
}
|
||||
}
|
||||
@@ -10,37 +10,25 @@ use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Models\PageRevision;
|
||||
use BookStack\Entities\Tools\BookContents;
|
||||
use BookStack\Entities\Tools\PageContent;
|
||||
use BookStack\Entities\Tools\PageEditorData;
|
||||
use BookStack\Entities\Tools\TrashCan;
|
||||
use BookStack\Exceptions\MoveOperationException;
|
||||
use BookStack\Exceptions\NotFoundException;
|
||||
use BookStack\Exceptions\PermissionsException;
|
||||
use BookStack\Facades\Activity;
|
||||
use BookStack\References\ReferenceStore;
|
||||
use BookStack\References\ReferenceUpdater;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class PageRepo
|
||||
{
|
||||
protected BaseRepo $baseRepo;
|
||||
protected RevisionRepo $revisionRepo;
|
||||
protected ReferenceStore $referenceStore;
|
||||
protected ReferenceUpdater $referenceUpdater;
|
||||
protected $baseRepo;
|
||||
|
||||
/**
|
||||
* PageRepo constructor.
|
||||
*/
|
||||
public function __construct(
|
||||
BaseRepo $baseRepo,
|
||||
RevisionRepo $revisionRepo,
|
||||
ReferenceStore $referenceStore,
|
||||
ReferenceUpdater $referenceUpdater
|
||||
) {
|
||||
public function __construct(BaseRepo $baseRepo)
|
||||
{
|
||||
$this->baseRepo = $baseRepo;
|
||||
$this->revisionRepo = $revisionRepo;
|
||||
$this->referenceStore = $referenceStore;
|
||||
$this->referenceUpdater = $referenceUpdater;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +38,6 @@ class PageRepo
|
||||
*/
|
||||
public function getById(int $id, array $relations = ['book']): Page
|
||||
{
|
||||
/** @var Page $page */
|
||||
$page = Page::visible()->with($relations)->find($id);
|
||||
|
||||
if (!$page) {
|
||||
@@ -82,7 +69,17 @@ class PageRepo
|
||||
*/
|
||||
public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
|
||||
{
|
||||
$revision = $this->revisionRepo->getBySlugs($bookSlug, $pageSlug);
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = PageRevision::query()
|
||||
->whereHas('page', function (Builder $query) {
|
||||
$query->scopes('visible');
|
||||
})
|
||||
->where('slug', '=', $pageSlug)
|
||||
->where('type', '=', 'version')
|
||||
->where('book_slug', '=', $bookSlug)
|
||||
->orderBy('created_at', 'desc')
|
||||
->with('page')
|
||||
->first();
|
||||
|
||||
return $revision->page ?? null;
|
||||
}
|
||||
@@ -114,7 +111,7 @@ class PageRepo
|
||||
public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null): Entity
|
||||
{
|
||||
if ($chapterSlug !== null) {
|
||||
return Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
|
||||
return $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
|
||||
}
|
||||
|
||||
return Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
|
||||
@@ -125,7 +122,9 @@ class PageRepo
|
||||
*/
|
||||
public function getUserDraft(Page $page): ?PageRevision
|
||||
{
|
||||
return $this->revisionRepo->getLatestDraftForCurrentUser($page);
|
||||
$revision = $this->getUserDraftQuery($page)->first();
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,10 +164,11 @@ class PageRepo
|
||||
$draft->draft = false;
|
||||
$draft->revision_count = 1;
|
||||
$draft->priority = $this->getNewPriority($draft);
|
||||
$draft->refreshSlug();
|
||||
$draft->save();
|
||||
|
||||
$this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision'));
|
||||
$this->referenceStore->updateForPage($draft);
|
||||
$this->savePageRevision($draft, trans('entities.pages_initial_revision'));
|
||||
$draft->indexForSearch();
|
||||
$draft->refresh();
|
||||
|
||||
Activity::add(ActivityType::PAGE_CREATE, $draft);
|
||||
@@ -188,14 +188,13 @@ class PageRepo
|
||||
|
||||
$this->updateTemplateStatusAndContentFromInput($page, $input);
|
||||
$this->baseRepo->update($page, $input);
|
||||
$this->referenceStore->updateForPage($page);
|
||||
|
||||
// Update with new details
|
||||
$page->revision_count++;
|
||||
$page->save();
|
||||
|
||||
// Remove all update drafts for this user & page.
|
||||
$this->revisionRepo->deleteDraftsForCurrentUser($page);
|
||||
$this->getUserDraftQuery($page)->delete();
|
||||
|
||||
// Save a revision after updating
|
||||
$summary = trim($input['summary'] ?? '');
|
||||
@@ -203,7 +202,7 @@ class PageRepo
|
||||
$nameChanged = isset($input['name']) && $input['name'] !== $oldName;
|
||||
$markdownChanged = isset($input['markdown']) && $input['markdown'] !== $oldMarkdown;
|
||||
if ($htmlChanged || $nameChanged || $markdownChanged || $summary) {
|
||||
$this->revisionRepo->storeNewForPage($page, $summary);
|
||||
$this->savePageRevision($page, $summary);
|
||||
}
|
||||
|
||||
Activity::add(ActivityType::PAGE_UPDATE, $page);
|
||||
@@ -218,25 +217,33 @@ class PageRepo
|
||||
}
|
||||
|
||||
$pageContent = new PageContent($page);
|
||||
$currentEditor = $page->editor ?: PageEditorData::getSystemDefaultEditor();
|
||||
$newEditor = $currentEditor;
|
||||
|
||||
$haveInput = isset($input['markdown']) || isset($input['html']);
|
||||
$inputEmpty = empty($input['markdown']) && empty($input['html']);
|
||||
|
||||
if ($haveInput && $inputEmpty) {
|
||||
$pageContent->setNewHTML('');
|
||||
} elseif (!empty($input['markdown']) && is_string($input['markdown'])) {
|
||||
$newEditor = 'markdown';
|
||||
if (!empty($input['markdown'] ?? '')) {
|
||||
$pageContent->setNewMarkdown($input['markdown']);
|
||||
} elseif (isset($input['html'])) {
|
||||
$newEditor = 'wysiwyg';
|
||||
$pageContent->setNewHTML($input['html']);
|
||||
}
|
||||
}
|
||||
|
||||
if ($newEditor !== $currentEditor && userCan('editor-change')) {
|
||||
$page->editor = $newEditor;
|
||||
}
|
||||
/**
|
||||
* Saves a page revision into the system.
|
||||
*/
|
||||
protected function savePageRevision(Page $page, string $summary = null): PageRevision
|
||||
{
|
||||
$revision = new PageRevision($page->getAttributes());
|
||||
|
||||
$revision->page_id = $page->id;
|
||||
$revision->slug = $page->slug;
|
||||
$revision->book_slug = $page->book->slug;
|
||||
$revision->created_by = user()->id;
|
||||
$revision->created_at = $page->updated_at;
|
||||
$revision->type = 'version';
|
||||
$revision->summary = $summary;
|
||||
$revision->revision_number = $page->revision_count;
|
||||
$revision->save();
|
||||
|
||||
$this->deleteOldRevisions($page);
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -253,15 +260,10 @@ class PageRepo
|
||||
return $page;
|
||||
}
|
||||
|
||||
// Otherwise, save the data to a revision
|
||||
$draft = $this->revisionRepo->getNewDraftForCurrentUser($page);
|
||||
// Otherwise save the data to a revision
|
||||
$draft = $this->getPageRevisionToUpdate($page);
|
||||
$draft->fill($input);
|
||||
|
||||
if (!empty($input['markdown'])) {
|
||||
$draft->markdown = $input['markdown'];
|
||||
$draft->html = '';
|
||||
} else {
|
||||
$draft->html = $input['html'];
|
||||
if (setting('app-editor') !== 'markdown') {
|
||||
$draft->markdown = '';
|
||||
}
|
||||
|
||||
@@ -288,7 +290,6 @@ class PageRepo
|
||||
*/
|
||||
public function restoreRevision(Page $page, int $revisionId): Page
|
||||
{
|
||||
$oldUrl = $page->getUrl();
|
||||
$page->revision_count++;
|
||||
|
||||
/** @var PageRevision $revision */
|
||||
@@ -307,17 +308,11 @@ class PageRepo
|
||||
$page->refreshSlug();
|
||||
$page->save();
|
||||
$page->indexForSearch();
|
||||
$this->referenceStore->updateForPage($page);
|
||||
|
||||
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
|
||||
$this->revisionRepo->storeNewForPage($page, $summary);
|
||||
|
||||
if ($oldUrl !== $page->getUrl()) {
|
||||
$this->referenceUpdater->updateEntityPageReferences($page, $oldUrl);
|
||||
}
|
||||
$this->savePageRevision($page, $summary);
|
||||
|
||||
Activity::add(ActivityType::PAGE_RESTORE, $page);
|
||||
Activity::add(ActivityType::REVISION_RESTORE, $revision);
|
||||
|
||||
return $page;
|
||||
}
|
||||
@@ -373,6 +368,65 @@ class PageRepo
|
||||
return $parentClass::visible()->where('id', '=', $entityId)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the page's parent to the given entity.
|
||||
*/
|
||||
protected function changeParent(Page $page, Entity $parent)
|
||||
{
|
||||
$book = ($parent instanceof Chapter) ? $parent->book : $parent;
|
||||
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0;
|
||||
$page->save();
|
||||
|
||||
if ($page->book->id !== $book->id) {
|
||||
$page->changeBook($book->id);
|
||||
}
|
||||
|
||||
$page->load('book');
|
||||
$book->rebuildPermissions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a page revision to update for the given page.
|
||||
* Checks for an existing revisions before providing a fresh one.
|
||||
*/
|
||||
protected function getPageRevisionToUpdate(Page $page): PageRevision
|
||||
{
|
||||
$drafts = $this->getUserDraftQuery($page)->get();
|
||||
if ($drafts->count() > 0) {
|
||||
return $drafts->first();
|
||||
}
|
||||
|
||||
$draft = new PageRevision();
|
||||
$draft->page_id = $page->id;
|
||||
$draft->slug = $page->slug;
|
||||
$draft->book_slug = $page->book->slug;
|
||||
$draft->created_by = user()->id;
|
||||
$draft->type = 'update_draft';
|
||||
|
||||
return $draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old revisions, for the given page, from the system.
|
||||
*/
|
||||
protected function deleteOldRevisions(Page $page)
|
||||
{
|
||||
$revisionLimit = config('app.revision_limit');
|
||||
if ($revisionLimit === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$revisionsToDelete = PageRevision::query()
|
||||
->where('page_id', '=', $page->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->skip(intval($revisionLimit))
|
||||
->take(10)
|
||||
->get(['id']);
|
||||
if ($revisionsToDelete->count() > 0) {
|
||||
PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new priority for a page.
|
||||
*/
|
||||
@@ -388,4 +442,15 @@ class PageRepo
|
||||
|
||||
return (new BookContents($page->book))->getLastPriority() + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the query to find the user's draft copies of the given page.
|
||||
*/
|
||||
protected function getUserDraftQuery(Page $page)
|
||||
{
|
||||
return PageRevision::query()->where('created_by', '=', user()->id)
|
||||
->where('type', 'update_draft')
|
||||
->where('page_id', '=', $page->id)
|
||||
->orderBy('created_at', 'desc');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Repos;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Models\PageRevision;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class RevisionRepo
|
||||
{
|
||||
/**
|
||||
* Get a revision by its stored book and page slug values.
|
||||
*/
|
||||
public function getBySlugs(string $bookSlug, string $pageSlug): ?PageRevision
|
||||
{
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = PageRevision::query()
|
||||
->whereHas('page', function (Builder $query) {
|
||||
$query->scopes('visible');
|
||||
})
|
||||
->where('slug', '=', $pageSlug)
|
||||
->where('type', '=', 'version')
|
||||
->where('book_slug', '=', $bookSlug)
|
||||
->orderBy('created_at', 'desc')
|
||||
->with('page')
|
||||
->first();
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest draft revision, for the given page, belonging to the current user.
|
||||
*/
|
||||
public function getLatestDraftForCurrentUser(Page $page): ?PageRevision
|
||||
{
|
||||
/** @var ?PageRevision $revision */
|
||||
$revision = $this->queryForCurrentUserDraft($page->id)->first();
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all drafts revisions, for the given page, belonging to the current user.
|
||||
*/
|
||||
public function deleteDraftsForCurrentUser(Page $page): void
|
||||
{
|
||||
$this->queryForCurrentUserDraft($page->id)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user update_draft page revision to update for the given page.
|
||||
* Checks for an existing revisions before providing a fresh one.
|
||||
*/
|
||||
public function getNewDraftForCurrentUser(Page $page): PageRevision
|
||||
{
|
||||
$draft = $this->getLatestDraftForCurrentUser($page);
|
||||
|
||||
if ($draft) {
|
||||
return $draft;
|
||||
}
|
||||
|
||||
$draft = new PageRevision();
|
||||
$draft->page_id = $page->id;
|
||||
$draft->slug = $page->slug;
|
||||
$draft->book_slug = $page->book->slug;
|
||||
$draft->created_by = user()->id;
|
||||
$draft->type = 'update_draft';
|
||||
|
||||
return $draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new revision in the system for the given page.
|
||||
*/
|
||||
public function storeNewForPage(Page $page, string $summary = null): PageRevision
|
||||
{
|
||||
$revision = new PageRevision();
|
||||
|
||||
$revision->name = $page->name;
|
||||
$revision->html = $page->html;
|
||||
$revision->markdown = $page->markdown;
|
||||
$revision->text = $page->text;
|
||||
$revision->page_id = $page->id;
|
||||
$revision->slug = $page->slug;
|
||||
$revision->book_slug = $page->book->slug;
|
||||
$revision->created_by = user()->id;
|
||||
$revision->created_at = $page->updated_at;
|
||||
$revision->type = 'version';
|
||||
$revision->summary = $summary;
|
||||
$revision->revision_number = $page->revision_count;
|
||||
$revision->save();
|
||||
|
||||
$this->deleteOldRevisions($page);
|
||||
|
||||
return $revision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old revisions, for the given page, from the system.
|
||||
*/
|
||||
protected function deleteOldRevisions(Page $page)
|
||||
{
|
||||
$revisionLimit = config('app.revision_limit');
|
||||
if ($revisionLimit === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$revisionsToDelete = PageRevision::query()
|
||||
->where('page_id', '=', $page->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->skip(intval($revisionLimit))
|
||||
->take(10)
|
||||
->get(['id']);
|
||||
|
||||
if ($revisionsToDelete->count() > 0) {
|
||||
PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query update draft revisions for the current user.
|
||||
*/
|
||||
protected function queryForCurrentUserDraft(int $pageId): Builder
|
||||
{
|
||||
return PageRevision::query()
|
||||
->where('created_by', '=', user()->id)
|
||||
->where('type', 'update_draft')
|
||||
->where('page_id', '=', $pageId)
|
||||
->orderBy('created_at', 'desc');
|
||||
}
|
||||
}
|
||||
@@ -11,15 +11,22 @@ use Illuminate\Support\Collection;
|
||||
|
||||
class BookContents
|
||||
{
|
||||
protected Book $book;
|
||||
/**
|
||||
* @var Book
|
||||
*/
|
||||
protected $book;
|
||||
|
||||
/**
|
||||
* BookContents constructor.
|
||||
*/
|
||||
public function __construct(Book $book)
|
||||
{
|
||||
$this->book = $book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current priority of the last item at the top-level of the book.
|
||||
* Get the current priority of the last item
|
||||
* at the top-level of the book.
|
||||
*/
|
||||
public function getLastPriority(): int
|
||||
{
|
||||
@@ -181,7 +188,7 @@ class BookContents
|
||||
$model->changeBook($newBook->id);
|
||||
}
|
||||
|
||||
if ($model instanceof Page && $chapterChanged) {
|
||||
if ($chapterChanged) {
|
||||
$model->chapter_id = $newChapter->id ?? 0;
|
||||
}
|
||||
|
||||
@@ -235,7 +242,7 @@ class BookContents
|
||||
}
|
||||
|
||||
$hasPageEditPermission = userCan('page-update', $model);
|
||||
$newParentInRightLocation = ($newParent instanceof Book || ($newParent instanceof Chapter && $newParent->book_id === $newBook->id));
|
||||
$newParentInRightLocation = ($newParent instanceof Book || $newParent->book_id === $newBook->id);
|
||||
$newParentPermission = ($newParent instanceof Chapter) ? 'chapter-update' : 'book-update';
|
||||
$hasNewParentPermission = userCan($newParentPermission, $newParent);
|
||||
|
||||
|
||||
@@ -4,10 +4,8 @@ namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Actions\Tag;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\HasCoverImage;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\ChapterRepo;
|
||||
@@ -18,10 +16,25 @@ use Illuminate\Http\UploadedFile;
|
||||
|
||||
class Cloner
|
||||
{
|
||||
protected PageRepo $pageRepo;
|
||||
protected ChapterRepo $chapterRepo;
|
||||
protected BookRepo $bookRepo;
|
||||
protected ImageService $imageService;
|
||||
/**
|
||||
* @var PageRepo
|
||||
*/
|
||||
protected $pageRepo;
|
||||
|
||||
/**
|
||||
* @var ChapterRepo
|
||||
*/
|
||||
protected $chapterRepo;
|
||||
|
||||
/**
|
||||
* @var BookRepo
|
||||
*/
|
||||
protected $bookRepo;
|
||||
|
||||
/**
|
||||
* @var ImageService
|
||||
*/
|
||||
protected $imageService;
|
||||
|
||||
public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService)
|
||||
{
|
||||
@@ -37,8 +50,11 @@ class Cloner
|
||||
public function clonePage(Page $original, Entity $parent, string $newName): Page
|
||||
{
|
||||
$copyPage = $this->pageRepo->getNewDraftPage($parent);
|
||||
$pageData = $this->entityToInputData($original);
|
||||
$pageData = $original->getAttributes();
|
||||
|
||||
// Update name & tags
|
||||
$pageData['name'] = $newName;
|
||||
$pageData['tags'] = $this->entityTagsToInputArray($original);
|
||||
|
||||
return $this->pageRepo->publishDraft($copyPage, $pageData);
|
||||
}
|
||||
@@ -49,8 +65,9 @@ class Cloner
|
||||
*/
|
||||
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
|
||||
{
|
||||
$chapterDetails = $this->entityToInputData($original);
|
||||
$chapterDetails = $original->getAttributes();
|
||||
$chapterDetails['name'] = $newName;
|
||||
$chapterDetails['tags'] = $this->entityTagsToInputArray($original);
|
||||
|
||||
$copyChapter = $this->chapterRepo->create($chapterDetails, $parent);
|
||||
|
||||
@@ -70,13 +87,12 @@ class Cloner
|
||||
*/
|
||||
public function cloneBook(Book $original, string $newName): Book
|
||||
{
|
||||
$bookDetails = $this->entityToInputData($original);
|
||||
$bookDetails = $original->getAttributes();
|
||||
$bookDetails['name'] = $newName;
|
||||
$bookDetails['tags'] = $this->entityTagsToInputArray($original);
|
||||
|
||||
// Clone book
|
||||
$copyBook = $this->bookRepo->create($bookDetails);
|
||||
|
||||
// Clone contents
|
||||
$directChildren = $original->getDirectChildren();
|
||||
foreach ($directChildren as $child) {
|
||||
if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) {
|
||||
@@ -88,57 +104,26 @@ class Cloner
|
||||
}
|
||||
}
|
||||
|
||||
// Clone bookshelf relationships
|
||||
/** @var Bookshelf $shelf */
|
||||
foreach ($original->shelves as $shelf) {
|
||||
if (userCan('bookshelf-update', $shelf)) {
|
||||
$shelf->appendBook($copyBook);
|
||||
if ($original->cover) {
|
||||
try {
|
||||
$tmpImgFile = tmpfile();
|
||||
$uploadedFile = $this->imageToUploadedFile($original->cover, $tmpImgFile);
|
||||
$this->bookRepo->updateCoverImage($copyBook, $uploadedFile, false);
|
||||
} catch (\Exception $exception) {
|
||||
}
|
||||
}
|
||||
|
||||
return $copyBook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an entity to a raw data array of input data.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function entityToInputData(Entity $entity): array
|
||||
{
|
||||
$inputData = $entity->getAttributes();
|
||||
$inputData['tags'] = $this->entityTagsToInputArray($entity);
|
||||
|
||||
// Add a cover to the data if existing on the original entity
|
||||
if ($entity instanceof HasCoverImage) {
|
||||
$cover = $entity->cover()->first();
|
||||
if ($cover) {
|
||||
$inputData['image'] = $this->imageToUploadedFile($cover);
|
||||
}
|
||||
}
|
||||
|
||||
return $inputData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the permission settings from the source entity to the target entity.
|
||||
*/
|
||||
public function copyEntityPermissions(Entity $sourceEntity, Entity $targetEntity): void
|
||||
{
|
||||
$permissions = $sourceEntity->permissions()->get(['role_id', 'view', 'create', 'update', 'delete'])->toArray();
|
||||
$targetEntity->permissions()->delete();
|
||||
$targetEntity->permissions()->createMany($permissions);
|
||||
$targetEntity->rebuildPermissions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an image instance to an UploadedFile instance to mimic
|
||||
* a file being uploaded.
|
||||
*/
|
||||
protected function imageToUploadedFile(Image $image): ?UploadedFile
|
||||
protected function imageToUploadedFile(Image $image, &$tmpFile): ?UploadedFile
|
||||
{
|
||||
$imgData = $this->imageService->getImageData($image);
|
||||
$tmpImgFilePath = tempnam(sys_get_temp_dir(), 'bs_cover_clone_');
|
||||
$tmpImgFilePath = stream_get_meta_data($tmpFile)['uri'];
|
||||
file_put_contents($tmpImgFilePath, $imgData);
|
||||
|
||||
return new UploadedFile($tmpImgFilePath, basename($image->path));
|
||||
|
||||
@@ -7,7 +7,6 @@ use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
||||
use BookStack\Uploads\ImageService;
|
||||
use BookStack\Util\CspService;
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMXPath;
|
||||
@@ -16,18 +15,16 @@ use Throwable;
|
||||
|
||||
class ExportFormatter
|
||||
{
|
||||
protected ImageService $imageService;
|
||||
protected PdfGenerator $pdfGenerator;
|
||||
protected CspService $cspService;
|
||||
protected $imageService;
|
||||
protected $pdfGenerator;
|
||||
|
||||
/**
|
||||
* ExportService constructor.
|
||||
*/
|
||||
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator, CspService $cspService)
|
||||
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator)
|
||||
{
|
||||
$this->imageService = $imageService;
|
||||
$this->pdfGenerator = $pdfGenerator;
|
||||
$this->cspService = $cspService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,10 +36,9 @@ class ExportFormatter
|
||||
public function pageToContainedHtml(Page $page)
|
||||
{
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$pageHtml = view('exports.page', [
|
||||
'page' => $page,
|
||||
'format' => 'html',
|
||||
'cspContent' => $this->cspService->getCspMetaTagValue(),
|
||||
$pageHtml = view('pages.export', [
|
||||
'page' => $page,
|
||||
'format' => 'html',
|
||||
])->render();
|
||||
|
||||
return $this->containHtml($pageHtml);
|
||||
@@ -59,11 +55,10 @@ class ExportFormatter
|
||||
$pages->each(function ($page) {
|
||||
$page->html = (new PageContent($page))->render();
|
||||
});
|
||||
$html = view('exports.chapter', [
|
||||
'chapter' => $chapter,
|
||||
'pages' => $pages,
|
||||
'format' => 'html',
|
||||
'cspContent' => $this->cspService->getCspMetaTagValue(),
|
||||
$html = view('chapters.export', [
|
||||
'chapter' => $chapter,
|
||||
'pages' => $pages,
|
||||
'format' => 'html',
|
||||
])->render();
|
||||
|
||||
return $this->containHtml($html);
|
||||
@@ -77,11 +72,10 @@ class ExportFormatter
|
||||
public function bookToContainedHtml(Book $book)
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$html = view('exports.book', [
|
||||
$html = view('books.export', [
|
||||
'book' => $book,
|
||||
'bookChildren' => $bookTree,
|
||||
'format' => 'html',
|
||||
'cspContent' => $this->cspService->getCspMetaTagValue(),
|
||||
])->render();
|
||||
|
||||
return $this->containHtml($html);
|
||||
@@ -95,7 +89,7 @@ class ExportFormatter
|
||||
public function pageToPdf(Page $page)
|
||||
{
|
||||
$page->html = (new PageContent($page))->render();
|
||||
$html = view('exports.page', [
|
||||
$html = view('pages.export', [
|
||||
'page' => $page,
|
||||
'format' => 'pdf',
|
||||
'engine' => $this->pdfGenerator->getActiveEngine(),
|
||||
@@ -116,7 +110,7 @@ class ExportFormatter
|
||||
$page->html = (new PageContent($page))->render();
|
||||
});
|
||||
|
||||
$html = view('exports.chapter', [
|
||||
$html = view('chapters.export', [
|
||||
'chapter' => $chapter,
|
||||
'pages' => $pages,
|
||||
'format' => 'pdf',
|
||||
@@ -134,7 +128,7 @@ class ExportFormatter
|
||||
public function bookToPdf(Book $book)
|
||||
{
|
||||
$bookTree = (new BookContents($book))->getTree(false, true);
|
||||
$html = view('exports.book', [
|
||||
$html = view('books.export', [
|
||||
'book' => $book,
|
||||
'bookChildren' => $bookTree,
|
||||
'format' => 'pdf',
|
||||
@@ -235,7 +229,7 @@ class ExportFormatter
|
||||
$linksOutput = [];
|
||||
preg_match_all("/\<a.*href\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $linksOutput);
|
||||
|
||||
// Update relative links to be absolute, with instance url
|
||||
// Replace image src with base64 encoded image strings
|
||||
if (isset($linksOutput[0]) && count($linksOutput[0]) > 0) {
|
||||
foreach ($linksOutput[0] as $index => $linkMatch) {
|
||||
$oldLinkString = $linkMatch;
|
||||
@@ -248,6 +242,7 @@ class ExportFormatter
|
||||
}
|
||||
}
|
||||
|
||||
// Replace any relative links with system domain
|
||||
return $htmlContent;
|
||||
}
|
||||
|
||||
@@ -325,7 +320,7 @@ class ExportFormatter
|
||||
$text .= $this->pageToMarkdown($page) . "\n\n";
|
||||
}
|
||||
|
||||
return trim($text);
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -337,12 +332,12 @@ class ExportFormatter
|
||||
$text = '# ' . $book->name . "\n\n";
|
||||
foreach ($bookTree as $bookChild) {
|
||||
if ($bookChild instanceof Chapter) {
|
||||
$text .= $this->chapterToMarkdown($bookChild) . "\n\n";
|
||||
$text .= $this->chapterToMarkdown($bookChild);
|
||||
} else {
|
||||
$text .= $this->pageToMarkdown($bookChild) . "\n\n";
|
||||
$text .= $this->pageToMarkdown($bookChild);
|
||||
}
|
||||
}
|
||||
|
||||
return trim($text);
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Chapter;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\BookRepo;
|
||||
use BookStack\Entities\Repos\BookshelfRepo;
|
||||
use BookStack\Facades\Activity;
|
||||
|
||||
class HierarchyTransformer
|
||||
{
|
||||
protected BookRepo $bookRepo;
|
||||
protected BookshelfRepo $shelfRepo;
|
||||
protected Cloner $cloner;
|
||||
protected TrashCan $trashCan;
|
||||
|
||||
public function __construct(BookRepo $bookRepo, BookshelfRepo $shelfRepo, Cloner $cloner, TrashCan $trashCan)
|
||||
{
|
||||
$this->bookRepo = $bookRepo;
|
||||
$this->shelfRepo = $shelfRepo;
|
||||
$this->cloner = $cloner;
|
||||
$this->trashCan = $trashCan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a chapter into a book.
|
||||
* Does not check permissions, check before calling.
|
||||
*/
|
||||
public function transformChapterToBook(Chapter $chapter): Book
|
||||
{
|
||||
$inputData = $this->cloner->entityToInputData($chapter);
|
||||
$book = $this->bookRepo->create($inputData);
|
||||
$this->cloner->copyEntityPermissions($chapter, $book);
|
||||
|
||||
/** @var Page $page */
|
||||
foreach ($chapter->pages as $page) {
|
||||
$page->chapter_id = 0;
|
||||
$page->changeBook($book->id);
|
||||
}
|
||||
|
||||
$this->trashCan->destroyEntity($chapter);
|
||||
|
||||
Activity::add(ActivityType::BOOK_CREATE_FROM_CHAPTER, $book);
|
||||
|
||||
return $book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a book into a shelf.
|
||||
* Does not check permissions, check before calling.
|
||||
*/
|
||||
public function transformBookToShelf(Book $book): Bookshelf
|
||||
{
|
||||
$inputData = $this->cloner->entityToInputData($book);
|
||||
$shelf = $this->shelfRepo->create($inputData, []);
|
||||
$this->cloner->copyEntityPermissions($book, $shelf);
|
||||
|
||||
$shelfBookSyncData = [];
|
||||
|
||||
/** @var Chapter $chapter */
|
||||
foreach ($book->chapters as $index => $chapter) {
|
||||
$newBook = $this->transformChapterToBook($chapter);
|
||||
$shelfBookSyncData[$newBook->id] = ['order' => $index];
|
||||
if (!$newBook->hasPermissions()) {
|
||||
$this->cloner->copyEntityPermissions($shelf, $newBook);
|
||||
}
|
||||
}
|
||||
|
||||
if ($book->directPages->count() > 0) {
|
||||
$book->name .= ' ' . trans('entities.pages');
|
||||
$shelfBookSyncData[$book->id] = ['order' => count($shelfBookSyncData) + 1];
|
||||
$book->save();
|
||||
} else {
|
||||
$this->trashCan->destroyEntity($book);
|
||||
}
|
||||
|
||||
$shelf->books()->sync($shelfBookSyncData);
|
||||
|
||||
Activity::add(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $shelf);
|
||||
|
||||
return $shelf;
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools\Markdown;
|
||||
|
||||
use League\HTMLToMarkdown\Converter\ConverterInterface;
|
||||
use League\HTMLToMarkdown\ElementInterface;
|
||||
|
||||
class CheckboxConverter implements ConverterInterface
|
||||
{
|
||||
public function convert(ElementInterface $element): string
|
||||
{
|
||||
if (strtolower($element->getAttribute('type')) === 'checkbox') {
|
||||
$isChecked = $element->getAttribute('checked') === 'checked';
|
||||
|
||||
return $isChecked ? ' [x] ' : ' [ ] ';
|
||||
}
|
||||
|
||||
return $element->getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public function getSupportedTags(): array
|
||||
{
|
||||
return ['input'];
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools\Markdown;
|
||||
|
||||
use League\HTMLToMarkdown\Converter\DivConverter;
|
||||
use League\HTMLToMarkdown\ElementInterface;
|
||||
|
||||
class CustomDivConverter extends DivConverter
|
||||
{
|
||||
public function convert(ElementInterface $element): string
|
||||
{
|
||||
// Clean up draw.io diagrams
|
||||
$drawIoDiagram = $element->getAttribute('drawio-diagram');
|
||||
if ($drawIoDiagram) {
|
||||
return "<div drawio-diagram=\"{$drawIoDiagram}\">{$element->getValue()}</div>\n\n";
|
||||
}
|
||||
|
||||
return parent::convert($element);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools\Markdown;
|
||||
|
||||
use League\HTMLToMarkdown\Converter\ImageConverter;
|
||||
use League\HTMLToMarkdown\ElementInterface;
|
||||
|
||||
class CustomImageConverter extends ImageConverter
|
||||
{
|
||||
public function convert(ElementInterface $element): string
|
||||
{
|
||||
$parent = $element->getParent();
|
||||
|
||||
// Remain as HTML if within diagram block.
|
||||
$withinDrawing = $parent && !empty($parent->getAttribute('drawio-diagram'));
|
||||
if ($withinDrawing) {
|
||||
$src = e($element->getAttribute('src'));
|
||||
$alt = e($element->getAttribute('alt'));
|
||||
|
||||
return "<img src=\"{$src}\" alt=\"{$alt}\"/>";
|
||||
}
|
||||
|
||||
return parent::convert($element);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ class CustomParagraphConverter extends ParagraphConverter
|
||||
{
|
||||
public function convert(ElementInterface $element): string
|
||||
{
|
||||
$class = e($element->getAttribute('class'));
|
||||
$class = $element->getAttribute('class');
|
||||
if (strpos($class, 'callout') !== false) {
|
||||
return "<{$element->getTagName()} class=\"{$class}\">{$element->getValue()}</{$element->getTagName()}>\n\n";
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ namespace BookStack\Entities\Tools\Markdown;
|
||||
use League\HTMLToMarkdown\Converter\BlockquoteConverter;
|
||||
use League\HTMLToMarkdown\Converter\CodeConverter;
|
||||
use League\HTMLToMarkdown\Converter\CommentConverter;
|
||||
use League\HTMLToMarkdown\Converter\DivConverter;
|
||||
use League\HTMLToMarkdown\Converter\EmphasisConverter;
|
||||
use League\HTMLToMarkdown\Converter\HardBreakConverter;
|
||||
use League\HTMLToMarkdown\Converter\HeaderConverter;
|
||||
use League\HTMLToMarkdown\Converter\HorizontalRuleConverter;
|
||||
use League\HTMLToMarkdown\Converter\ImageConverter;
|
||||
use League\HTMLToMarkdown\Converter\LinkConverter;
|
||||
use League\HTMLToMarkdown\Converter\ListBlockConverter;
|
||||
use League\HTMLToMarkdown\Converter\ListItemConverter;
|
||||
@@ -19,7 +21,7 @@ use League\HTMLToMarkdown\HtmlConverter;
|
||||
|
||||
class HtmlToMarkdown
|
||||
{
|
||||
protected string $html;
|
||||
protected $html;
|
||||
|
||||
public function __construct(string $html)
|
||||
{
|
||||
@@ -73,20 +75,18 @@ class HtmlToMarkdown
|
||||
$environment->addConverter(new BlockquoteConverter());
|
||||
$environment->addConverter(new CodeConverter());
|
||||
$environment->addConverter(new CommentConverter());
|
||||
$environment->addConverter(new CustomDivConverter());
|
||||
$environment->addConverter(new DivConverter());
|
||||
$environment->addConverter(new EmphasisConverter());
|
||||
$environment->addConverter(new HardBreakConverter());
|
||||
$environment->addConverter(new HeaderConverter());
|
||||
$environment->addConverter(new HorizontalRuleConverter());
|
||||
$environment->addConverter(new CustomImageConverter());
|
||||
$environment->addConverter(new ImageConverter());
|
||||
$environment->addConverter(new LinkConverter());
|
||||
$environment->addConverter(new ListBlockConverter());
|
||||
$environment->addConverter(new ListItemConverter());
|
||||
$environment->addConverter(new CustomParagraphConverter());
|
||||
$environment->addConverter(new PreformattedConverter());
|
||||
$environment->addConverter(new TextConverter());
|
||||
$environment->addConverter(new CheckboxConverter());
|
||||
$environment->addConverter(new SpacedTagFallbackConverter());
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools\Markdown;
|
||||
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
use League\CommonMark\Block\Element\ListItem;
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
use League\CommonMark\Environment;
|
||||
use League\CommonMark\Extension\Table\TableExtension;
|
||||
use League\CommonMark\Extension\TaskList\TaskListExtension;
|
||||
|
||||
class MarkdownToHtml
|
||||
{
|
||||
protected string $markdown;
|
||||
|
||||
public function __construct(string $markdown)
|
||||
{
|
||||
$this->markdown = $markdown;
|
||||
}
|
||||
|
||||
public function convert(): string
|
||||
{
|
||||
$environment = Environment::createCommonMarkEnvironment();
|
||||
$environment->addExtension(new TableExtension());
|
||||
$environment->addExtension(new TaskListExtension());
|
||||
$environment->addExtension(new CustomStrikeThroughExtension());
|
||||
$environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment;
|
||||
$converter = new CommonMarkConverter([], $environment);
|
||||
|
||||
$environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);
|
||||
|
||||
return $converter->convertToHtml($this->markdown);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools\Markdown;
|
||||
|
||||
use League\HTMLToMarkdown\Converter\ConverterInterface;
|
||||
use League\HTMLToMarkdown\ElementInterface;
|
||||
|
||||
/**
|
||||
* For certain defined tags, add additional spacing upon the retained HTML content
|
||||
* to separate it out from anything that may be markdown soon afterwards or within.
|
||||
*/
|
||||
class SpacedTagFallbackConverter implements ConverterInterface
|
||||
{
|
||||
public function convert(ElementInterface $element): string
|
||||
{
|
||||
return \html_entity_decode($element->getChildrenAsString()) . "\n\n";
|
||||
}
|
||||
|
||||
public function getSupportedTags(): array
|
||||
{
|
||||
return ['summary', 'iframe'];
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||
use BookStack\Entities\Tools\Markdown\CustomListItemRenderer;
|
||||
use BookStack\Entities\Tools\Markdown\CustomStrikeThroughExtension;
|
||||
use BookStack\Exceptions\ImageUploadException;
|
||||
use BookStack\Facades\Theme;
|
||||
use BookStack\Theming\ThemeEvents;
|
||||
@@ -16,10 +17,15 @@ use DOMNode;
|
||||
use DOMNodeList;
|
||||
use DOMXPath;
|
||||
use Illuminate\Support\Str;
|
||||
use League\CommonMark\Block\Element\ListItem;
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
use League\CommonMark\Environment;
|
||||
use League\CommonMark\Extension\Table\TableExtension;
|
||||
use League\CommonMark\Extension\TaskList\TaskListExtension;
|
||||
|
||||
class PageContent
|
||||
{
|
||||
protected Page $page;
|
||||
protected $page;
|
||||
|
||||
/**
|
||||
* PageContent constructor.
|
||||
@@ -47,11 +53,28 @@ class PageContent
|
||||
{
|
||||
$markdown = $this->extractBase64ImagesFromMarkdown($markdown);
|
||||
$this->page->markdown = $markdown;
|
||||
$html = (new MarkdownToHtml($markdown))->convert();
|
||||
$html = $this->markdownToHtml($markdown);
|
||||
$this->page->html = $this->formatHtml($html);
|
||||
$this->page->text = $this->toPlainText();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the given Markdown content to a HTML string.
|
||||
*/
|
||||
protected function markdownToHtml(string $markdown): string
|
||||
{
|
||||
$environment = Environment::createCommonMarkEnvironment();
|
||||
$environment->addExtension(new TableExtension());
|
||||
$environment->addExtension(new TaskListExtension());
|
||||
$environment->addExtension(new CustomStrikeThroughExtension());
|
||||
$environment = Theme::dispatch(ThemeEvents::COMMONMARK_ENVIRONMENT_CONFIGURE, $environment) ?? $environment;
|
||||
$converter = new CommonMarkConverter([], $environment);
|
||||
|
||||
$environment->addBlockRenderer(ListItem::class, new CustomListItemRenderer(), 10);
|
||||
|
||||
return $converter->convertToHtml($markdown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert all base64 image data to saved images.
|
||||
*/
|
||||
@@ -216,9 +239,6 @@ class PageContent
|
||||
$html .= $doc->saveHTML($childNode);
|
||||
}
|
||||
|
||||
// Perform required string-level tweaks
|
||||
$html = str_replace(' ', ' ', $html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
@@ -374,30 +394,23 @@ class PageContent
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find page to use, and default replacement to empty string for non-matches.
|
||||
// Find page and skip this if page not found
|
||||
/** @var ?Page $matchedPage */
|
||||
$matchedPage = Page::visible()->find($pageId);
|
||||
$replacement = '';
|
||||
|
||||
if ($matchedPage && count($splitInclude) === 1) {
|
||||
// If we only have page id, just insert all page html and continue.
|
||||
$replacement = $matchedPage->html;
|
||||
} elseif ($matchedPage && count($splitInclude) > 1) {
|
||||
// Otherwise, if our include tag defines a section, load that specific content
|
||||
$innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
|
||||
$replacement = trim($innerContent);
|
||||
if ($matchedPage === null) {
|
||||
$html = str_replace($fullMatch, '', $html);
|
||||
continue;
|
||||
}
|
||||
|
||||
$themeReplacement = Theme::dispatch(
|
||||
ThemeEvents::PAGE_INCLUDE_PARSE,
|
||||
$includeId,
|
||||
$replacement,
|
||||
clone $this->page,
|
||||
$matchedPage ? (clone $matchedPage) : null,
|
||||
);
|
||||
// If we only have page id, just insert all page html and continue.
|
||||
if (count($splitInclude) === 1) {
|
||||
$html = str_replace($fullMatch, $matchedPage->html, $html);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Perform the content replacement
|
||||
$html = str_replace($fullMatch, $themeReplacement ?? $replacement, $html);
|
||||
// Create and load HTML into a document
|
||||
$innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
|
||||
$html = str_replace($fullMatch, trim($innerContent), $html);
|
||||
}
|
||||
|
||||
return $html;
|
||||
|
||||
@@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class PageEditActivity
|
||||
{
|
||||
protected Page $page;
|
||||
protected $page;
|
||||
|
||||
/**
|
||||
* PageEditActivity constructor.
|
||||
@@ -42,7 +42,7 @@ class PageEditActivity
|
||||
$userMessage = trans('entities.pages_draft_edit_active.start_b', ['userName' => $firstDraft->createdBy->name ?? '']);
|
||||
}
|
||||
|
||||
$timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount' => 60]);
|
||||
$timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount'=> 60]);
|
||||
|
||||
return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Repos\PageRepo;
|
||||
use BookStack\Entities\Tools\Markdown\HtmlToMarkdown;
|
||||
use BookStack\Entities\Tools\Markdown\MarkdownToHtml;
|
||||
|
||||
class PageEditorData
|
||||
{
|
||||
protected Page $page;
|
||||
protected PageRepo $pageRepo;
|
||||
protected string $requestedEditor;
|
||||
|
||||
protected array $viewData;
|
||||
protected array $warnings;
|
||||
|
||||
public function __construct(Page $page, PageRepo $pageRepo, string $requestedEditor)
|
||||
{
|
||||
$this->page = $page;
|
||||
$this->pageRepo = $pageRepo;
|
||||
$this->requestedEditor = $requestedEditor;
|
||||
|
||||
$this->viewData = $this->build();
|
||||
}
|
||||
|
||||
public function getViewData(): array
|
||||
{
|
||||
return $this->viewData;
|
||||
}
|
||||
|
||||
public function getWarnings(): array
|
||||
{
|
||||
return $this->warnings;
|
||||
}
|
||||
|
||||
protected function build(): array
|
||||
{
|
||||
$page = clone $this->page;
|
||||
$isDraft = boolval($this->page->draft);
|
||||
$templates = $this->pageRepo->getTemplates(10);
|
||||
$draftsEnabled = auth()->check();
|
||||
|
||||
$isDraftRevision = false;
|
||||
$this->warnings = [];
|
||||
$editActivity = new PageEditActivity($page);
|
||||
|
||||
if ($editActivity->hasActiveEditing()) {
|
||||
$this->warnings[] = $editActivity->activeEditingMessage();
|
||||
}
|
||||
|
||||
// Check for a current draft version for this user
|
||||
$userDraft = $this->pageRepo->getUserDraft($page);
|
||||
if ($userDraft !== null) {
|
||||
$page->forceFill($userDraft->only(['name', 'html', 'markdown']));
|
||||
$isDraftRevision = true;
|
||||
$this->warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft);
|
||||
}
|
||||
|
||||
$editorType = $this->getEditorType($page);
|
||||
$this->updateContentForEditor($page, $editorType);
|
||||
|
||||
return [
|
||||
'page' => $page,
|
||||
'book' => $page->book,
|
||||
'isDraft' => $isDraft,
|
||||
'isDraftRevision' => $isDraftRevision,
|
||||
'draftsEnabled' => $draftsEnabled,
|
||||
'templates' => $templates,
|
||||
'editor' => $editorType,
|
||||
];
|
||||
}
|
||||
|
||||
protected function updateContentForEditor(Page $page, string $editorType): void
|
||||
{
|
||||
$isHtml = !empty($page->html) && empty($page->markdown);
|
||||
|
||||
// HTML to markdown-clean conversion
|
||||
if ($editorType === 'markdown' && $isHtml && $this->requestedEditor === 'markdown-clean') {
|
||||
$page->markdown = (new HtmlToMarkdown($page->html))->convert();
|
||||
}
|
||||
|
||||
// Markdown to HTML conversion if we don't have HTML
|
||||
if ($editorType === 'wysiwyg' && !$isHtml) {
|
||||
$page->html = (new MarkdownToHtml($page->markdown))->convert();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of editor to show for editing the given page.
|
||||
* Defaults based upon the current content of the page otherwise will fall back
|
||||
* to system default but will take a requested type (if provided) if permissions allow.
|
||||
*/
|
||||
protected function getEditorType(Page $page): string
|
||||
{
|
||||
$editorType = $page->editor ?: self::getSystemDefaultEditor();
|
||||
|
||||
// Use requested editor if valid and if we have permission
|
||||
$requestedType = explode('-', $this->requestedEditor)[0];
|
||||
if (($requestedType === 'markdown' || $requestedType === 'wysiwyg') && userCan('editor-change')) {
|
||||
$editorType = $requestedType;
|
||||
}
|
||||
|
||||
return $editorType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured system default editor.
|
||||
*/
|
||||
public static function getSystemDefaultEditor(): string
|
||||
{
|
||||
return setting('app-editor') === 'markdown' ? 'markdown' : 'wysiwyg';
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use Barryvdh\DomPDF\Facade\Pdf as DomPDF;
|
||||
use Barryvdh\DomPDF\Facade as DomPDF;
|
||||
use Barryvdh\Snappy\Facades\SnappyPdf;
|
||||
|
||||
class PdfGenerator
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Actions\ActivityType;
|
||||
use BookStack\Auth\Permissions\EntityPermission;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\Models\Book;
|
||||
use BookStack\Entities\Models\Bookshelf;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Facades\Activity;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class PermissionsUpdater
|
||||
{
|
||||
@@ -18,9 +16,11 @@ class PermissionsUpdater
|
||||
*/
|
||||
public function updateFromPermissionsForm(Entity $entity, Request $request)
|
||||
{
|
||||
$permissions = $request->get('permissions', null);
|
||||
$restricted = $request->get('restricted') === 'true';
|
||||
$permissions = $request->get('restrictions', null);
|
||||
$ownerId = $request->get('owned_by', null);
|
||||
|
||||
$entity->restricted = $restricted;
|
||||
$entity->permissions()->delete();
|
||||
|
||||
if (!is_null($permissions)) {
|
||||
@@ -52,60 +52,18 @@ class PermissionsUpdater
|
||||
}
|
||||
|
||||
/**
|
||||
* Format permissions provided from a permission form to be EntityPermission data.
|
||||
* Format permissions provided from a permission form to be
|
||||
* EntityPermission data.
|
||||
*/
|
||||
protected function formatPermissionsFromRequestToEntityPermissions(array $permissions): array
|
||||
protected function formatPermissionsFromRequestToEntityPermissions(array $permissions): Collection
|
||||
{
|
||||
$formatted = [];
|
||||
$columnsByType = [
|
||||
'role' => 'role_id',
|
||||
'user' => 'user_id',
|
||||
'fallback' => '',
|
||||
];
|
||||
|
||||
foreach ($permissions as $type => $byId) {
|
||||
$column = $columnsByType[$type] ?? null;
|
||||
if (is_null($column)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($byId as $id => $info) {
|
||||
$entityPermissionData = [];
|
||||
|
||||
if (!empty($column)) {
|
||||
$entityPermissionData[$column] = $id;
|
||||
}
|
||||
|
||||
foreach (EntityPermission::PERMISSIONS as $permission) {
|
||||
$entityPermissionData[$permission] = (($info[$permission] ?? false) === "true");
|
||||
}
|
||||
$formatted[] = $entityPermissionData;
|
||||
}
|
||||
}
|
||||
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy down the permissions of the given shelf to all child books.
|
||||
*/
|
||||
public function updateBookPermissionsFromShelf(Bookshelf $shelf, $checkUserPermissions = true): int
|
||||
{
|
||||
$shelfPermissions = $shelf->permissions()->get(['role_id', 'view', 'create', 'update', 'delete'])->toArray();
|
||||
$shelfBooks = $shelf->books()->get(['id', 'owned_by']);
|
||||
$updatedBookCount = 0;
|
||||
|
||||
/** @var Book $book */
|
||||
foreach ($shelfBooks as $book) {
|
||||
if ($checkUserPermissions && !userCan('restrictions-manage', $book)) {
|
||||
continue;
|
||||
}
|
||||
$book->permissions()->delete();
|
||||
$book->permissions()->createMany($shelfPermissions);
|
||||
$book->rebuildPermissions();
|
||||
$updatedBookCount++;
|
||||
}
|
||||
|
||||
return $updatedBookCount;
|
||||
return collect($permissions)->flatMap(function ($restrictions, $roleId) {
|
||||
return collect($restrictions)->keys()->map(function ($action) use ($roleId) {
|
||||
return [
|
||||
'role_id' => $roleId,
|
||||
'action' => strtolower($action),
|
||||
];
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search;
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Actions\Tag;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Models\SearchTerm;
|
||||
use DOMDocument;
|
||||
use DOMNode;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -146,8 +147,6 @@ class SearchIndex
|
||||
];
|
||||
|
||||
$html = '<body>' . $html . '</body>';
|
||||
$html = str_ireplace(['<br>', '<br />', '<br/>'], "\n", $html);
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$doc = new DOMDocument();
|
||||
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
|
||||
@@ -1,15 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search;
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SearchOptions
|
||||
{
|
||||
public array $searches = [];
|
||||
public array $exacts = [];
|
||||
public array $tags = [];
|
||||
public array $filters = [];
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public $searches = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public $exacts = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public $tags = [];
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
public $filters = [];
|
||||
|
||||
/**
|
||||
* Create a new instance from a search string.
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search;
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Actions\Tag;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
@@ -1,13 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace BookStack\Search;
|
||||
namespace BookStack\Entities\Tools;
|
||||
|
||||
use BookStack\Auth\Permissions\PermissionApplicator;
|
||||
use BookStack\Auth\Permissions\PermissionService;
|
||||
use BookStack\Auth\User;
|
||||
use BookStack\Entities\EntityProvider;
|
||||
use BookStack\Entities\Models\BookChild;
|
||||
use BookStack\Entities\Models\Entity;
|
||||
use BookStack\Entities\Models\Page;
|
||||
use BookStack\Entities\Models\SearchTerm;
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
@@ -20,15 +21,22 @@ use SplObjectStorage;
|
||||
|
||||
class SearchRunner
|
||||
{
|
||||
protected EntityProvider $entityProvider;
|
||||
protected PermissionApplicator $permissions;
|
||||
/**
|
||||
* @var EntityProvider
|
||||
*/
|
||||
protected $entityProvider;
|
||||
|
||||
/**
|
||||
* @var PermissionService
|
||||
*/
|
||||
protected $permissionService;
|
||||
|
||||
/**
|
||||
* Acceptable operators to be used in a query.
|
||||
*
|
||||
* @var string[]
|
||||
* @var array
|
||||
*/
|
||||
protected array $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
|
||||
protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
|
||||
|
||||
/**
|
||||
* Retain a cache of score adjusted terms for specific search options.
|
||||
@@ -38,10 +46,10 @@ class SearchRunner
|
||||
*/
|
||||
protected $termAdjustmentCache;
|
||||
|
||||
public function __construct(EntityProvider $entityProvider, PermissionApplicator $permissions)
|
||||
public function __construct(EntityProvider $entityProvider, PermissionService $permissionService)
|
||||
{
|
||||
$this->entityProvider = $entityProvider;
|
||||
$this->permissions = $permissions;
|
||||
$this->permissionService = $permissionService;
|
||||
$this->termAdjustmentCache = new SplObjectStorage();
|
||||
}
|
||||
|
||||
@@ -50,9 +58,9 @@ class SearchRunner
|
||||
* The provided count is for each entity to search,
|
||||
* Total returned could be larger and not guaranteed.
|
||||
*
|
||||
* @return array{total: int, count: int, has_more: bool, results: Collection<Entity>}
|
||||
* @return array{total: int, count: int, has_more: bool, results: Entity[]}
|
||||
*/
|
||||
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20): array
|
||||
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array
|
||||
{
|
||||
$entityTypes = array_keys($this->entityProvider->all());
|
||||
$entityTypesToSearch = $entityTypes;
|
||||
@@ -73,7 +81,7 @@ class SearchRunner
|
||||
}
|
||||
|
||||
$entityModelInstance = $this->entityProvider->get($entityType);
|
||||
$searchQuery = $this->buildQuery($searchOpts, $entityModelInstance);
|
||||
$searchQuery = $this->buildQuery($searchOpts, $entityModelInstance, $action);
|
||||
$entityTotal = $searchQuery->count();
|
||||
$searchResults = $this->getPageOfDataFromQuery($searchQuery, $entityModelInstance, $page, $count);
|
||||
|
||||
@@ -157,12 +165,12 @@ class SearchRunner
|
||||
/**
|
||||
* Create a search query for an entity.
|
||||
*/
|
||||
protected function buildQuery(SearchOptions $searchOpts, Entity $entityModelInstance): EloquentBuilder
|
||||
protected function buildQuery(SearchOptions $searchOpts, Entity $entityModelInstance, string $action = 'view'): EloquentBuilder
|
||||
{
|
||||
$entityQuery = $entityModelInstance->newQuery()->scopes('visible');
|
||||
$entityQuery = $entityModelInstance->newQuery();
|
||||
|
||||
if ($entityModelInstance instanceof Page) {
|
||||
$entityQuery->select(array_merge($entityModelInstance::$listAttributes, ['owned_by']));
|
||||
$entityQuery->select($entityModelInstance::$listAttributes);
|
||||
} else {
|
||||
$entityQuery->select(['*']);
|
||||
}
|
||||
@@ -191,7 +199,7 @@ class SearchRunner
|
||||
}
|
||||
}
|
||||
|
||||
return $entityQuery;
|
||||
return $this->permissionService->enforceEntityRestrictions($entityModelInstance, $entityQuery, $action);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -223,7 +231,7 @@ class SearchRunner
|
||||
});
|
||||
$subQuery->groupBy('entity_type', 'entity_id');
|
||||
|
||||
$entityQuery->joinSub($subQuery, 's', 'id', '=', 's.entity_id');
|
||||
$entityQuery->joinSub($subQuery, 's', 'id', '=', 'entity_id');
|
||||
$entityQuery->addSelect('s.score');
|
||||
$entityQuery->orderBy('score', 'desc');
|
||||
}
|
||||
@@ -352,7 +360,7 @@ class SearchRunner
|
||||
/** @var Connection $connection */
|
||||
$connection = $query->getConnection();
|
||||
$tagValue = (float) trim($connection->getPdo()->quote($tagValue), "'");
|
||||
$query->whereRaw("value {$tagOperator} {$tagValue}");
|
||||
$query->whereRaw("value ${tagOperator} ${tagValue}");
|
||||
} else {
|
||||
$query->where('value', $tagOperator, $tagValue);
|
||||
}
|
||||
@@ -447,7 +455,7 @@ class SearchRunner
|
||||
|
||||
protected function filterIsRestricted(EloquentBuilder $query, Entity $model, $input)
|
||||
{
|
||||
$query->whereHas('permissions');
|
||||
$query->where('restricted', '=', true);
|
||||
}
|
||||
|
||||
protected function filterViewedByMe(EloquentBuilder $query, Entity $model, $input)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user